Source code for subsequence.tuning

"""Microtonal tuning support for Subsequence.

Provides the ``Tuning`` class for specifying alternative tuning systems,
a parser for Scala ``.scl`` files, and ``apply_tuning_to_pattern()`` which
injects per-note pitch bend events so that any MIDI-compatible synthesiser
can play the tuning without MPE or special hardware support.

Pitch bend is injected automatically:

- **Monophonic patterns** (no overlapping notes): a single pitch bend event
  precedes each note on the pattern's own channel.
- **Polyphonic patterns** (overlapping notes): notes are spread across an
  explicit channel pool via ``ChannelAllocator``.  Each channel receives an
  independent pitch bend, so simultaneous notes can carry different tuning
  offsets.  The channel pool must be supplied by the caller.

Typical usage via ``Composition.tuning()`` (applies globally, automatically):

    comp.tuning("meanquar.scl", bend_range=2.0)

Per-pattern override via the ``PatternBuilder.apply_tuning()`` post-build
transform:

    p.apply_tuning(Tuning.equal(19), bend_range=2.0)
"""

import dataclasses
import logging
import math
import os
import typing

import subsequence.pattern

logger = logging.getLogger(__name__)


# ── Tuning class ─────────────────────────────────────────────────────────────

@dataclasses.dataclass
[docs] class Tuning: """A microtonal tuning system expressed as cent offsets from the unison. The ``cents`` list contains the cent values for scale degrees 1 through N. Degree 0 (the unison, 0.0 cents) is always implicit and not stored. The last entry is typically 1200.0 cents (the octave) for octave-repeating scales, but any period is supported. Create a ``Tuning`` from a file or programmatically: Tuning.from_scl("meanquar.scl") # Scala .scl file Tuning.from_cents([100, 200, ..., 1200]) # explicit cents Tuning.from_ratios([9/8, 5/4, ..., 2]) # frequency ratios Tuning.equal(19) # 19-tone equal temperament """ cents: typing.List[float] description: str = "" @property
[docs] def size (self) -> int: """Number of scale degrees per period (the .scl ``count`` line).""" return len(self.cents)
@property
[docs] def period_cents (self) -> float: """Cent span of one period (typically 1200.0 for octave-repeating scales).""" return self.cents[-1] if self.cents else 1200.0
# ── Factory methods ─────────────────────────────────────────────────────── @classmethod
[docs] def from_scl (cls, source: typing.Union[str, os.PathLike]) -> "Tuning": """Parse a Scala .scl file. ``source`` is a file path. Lines beginning with ``!`` are comments. The first non-comment line is the description. The second is the integer count of pitch values. Each subsequent line is a pitch: - Contains ``.`` → cents (float). - Contains ``/`` or is a bare integer → ratio; converted to cents via ``1200 × log₂(ratio)``. Raises ``ValueError`` for malformed files. """ with open(source, "r", encoding="utf-8") as fh: text = fh.read() return cls._parse_scl_text(text)
@classmethod
[docs] def from_scl_string (cls, text: str) -> "Tuning": """Parse a Scala .scl file from a string (useful for testing).""" return cls._parse_scl_text(text)
@classmethod def _parse_scl_text (cls, text: str) -> "Tuning": lines = [line.rstrip() for line in text.splitlines()] non_comment: typing.List[str] = [l for l in lines if not l.lstrip().startswith("!")] if len(non_comment) < 2: raise ValueError("Malformed .scl: need description + count lines") description = non_comment[0].strip() try: count = int(non_comment[1].strip()) except ValueError: raise ValueError(f"Malformed .scl: expected integer count, got {non_comment[1]!r}") pitch_lines = non_comment[2:2 + count] if len(pitch_lines) < count: raise ValueError( f"Malformed .scl: expected {count} pitch values, got {len(pitch_lines)}" ) cents_list: typing.List[float] = [] for raw in pitch_lines: # Text after the pitch value is ignored (Scala spec) token = raw.split()[0] if raw.split() else "" cents_list.append(cls._parse_pitch_token(token)) return cls(cents=cents_list, description=description) @staticmethod def _parse_pitch_token (token: str) -> float: """Convert a single .scl pitch token to cents.""" if not token: raise ValueError("Empty pitch token in .scl file") if "." in token: # Cents value return float(token) if "/" in token: # Ratio like 3/2 num_str, den_str = token.split("/", 1) ratio = int(num_str) / int(den_str) else: # Bare integer like 2 (interpreted as 2/1) ratio = float(token) if ratio <= 0: raise ValueError(f"Non-positive ratio in .scl: {token!r}") return 1200.0 * math.log2(ratio) @classmethod
[docs] def from_cents (cls, cents: typing.List[float], description: str = "") -> "Tuning": """Construct a tuning from a list of cent values for degrees 1..N. The implicit degree 0 (unison, 0.0 cents) is not included in ``cents``. The last value is typically 1200.0 for an octave-repeating scale. """ return cls(cents=list(cents), description=description)
@classmethod
[docs] def from_ratios (cls, ratios: typing.List[float], description: str = "") -> "Tuning": """Construct a tuning from frequency ratios relative to 1/1. Each ratio is converted to cents via ``1200 × log₂(ratio)``. Pass ``2`` or ``2.0`` for the octave (1200 cents). """ cents = [1200.0 * math.log2(r) for r in ratios] return cls(cents=cents, description=description)
@classmethod
[docs] def equal (cls, divisions: int = 12, period: float = 1200.0) -> "Tuning": """Construct an equal-tempered tuning with ``divisions`` equal steps per period. ``Tuning.equal(12)`` is standard 12-TET (no pitch bend needed). ``Tuning.equal(19)`` gives 19-tone equal temperament. """ step = period / divisions cents = [step * i for i in range(1, divisions + 1)] return cls( cents=cents, description=f"{divisions}-tone equal temperament", )
# ── Core calculation ──────────────────────────────────────────────────────
[docs] def pitch_bend_for_note ( self, midi_note: int, reference_note: int = 60, bend_range: float = 2.0, ) -> typing.Tuple[int, float]: """Return ``(nearest_12tet_note, bend_normalized)`` for a MIDI note number. The MIDI note number is interpreted as a scale degree relative to ``reference_note`` (default 60 = C4, degree 0 of the scale). The tuning's cent table determines the exact frequency, and the nearest 12-TET MIDI note plus a fractional pitch bend corrects the remainder. Parameters: midi_note: The MIDI note to tune (0–127). reference_note: MIDI note number that maps to degree 0 of the scale. bend_range: Pitch wheel range in semitones (must match the synth's pitch-bend range setting). Default ±2 semitones. Returns: A tuple ``(nearest_note, bend_normalized)`` where ``nearest_note`` is the integer MIDI note to send and ``bend_normalized`` is the normalised pitch bend value (-1.0 to +1.0). """ if self.size == 0: return midi_note, 0.0 steps_from_root = midi_note - reference_note degree = steps_from_root % self.size octave = steps_from_root // self.size # Cent value for this degree (degree 0 = 0.0, degree k = cents[k-1]) degree_cents = 0.0 if degree == 0 else self.cents[degree - 1] # Total cents from the root total_cents = octave * self.period_cents + degree_cents # Equivalent continuous 12-TET note number (100 cents per semitone) continuous = reference_note + total_cents / 100.0 nearest = int(round(continuous)) nearest = max(0, min(127, nearest)) offset_semitones = continuous - nearest # signed, in semitones if bend_range <= 0: bend_normalized = 0.0 else: bend_normalized = max(-1.0, min(1.0, offset_semitones / bend_range)) return nearest, bend_normalized
# ── Channel allocator ─────────────────────────────────────────────────────────
[docs] class ChannelAllocator: """Assign MIDI channels from a pool for polyphonic channel rotation. Tracks which channels are busy (a note is sounding) and which are free. Channels are reclaimed once a note ends (pulse ≥ release_pulse). A simple round-robin fallback is used when all channels are busy (simultaneous voices exceed pool size) — accompanied by a warning log. """ def __init__ (self, channels: typing.List[int]) -> None: if not channels: raise ValueError("ChannelAllocator requires at least one channel") self._channels = list(channels) # Map channel -> pulse at which it becomes free again self._release: typing.Dict[int, int] = {ch: 0 for ch in channels} self._rr_index = 0
[docs] def allocate (self, pulse: int, duration: int) -> int: """Return a free channel for a note starting at ``pulse`` lasting ``duration`` pulses.""" # Find a channel that is free at this pulse for ch in self._channels: if self._release[ch] <= pulse: self._release[ch] = pulse + duration return ch # All channels busy — round-robin with a warning ch = self._channels[self._rr_index % len(self._channels)] self._rr_index += 1 logger.warning( "ChannelAllocator: pool exhausted (%d channels, all busy at pulse %d). " "Simultaneous voices exceed pool size. Some pitch bends may conflict.", len(self._channels), pulse, ) self._release[ch] = pulse + duration return ch
# ── Pattern transform ─────────────────────────────────────────────────────────
[docs] def apply_tuning_to_pattern ( pattern: "subsequence.pattern.Pattern", tuning: Tuning, bend_range: float = 2.0, channels: typing.Optional[typing.List[int]] = None, reference_note: int = 60, ) -> None: """Apply a microtonal tuning to all notes in a pattern in place. For each note: 1. The nearest 12-TET MIDI note is computed and replaces ``note.pitch``. 2. A pitchwheel ``CcEvent`` is injected at the note's onset with the fractional bend that corrects from the nearest 12-TET pitch to the exact tuned frequency. 3. If ``channels`` is provided and the pattern has overlapping notes, notes are spread across the channel pool (``ChannelAllocator``). Existing pitchwheel events (e.g., from ``p.portamento()`` or ``p.slide()``) are shifted additively by the tuning offset of the note sounding at each pulse. Bend-reset-to-zero events are replaced with bend-reset-to-tuning-offset events. Parameters: pattern: The pattern to transform in place. tuning: The ``Tuning`` object specifying cent offsets. bend_range: Must match the MIDI synth's pitch-bend range setting (default ±2 semitones). channels: Optional explicit channel pool for polyphonic parts. When ``None``, all notes stay on ``pattern.channel``. Under polyphonic rotation, expression events created BEFORE this call (``portamento()``/``slide()`` bends) are not re-routed per note — apply tuning last, or avoid combining note-correlated bends with a channel pool. reference_note: MIDI note number mapped to scale degree 0. """ if not pattern.steps: return # ── Step 1: determine if polyphony requires channel rotation ───────────── allocator: typing.Optional[ChannelAllocator] = None if channels is not None: # Check whether the pattern actually has overlapping notes if _has_overlapping_notes(pattern): allocator = ChannelAllocator(channels) # Even if monophonic, use the first channel from the pool elif channels: # Re-assign the pattern's notes to the first pool channel for step in pattern.steps.values(): for note in step.notes: note.channel = channels[0] # Pre-existing expression events (portamento/slide bends, CCs) # with no explicit channel would otherwise stay on # pattern.channel — targeting a channel where nothing sounds and # escaping the additive-shift pass below. for ev in pattern.cc_events: if ev.channel is None: ev.channel = channels[0] # ── Step 2: build a pulse→(tuning_bend_normalized, note_channel) map ───── # We need this for two things: # - Injecting onset pitch bend events # - Shifting existing pitchwheel events additively # # tuning_map: pulse → list of (tuning_bend_raw_int, channel) # For overlapping notes on the same pulse, each gets its own channel. tuning_map: typing.Dict[int, typing.List[typing.Tuple[int, int]]] = {} for pulse, step in sorted(pattern.steps.items()): for note in step.notes: nearest, bend_norm = tuning.pitch_bend_for_note( note.pitch, reference_note=reference_note, bend_range=bend_range ) # Assign channel (rotation or single channel) if allocator is not None: note.channel = allocator.allocate(pulse, note.duration) # (else note.channel stays as set above or unchanged) # Replace pitch with nearest 12-TET note note.pitch = nearest bend_raw = _norm_to_raw(bend_norm) if pulse not in tuning_map: tuning_map[pulse] = [] tuning_map[pulse].append((bend_raw, note.channel)) # ── Step 3: shift existing pitchwheel events additively ────────────────── # Build a timeline of (pulse, note_end_pulse, bend_raw, channel) for # all tuning bends, so we can look up which tuning offset is active # at any given pulse/channel. # Sorted list of (onset_pulse, end_pulse, bend_raw, channel) timeline: typing.List[typing.Tuple[int, int, int, int]] = [] for pulse, step in sorted(pattern.steps.items()): for note in step.notes: # Find the bend_raw that was computed for this note/pulse/channel # (use the first matching entry for this channel) for br, ch in tuning_map.get(pulse, []): if ch == note.channel: timeline.append((pulse, pulse + note.duration, br, ch)) break def _active_bend_at (pulse: int, channel: int) -> int: """Return the tuning bend_raw active for (pulse, channel), or 0.""" result = 0 for onset, end, br, ch in timeline: if ch == channel and onset <= pulse < end: result = br break return result # Shift existing pitchwheel events new_cc: typing.List["subsequence.pattern.CcEvent"] = [] for ev in pattern.cc_events: if ev.message_type != "pitchwheel": new_cc.append(ev) continue ch = ev.channel if ev.channel is not None else pattern.channel active = _active_bend_at(ev.pulse, ch) if ev.value == 0: # Bend-reset: replace with tuning offset (so glides land correctly) shifted = active else: # Additive shift shifted = max(-8192, min(8191, ev.value + active)) new_cc.append(dataclasses.replace(ev, value=shifted)) pattern.cc_events = new_cc # ── Step 4: inject onset tuning bend events ─────────────────────────────── onset_events: typing.List["subsequence.pattern.CcEvent"] = [] for pulse, entries in sorted(tuning_map.items()): for bend_raw, channel in entries: onset_events.append( subsequence.pattern.CcEvent( pulse=pulse, message_type="pitchwheel", value=bend_raw, channel=channel, ) ) # Prepend onset bends (they must fire before note_on at the same pulse) pattern.cc_events = onset_events + pattern.cc_events
def _has_overlapping_notes (pattern: "subsequence.pattern.Pattern") -> bool: """Return True if any notes in the pattern overlap in time.""" # Build a list of (onset, offset) across all notes intervals: typing.List[typing.Tuple[int, int]] = [] for pulse, step in pattern.steps.items(): for note in step.notes: intervals.append((pulse, pulse + note.duration)) # Sort by onset; check if any start before the previous one ends intervals.sort() for i in range(1, len(intervals)): if intervals[i][0] < intervals[i - 1][1]: return True return False def _norm_to_raw (bend_normalized: float) -> int: """Convert a normalised pitch bend (-1.0 to +1.0) to a raw MIDI value (-8192 to +8191).""" return max(-8192, min(8191, int(round(bend_normalized * 8192))))