subsequence.tuning ================== .. py:module:: subsequence.tuning .. autoapi-nested-parse:: 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) Module Contents --------------- .. py:class:: ChannelAllocator(channels: List[int]) 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. .. py:method:: allocate(pulse: int, duration: int) -> int Return a free channel for a note starting at ``pulse`` lasting ``duration`` pulses. .. py: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 .. py:method:: equal(divisions: int = 12, period: float = 1200.0) -> Tuning :classmethod: 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. .. py:method:: from_cents(cents: List[float], description: str = '') -> Tuning :classmethod: 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. .. py:method:: from_ratios(ratios: List[float], description: str = '') -> Tuning :classmethod: 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). .. py:method:: from_scl(source: Union[str, os.PathLike]) -> Tuning :classmethod: 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. .. py:method:: from_scl_string(text: str) -> Tuning :classmethod: Parse a Scala .scl file from a string (useful for testing). .. py:method:: pitch_bend_for_note(midi_note: int, reference_note: int = 60, bend_range: float = 2.0) -> 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. :param midi_note: The MIDI note to tune (0–127). :param reference_note: MIDI note number that maps to degree 0 of the scale. :param 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). .. py:property:: period_cents :type: float Cent span of one period (typically 1200.0 for octave-repeating scales). .. py:property:: size :type: int Number of scale degrees per period (the .scl ``count`` line). .. py:function:: apply_tuning_to_pattern(pattern: subsequence.pattern.Pattern, tuning: Tuning, bend_range: float = 2.0, channels: Optional[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. :param pattern: The pattern to transform in place. :param tuning: The ``Tuning`` object specifying cent offsets. :param bend_range: Must match the MIDI synth's pitch-bend range setting (default ±2 semitones). :param 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. :param reference_note: MIDI note number mapped to scale degree 0.