Source code for subsequence.voicings

"""Chord inversions and voice leading.

Provides functions for rotating chord intervals into different inversions and
choosing the smoothest voicing between consecutive chords. Voice leading
minimises the total semitone movement so that chord pads sound connected
rather than jumping around the keyboard.

Example:
	```python
	# Manual inversion
	from subsequence.voicings import invert_chord
	first_inv = invert_chord([0, 4, 7], inversion=1)  # [4, 7, 12]

	# Automatic voice leading across a pattern
	@composition.pattern(channel=0, length=4, voice_leading=True)
	def chords (p, chord):
		p.chord(chord, root=52, velocity=90, sustain=True)
	```
"""

import typing


[docs] def invert_chord (intervals: typing.List[int], inversion: int) -> typing.List[int]: """Rotate chord intervals to produce an inversion. Inversion 0 is root position. Inversion 1 raises the bottom note by an octave (first inversion). Wraps around for inversions >= the number of notes. Parameters: intervals: Chord intervals in semitones from root (e.g., ``[0, 4, 7]``) inversion: Which inversion to produce (0 = root position) Returns: Rotated interval list, still measured from the original chord root, so adding any root yields the same chord with a different bass note. Example: ```python invert_chord([0, 4, 7], 0) # [0, 4, 7] - root position invert_chord([0, 4, 7], 1) # [4, 7, 12] - first inversion (E-G-C) invert_chord([0, 4, 7], 2) # [7, 12, 16] - second inversion (G-C-E) ``` """ n = len(intervals) if n == 0: return [] inversion = inversion % n if inversion == 0: return list(intervals) # Keep the intervals anchored at the original root: re-zeroing to the new # bass note would change the chord's pitch classes once a caller adds the # root back (the pre-2026-06 bug — [0, 3, 8] is an Ab-major shape, not # C major first inversion). return intervals[inversion:] + [i + 12 for i in intervals[:inversion]]
[docs] def voice_lead (intervals: typing.List[int], root_midi: int, previous_voicing: typing.Optional[typing.List[int]]) -> typing.List[int]: """Find the inversion closest to a previous voicing. Tries every inversion, in the nearest octaves, and picks the candidate with the smallest total semitone movement from ``previous_voicing``. Voices are compared *positionally* (voice ``i`` to voice ``i``), so this picks the best inversion rather than the globally optimal voice reassignment. If ``previous_voicing`` is ``None`` or the chord sizes differ, returns root position. Parameters: intervals: Chord intervals in semitones from root (e.g., ``[0, 4, 7]``) root_midi: MIDI note number for the chord root previous_voicing: MIDI note numbers of the previous chord, or ``None`` Returns: MIDI note numbers for the best voicing """ n = len(intervals) if n == 0: return [] # No previous voicing or size mismatch - return root position. if previous_voicing is None or len(previous_voicing) != n: return [root_midi + i for i in intervals] best_voicing: typing.Optional[typing.List[int]] = None best_cost = float("inf") for inv in range(n): inv_intervals = invert_chord(intervals, inv) # Inversions are anchored upward from the root, so also try each one # an octave down (and up) — the smoothest voicing often sits below # the nominal root (e.g. C-F-A for F major approached from C major). for octave_offset in (0, -12, 12): candidate = [root_midi + i + octave_offset for i in inv_intervals] cost = sum(abs(candidate[i] - previous_voicing[i]) for i in range(n)) if cost < best_cost: best_cost = cost best_voicing = candidate assert best_voicing is not None return best_voicing
[docs] class VoiceLeadingState: """Track the previous voicing across chord changes. Each pattern that uses voice leading gets its own instance so that a bass line and a pad can voice-lead independently. Example: ```python state = VoiceLeadingState() voicing1 = state.next([0, 4, 7], 60) # root position (no previous) voicing2 = state.next([0, 3, 7], 60) # picks closest inversion to voicing1 ``` """ def __init__ (self) -> None: """Start with no previous voicing.""" self.previous_voicing: typing.Optional[typing.List[int]] = None
[docs] def next (self, intervals: typing.List[int], root_midi: int) -> typing.List[int]: """Choose the smoothest voicing and update state. Parameters: intervals: Chord intervals in semitones from root root_midi: MIDI note number for the chord root Returns: MIDI note numbers for the chosen voicing """ result = voice_lead(intervals, root_midi, self.previous_voicing) self.previous_voicing = result return result