From b170bba21a0083b8d1773ae7af5501721b83b047 Mon Sep 17 00:00:00 2001 From: Brian Sorahan Date: Sun, 12 Jul 2026 18:07:41 -0500 Subject: [PATCH 1/3] ruff format --- benchmarks/clock_jitter.py | 290 +- examples/arpeggiator.py | 99 +- examples/bresenham_poly.py | 494 +- examples/demo.py | 32 +- examples/demo_advanced.py | 120 +- examples/emergence.py | 824 +- examples/frozen.py | 106 +- examples/iss.py | 311 +- examples/labyrinth.py | 243 +- examples/link_sync.py | 16 +- examples/live_init.py | 2 +- examples/live_patterns.py | 9 +- examples/live_single_file.py | 22 +- examples/load_patterns.py | 2 +- examples/subharmonicon.py | 393 +- scripts/check_docstring_markup.py | 132 +- scripts/generate_cheatsheet.py | 376 +- scripts/midi_in_observer.py | 267 +- scripts/simulation_gravity.py | 245 +- scripts/tuner_tone.py | 178 +- subsequence/__init__.py | 5 +- subsequence/__main__.py | 15 +- subsequence/cadences.py | 124 +- subsequence/chord_graphs/__init__.py | 119 +- subsequence/chord_graphs/aeolian_minor.py | 305 +- subsequence/chord_graphs/chromatic_mediant.py | 222 +- subsequence/chord_graphs/diminished.py | 193 +- subsequence/chord_graphs/dorian_minor.py | 245 +- subsequence/chord_graphs/functional_major.py | 193 +- subsequence/chord_graphs/hooktheory_major.py | 149 +- subsequence/chord_graphs/lydian_major.py | 245 +- subsequence/chord_graphs/mixolydian.py | 190 +- subsequence/chord_graphs/phrygian_minor.py | 140 +- subsequence/chord_graphs/suspended.py | 206 +- subsequence/chord_graphs/turnaround_global.py | 300 +- subsequence/chord_graphs/whole_tone.py | 104 +- subsequence/chords.py | 643 +- subsequence/composition.py | 12048 ++++++++-------- subsequence/conductor.py | 437 +- subsequence/constants/instruments/gm_cc.py | 22 +- subsequence/constants/instruments/gm_drums.py | 4 +- .../constants/instruments/gm_instruments.py | 6 +- .../constants/instruments/roland_tr8s.py | 330 +- .../instruments/vermona_drm1_drums.py | 77 +- subsequence/constants/midi_notes.py | 10 +- subsequence/constants/velocity.py | 12 +- subsequence/display.py | 1082 +- subsequence/easing.py | 530 +- subsequence/event_emitter.py | 195 +- subsequence/form_state.py | 1268 +- subsequence/forms.py | 466 +- subsequence/groove.py | 604 +- subsequence/harmonic_rhythm.py | 152 +- subsequence/harmonic_state.py | 576 +- subsequence/harmony.py | 340 +- subsequence/held_notes.py | 123 +- subsequence/helpers/network.py | 72 +- subsequence/helpers/wing.py | 616 +- subsequence/intervals.py | 761 +- subsequence/keystroke.py | 340 +- subsequence/link_clock.py | 207 +- subsequence/live_client.py | 229 +- subsequence/live_reloader.py | 418 +- subsequence/live_server.py | 234 +- subsequence/melodic_state.py | 777 +- subsequence/midi.py | 109 +- subsequence/midi_utils.py | 611 +- subsequence/mini_notation.py | 311 +- subsequence/motifs.py | 4577 +++--- subsequence/osc.py | 303 +- subsequence/pattern.py | 669 +- subsequence/pattern_algorithmic.py | 4131 +++--- subsequence/pattern_builder.py | 5370 +++---- subsequence/pattern_midi.py | 2404 +-- subsequence/progressions.py | 3570 ++--- subsequence/roles.py | 34 +- subsequence/sequence_utils.py | 5433 +++---- subsequence/sequencer.py | 4137 +++--- subsequence/tuning.py | 810 +- subsequence/voicings.py | 205 +- subsequence/web_ui.py | 640 +- subsequence/weighted_graph.py | 286 +- tests/conftest.py | 181 +- tests/test_api_consistency.py | 233 +- tests/test_cadences.py | 912 +- tests/test_callback_sequence.py | 360 +- tests/test_cc_forward.py | 683 +- tests/test_cc_name_map.py | 193 +- tests/test_chord_graphs.py | 336 +- tests/test_chord_quality_registry.py | 161 +- tests/test_clock_output.py | 98 +- tests/test_composition.py | 2593 ++-- tests/test_conductor.py | 370 +- tests/test_custom_scales.py | 377 +- tests/test_detached.py | 179 +- tests/test_determinism.py | 379 +- tests/test_display.py | 1552 +- tests/test_duration.py | 105 +- tests/test_eased_value.py | 278 +- tests/test_easing.py | 339 +- tests/test_event_emitter.py | 179 +- tests/test_form.py | 1056 +- tests/test_forms.py | 1312 +- tests/test_groove.py | 734 +- tests/test_harmonic_gravity.py | 538 +- tests/test_harmony.py | 294 +- tests/test_harmony_window.py | 513 +- tests/test_held_notes.py | 384 +- tests/test_hotkeys.py | 558 +- tests/test_instrument_drum_maps.py | 257 +- tests/test_intervals.py | 95 +- tests/test_key_resolution.py | 675 +- tests/test_keystroke.py | 95 +- tests/test_latency_compensation.py | 626 +- tests/test_legato.py | 145 +- tests/test_link.py | 166 +- tests/test_live_reloader.py | 675 +- tests/test_live_server.py | 686 +- tests/test_load_patterns.py | 317 +- tests/test_low_fixes_2026_07.py | 469 +- tests/test_melodic_state.py | 564 +- tests/test_melody_v2.py | 591 +- tests/test_midi_helpers.py | 88 +- tests/test_midi_input.py | 441 +- tests/test_midi_recording.py | 218 +- tests/test_mini_notation.py | 238 +- tests/test_mini_notation_integration.py | 74 +- tests/test_mirror.py | 1107 +- tests/test_motif_placement.py | 571 +- tests/test_motifs.py | 788 +- tests/test_multi_device.py | 1226 +- tests/test_new_chord_graphs.py | 874 +- tests/test_osc.py | 210 +- tests/test_pattern_algorithmic.py | 860 +- tests/test_pattern_builder.py | 7314 +++++----- tests/test_phrases.py | 651 +- tests/test_presets.py | 368 +- tests/test_progressions.py | 1231 +- tests/test_render.py | 216 +- tests/test_rescheduling.py | 216 +- tests/test_review_fixes.py | 369 +- tests/test_review_fixes_2026_07.py | 944 +- tests/test_schedule_context.py | 172 +- tests/test_scheduled_tasks.py | 319 +- tests/test_sequence_utils.py | 2371 +-- tests/test_sequence_utils_density_combine.py | 299 +- tests/test_sequence_utils_density_spread.py | 352 +- tests/test_sequence_utils_density_to_steps.py | 93 +- tests/test_sequence_utils_density_warp.py | 233 +- tests/test_sequence_utils_morse_code.py | 125 +- tests/test_sequence_utils_scale.py | 95 +- tests/test_sequence_utils_transforms_list.py | 314 +- tests/test_sequence_utils_transforms_value.py | 187 +- tests/test_sequencer_dispatch_safety.py | 143 +- tests/test_sequencer_events.py | 473 +- tests/test_tier1_fixes_2026_07.py | 295 +- tests/test_trigger.py | 299 +- tests/test_tuning.py | 814 +- tests/test_turnaround_graph.py | 102 +- tests/test_unregister.py | 248 +- tests/test_velocity_tuple.py | 450 +- tests/test_voicings.py | 302 +- tests/test_web_ui.py | 99 +- tests/test_weighted_graph.py | 98 +- tests/test_wing_helper.py | 261 +- 165 files changed, 56064 insertions(+), 53437 deletions(-) diff --git a/benchmarks/clock_jitter.py b/benchmarks/clock_jitter.py index d408973..c3613f5 100644 --- a/benchmarks/clock_jitter.py +++ b/benchmarks/clock_jitter.py @@ -29,145 +29,167 @@ # --------------------------------------------------------------------------- -PPQN = 24 # MIDI quarter note = 24 pulses +PPQN = 24 # MIDI quarter note = 24 pulses BEATS_PER_BAR = 4 -def _run_benchmark ( - bpm: float, - bars: int, - spin_wait: bool, - device_name: typing.Optional[str], +def _run_benchmark( + bpm: float, + bars: int, + spin_wait: bool, + device_name: typing.Optional[str], ) -> typing.List[float]: - - """Run the sequencer for *bars* bars and return per-pulse jitter (seconds).""" - - jitter_log: typing.List[float] = [] - seconds_per_bar = (60.0 / bpm) * BEATS_PER_BAR - total_seconds = seconds_per_bar * bars - pulses = bars * BEATS_PER_BAR * PPQN - - async def _run () -> None: - - seq = subsequence.sequencer.Sequencer( - output_device_name = device_name, - initial_bpm = bpm, - spin_wait = spin_wait, - _jitter_log = jitter_log, - ) - await seq.start() - - # Use shield so that the timeout does NOT cancel seq.task — we want - # the task to exit cleanly when we set running=False below. - try: - await asyncio.wait_for(asyncio.shield(seq.task), timeout=total_seconds + 2.0) # type: ignore[arg-type] - except asyncio.TimeoutError: - pass # Expected — target duration elapsed. - - # Signal the loop to exit at the next pulse, then wait for it. - seq.running = False - if seq.task and not seq.task.done(): - try: - await asyncio.wait_for(seq.task, timeout=2.0) # type: ignore[arg-type] - except (asyncio.TimeoutError, asyncio.CancelledError): - seq.task.cancel() - - if seq.midi_out: - seq.midi_out.close() - seq.midi_out = None - - asyncio.run(_run()) - - # Trim to the expected pulse count in case of minor over/under-run. - return jitter_log[:pulses] - - -def _print_report ( - jitter: typing.List[float], - bpm: float, - bars: int, - spin_wait: bool, - label: str = "", + """Run the sequencer for *bars* bars and return per-pulse jitter (seconds).""" + + jitter_log: typing.List[float] = [] + seconds_per_bar = (60.0 / bpm) * BEATS_PER_BAR + total_seconds = seconds_per_bar * bars + pulses = bars * BEATS_PER_BAR * PPQN + + async def _run() -> None: + seq = subsequence.sequencer.Sequencer( + output_device_name=device_name, + initial_bpm=bpm, + spin_wait=spin_wait, + _jitter_log=jitter_log, + ) + await seq.start() + + # Use shield so that the timeout does NOT cancel seq.task — we want + # the task to exit cleanly when we set running=False below. + try: + await asyncio.wait_for( + asyncio.shield(seq.task), timeout=total_seconds + 2.0 + ) # type: ignore[arg-type] + except asyncio.TimeoutError: + pass # Expected — target duration elapsed. + + # Signal the loop to exit at the next pulse, then wait for it. + seq.running = False + if seq.task and not seq.task.done(): + try: + await asyncio.wait_for(seq.task, timeout=2.0) # type: ignore[arg-type] + except (asyncio.TimeoutError, asyncio.CancelledError): + seq.task.cancel() + + if seq.midi_out: + seq.midi_out.close() + seq.midi_out = None + + asyncio.run(_run()) + + # Trim to the expected pulse count in case of minor over/under-run. + return jitter_log[:pulses] + + +def _print_report( + jitter: typing.List[float], + bpm: float, + bars: int, + spin_wait: bool, + label: str = "", ) -> None: - - if not jitter: - print("No jitter data collected.") - return - - ms = [j * 1000 for j in jitter] # convert to milliseconds - us = [j * 1e6 for j in jitter] # and microseconds for tight results - - mean_ms = statistics.mean(ms) - median_ms = statistics.median(ms) - stdev_ms = statistics.stdev(ms) if len(ms) > 1 else 0.0 - p95_ms = sorted(ms)[int(len(ms) * 0.95)] - p99_ms = sorted(ms)[int(len(ms) * 0.99)] - max_ms = max(ms) - - # Non-accumulating drift: difference between first and last jitter samples. - drift_ms = ms[-1] - ms[0] if len(ms) > 1 else 0.0 - - ppqn = PPQN - seconds_per_pulse = 60.0 / bpm / ppqn - pulse_interval_ms = seconds_per_pulse * 1000 - - mode = "spin-wait ON" if spin_wait else "spin-wait OFF" - header = f" {label} " if label else "" - - print(f"\nClock Jitter Benchmark{header}— {bars} bars at {bpm:.0f} BPM ({mode})") - print(f"{'─' * 62}") - print(f" Pulses measured : {len(ms)}") - print(f" Pulse interval : {pulse_interval_ms:.3f} ms ({ppqn} PPQN)") - print(f"{'─' * 62}") - print(f" Mean jitter : {mean_ms:>8.3f} ms") - print(f" Median jitter : {median_ms:>8.3f} ms") - print(f" Std deviation : {stdev_ms:>8.3f} ms") - print(f" P95 jitter : {p95_ms:>8.3f} ms") - print(f" P99 jitter : {p99_ms:>8.3f} ms") - print(f" Max jitter : {max_ms:>8.3f} ms") - print(f" Clock drift : {drift_ms:>+8.3f} ms (non-accumulating)") - print(f"{'─' * 62}") - - # Qualitative rating. - if mean_ms < 0.1: - rating = "Excellent (sub-100 μs — tight hardware-class timing)" - elif mean_ms < 0.5: - rating = "Very good (sub-500 μs — well below human perception)" - elif mean_ms < 2.0: - rating = "Good (< 2 ms — at or below human perception threshold)" - elif mean_ms < 5.0: - rating = "Fair (2–5 ms — may affect tight sync with hardware)" - else: - rating = "Poor (> 5 ms — noticeable timing issues likely)" - - print(f" Rating : {rating}") - print() - - -def main () -> None: - - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--bpm", type=float, default=120, help="Tempo in BPM (default: 120)") - parser.add_argument("--bars", type=int, default=32, help="Bars to measure (default: 32)") - parser.add_argument("--no-spin-wait", action="store_true", help="Disable spin-wait (use pure asyncio.sleep)") - parser.add_argument("--device", type=str, default=None, help="MIDI output device name") - parser.add_argument("--compare", action="store_true", help="Run both modes and compare") - args = parser.parse_args() - - if args.compare: - print("\nRunning with spin-wait ON ...") - spin_jitter = _run_benchmark(args.bpm, args.bars, spin_wait=True, device_name=args.device) - _print_report(spin_jitter, args.bpm, args.bars, spin_wait=True, label="[spin-wait ON]") - - print("Running with spin-wait OFF ...") - pure_jitter = _run_benchmark(args.bpm, args.bars, spin_wait=False, device_name=args.device) - _print_report(pure_jitter, args.bpm, args.bars, spin_wait=False, label="[spin-wait OFF]") - - else: - spin = not args.no_spin_wait - jitter = _run_benchmark(args.bpm, args.bars, spin_wait=spin, device_name=args.device) - _print_report(jitter, args.bpm, args.bars, spin_wait=spin) + if not jitter: + print("No jitter data collected.") + return + + ms = [j * 1000 for j in jitter] # convert to milliseconds + us = [j * 1e6 for j in jitter] # and microseconds for tight results + + mean_ms = statistics.mean(ms) + median_ms = statistics.median(ms) + stdev_ms = statistics.stdev(ms) if len(ms) > 1 else 0.0 + p95_ms = sorted(ms)[int(len(ms) * 0.95)] + p99_ms = sorted(ms)[int(len(ms) * 0.99)] + max_ms = max(ms) + + # Non-accumulating drift: difference between first and last jitter samples. + drift_ms = ms[-1] - ms[0] if len(ms) > 1 else 0.0 + + ppqn = PPQN + seconds_per_pulse = 60.0 / bpm / ppqn + pulse_interval_ms = seconds_per_pulse * 1000 + + mode = "spin-wait ON" if spin_wait else "spin-wait OFF" + header = f" {label} " if label else "" + + print(f"\nClock Jitter Benchmark{header}— {bars} bars at {bpm:.0f} BPM ({mode})") + print(f"{'─' * 62}") + print(f" Pulses measured : {len(ms)}") + print(f" Pulse interval : {pulse_interval_ms:.3f} ms ({ppqn} PPQN)") + print(f"{'─' * 62}") + print(f" Mean jitter : {mean_ms:>8.3f} ms") + print(f" Median jitter : {median_ms:>8.3f} ms") + print(f" Std deviation : {stdev_ms:>8.3f} ms") + print(f" P95 jitter : {p95_ms:>8.3f} ms") + print(f" P99 jitter : {p99_ms:>8.3f} ms") + print(f" Max jitter : {max_ms:>8.3f} ms") + print(f" Clock drift : {drift_ms:>+8.3f} ms (non-accumulating)") + print(f"{'─' * 62}") + + # Qualitative rating. + if mean_ms < 0.1: + rating = "Excellent (sub-100 μs — tight hardware-class timing)" + elif mean_ms < 0.5: + rating = "Very good (sub-500 μs — well below human perception)" + elif mean_ms < 2.0: + rating = "Good (< 2 ms — at or below human perception threshold)" + elif mean_ms < 5.0: + rating = "Fair (2–5 ms — may affect tight sync with hardware)" + else: + rating = "Poor (> 5 ms — noticeable timing issues likely)" + + print(f" Rating : {rating}") + print() + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--bpm", type=float, default=120, help="Tempo in BPM (default: 120)" + ) + parser.add_argument( + "--bars", type=int, default=32, help="Bars to measure (default: 32)" + ) + parser.add_argument( + "--no-spin-wait", + action="store_true", + help="Disable spin-wait (use pure asyncio.sleep)", + ) + parser.add_argument( + "--device", type=str, default=None, help="MIDI output device name" + ) + parser.add_argument( + "--compare", action="store_true", help="Run both modes and compare" + ) + args = parser.parse_args() + + if args.compare: + print("\nRunning with spin-wait ON ...") + spin_jitter = _run_benchmark( + args.bpm, args.bars, spin_wait=True, device_name=args.device + ) + _print_report( + spin_jitter, args.bpm, args.bars, spin_wait=True, label="[spin-wait ON]" + ) + + print("Running with spin-wait OFF ...") + pure_jitter = _run_benchmark( + args.bpm, args.bars, spin_wait=False, device_name=args.device + ) + _print_report( + pure_jitter, args.bpm, args.bars, spin_wait=False, label="[spin-wait OFF]" + ) + + else: + spin = not args.no_spin_wait + jitter = _run_benchmark( + args.bpm, args.bars, spin_wait=spin, device_name=args.device + ) + _print_report(jitter, args.bpm, args.bars, spin_wait=spin) if __name__ == "__main__": - main() + main() diff --git a/examples/arpeggiator.py b/examples/arpeggiator.py index 7fa1432..7ab5c21 100644 --- a/examples/arpeggiator.py +++ b/examples/arpeggiator.py @@ -12,86 +12,83 @@ ARP_CHANNEL = 1 LEAD_CHANNEL = 4 -composition = subsequence.Composition( - bpm=120, - key="E" +composition = subsequence.Composition(bpm=120, key="E") + +composition.form( + { + "intro": (8, [("section_1", 1)]), + "section_1": (8, [("section_1", 1), ("section_2", 2)]), + "section_2": (8, [("section_2", 1), ("section_1", 2)]), + }, + start="intro", ) -composition.form({ - "intro": (8, [("section_1", 1)]), - "section_1": (8, [("section_1", 1), ("section_2", 2)]), - "section_2": (8, [("section_2", 1), ("section_1", 2)]), -}, start="intro") - composition.harmony(style="phrygian_minor", cycle_beats=16, gravity=0.75) + @composition.pattern(channel=DRUM_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): +def drums(p): + if p.section and p.section.name == "intro": + return - if p.section and p.section.name == "intro": - return + hi_hat_open_steps = {15} - hi_hat_open_steps = {15} + if p.cycle and not (p.cycle % 4): + hi_hat_open_steps.add(5) - if p.cycle and not(p.cycle % 4): - hi_hat_open_steps.add(5) + hi_hat_closed_steps = set(range(16)) - hi_hat_open_steps - hi_hat_closed_steps = set(range(16)) - hi_hat_open_steps + p.hit_steps("hi_hat_closed", hi_hat_closed_steps, velocity=75) + p.hit_steps("hi_hat_open", hi_hat_open_steps, velocity=65) - p.hit_steps("hi_hat_closed", hi_hat_closed_steps, velocity=75) - p.hit_steps("hi_hat_open", hi_hat_open_steps, velocity=65) + p.velocity_shape(low=60, high=100) - p.velocity_shape(low=60, high=100) + p.hit_steps("snare_1", [4, 12], velocity=100) - p.hit_steps("snare_1", [4, 12], velocity=100) + if not (p.cycle % 16): + p.hit_steps("crash_2", {0}, velocity=127) - if not(p.cycle % 16): - p.hit_steps("crash_2", {0}, velocity=127) + if p.section and p.section.name == "section_2" and p.cycle % 8 == 0: + p.hit_steps("hand_clap", [12], velocity=100) - if p.section and p.section.name == "section_2" and p.cycle % 8 == 0: - p.hit_steps("hand_clap", [12], velocity=100) + p.hit_steps("kick_1", {0, 4, 8, 12}, velocity=100) - p.hit_steps("kick_1", {0, 4, 8, 12}, velocity=100) @composition.pattern(channel=BASS_CHANNEL, beats=4) -def bass (p, chord): +def bass(p, chord): + if p.section and p.section.name == "intro": + return - if p.section and p.section.name == "intro": - return + root = chord.root_note(33) - root = chord.root_note(33) + bass_steps = {0, 3, 8, 12} - bass_steps = {0, 3, 8, 12} + if p.cycle and p.cycle % 4 == 2: + bass_steps.update({6}) - if p.cycle and p.cycle % 4 == 2: - bass_steps.update({6}) + p.sequence(steps=bass_steps, pitches=root) - p.sequence( - steps=bass_steps, - pitches=root - ) + p.legato(0.9) - p.legato(0.9) @composition.pattern(channel=ARP_CHANNEL, steps=5, step_duration=dur.SIXTEENTH) -def arp (p, chord): +def arp(p, chord): + pitches = chord.tones(root=60, count=5) + p.arpeggio(pitches, spacing=dur.SIXTEENTH, direction="up") - pitches = chord.tones(root=60, count=5) - p.arpeggio(pitches, spacing=dur.SIXTEENTH, direction="up") @composition.pattern(channel=LEAD_CHANNEL, steps=6, step_duration=dur.SIXTEENTH) -def lead (p, chord): +def lead(p, chord): + root = chord.root_note(84) + # pitches = chord.tones(root=root, count=4) - root = chord.root_note(84) - # pitches = chord.tones(root=root, count=4) + p.sequence( + steps=[0, 1, 3, 5], + pitches=[root + 12, root, root, root], + durations=0.125, + ) - p.sequence( - steps=[0, 1, 3, 5], - pitches=[root+12, root, root, root], - durations=0.125, - ) if __name__ == "__main__": - - composition.display() - composition.play() + composition.display() + composition.play() diff --git a/examples/bresenham_poly.py b/examples/bresenham_poly.py index b6c30e2..8a39cff 100644 --- a/examples/bresenham_poly.py +++ b/examples/bresenham_poly.py @@ -36,240 +36,270 @@ # Each section is 16 bars. Transitions are weighted so the form usually # progresses pulse → emerge → peak → dissolve → pulse, but sometimes # a section repeats - keeping the trajectory while adding surprise. -composition.form({ - "pulse": (16, [("emerge", 3), ("pulse", 1)]), - "emerge": (16, [("peak", 3), ("emerge", 1)]), - "peak": (16, [("dissolve", 3), ("peak", 1)]), - "dissolve": (16, [("pulse", 3), ("emerge", 1)]), -}, start="pulse") +composition.form( + { + "pulse": (16, [("emerge", 3), ("pulse", 1)]), + "emerge": (16, [("peak", 3), ("emerge", 1)]), + "peak": (16, [("dissolve", 3), ("peak", 1)]), + "dissolve": (16, [("pulse", 3), ("emerge", 1)]), + }, + start="pulse", +) @composition.pattern(channel=DRUM_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): - - if not p.section: - return - - section = p.section.name - t = p.section.progress # 0.0 → ~1.0 within this section - - # ── Perlin-driven wandering parameters ──────────────────────────── - # Each wanders smoothly over time. Because p.cycle increments - # forever, each pass through the form starts at a different point - # in the noise field - "no sound ever plays the same way twice." - ghost_wander = subsequence.sequence_utils.perlin_1d(p.cycle * 0.07, seed=1) - hat_feel = subsequence.sequence_utils.perlin_1d(p.cycle * 0.05, seed=2) - tom_swell = subsequence.sequence_utils.perlin_1d(p.cycle * 0.04, seed=3) - - def ease (value: float, shape: str = "linear") -> float: - """Apply an easing curve to a 0-1 progress value.""" - return subsequence.easing.get_easing(shape)(value) - - # ═══════════════════════════════════════════════════════════════════ - # A: "PULSE" - # - # Minimal. Four-on-the-floor kick is the only constant. A single - # cellular automaton ghost layer slowly emerges, the snare enters - # mid-section, and hats begin to appear at the tail. Space and air. - # ═══════════════════════════════════════════════════════════════════ - if section == "pulse": - - # Crash marks the top of the section - if p.section.first_bar: - p.hit_steps("crash_1", [0], velocity=120) - - # Kick: four on the floor, steady - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=90) - - # Snare backbeat enters at progress 0.25 (~bar 4), tentative - if t >= 0.25: - snare_vel = round(65 + 13 * ease(min(1.0, (t - 0.25) / 0.75), "ease_in")) - p.hit_steps("snare_1", [4, 12], velocity=snare_vel) - - # Cellular ghost kicks emerge at progress 0.5 - Rule 30 structured chaos - if t >= 0.5: - ca_probability = 0.1 + 0.2 * ease(min(1.0, (t - 0.5) / 0.5), "ease_in") - p.cellular_1d("kick_1", rule=30, velocity=35, - no_overlap=True, probability=ca_probability) - - # Hats begin to appear at progress 0.75 - just a whisper - if t >= 0.75: - hat_d = 0.10 + 0.10 * ease(min(1.0, (t - 0.75) / 0.25), "ease_in") - hat_d *= (0.7 + hat_feel * 0.6) - p.bresenham_poly( - parts={"hi_hat_closed": hat_d}, - velocity={"hi_hat_closed": 62}, - ) - - # ═══════════════════════════════════════════════════════════════════ - # B: "EMERGE" - # - # Layers enter and thicken. Ghost fills appear on kick and snare, - # driven by Perlin noise. Hats open up. Open hats begin to - # punctuate. The groove starts to breathe. - # ═══════════════════════════════════════════════════════════════════ - elif section == "emerge": - - if p.section.first_bar: - p.hit_steps("crash_1", [0], velocity=95) - - # Kick: building confidence - kick_vel = round(96 + 11 * ease(t, "ease_in")) - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=kick_vel) - - # Kick ghosts: CA layer thickens through section - ca_probability = 0.3 + 0.2 * ease(t, "ease_in") - p.cellular_1d("kick_1", rule=30, velocity=38, - no_overlap=True, probability=ca_probability) - - # Kick ghost fill: enters at progress 0.25, density builds - if t >= 0.25: - gf_density = (0.05 + 0.13 * ease(min(1.0, (t - 0.25) / 0.75))) - gf_density *= (0.6 + ghost_wander * 0.4) - if gf_density > 0.02: - p.ghost_fill("kick_1", density=gf_density, - velocity=(28, 45), bias="sixteenths", no_overlap=True) - - # Snare: gaining confidence through section - snare_vel = round(78 + 17 * ease(t)) - p.hit_steps("snare_1", [4, 12], velocity=snare_vel) - - # Snare ghosts: "before" bias - offset ghosts as groove glue - gs_density = 0.06 + 0.09 * ease(t, "ease_in_out") - gs_density *= (0.7 + ghost_wander * 0.3) - if gs_density > 0.02: - p.ghost_fill("snare_1", density=gs_density, - velocity=(20, 36), bias="before", no_overlap=True) - - # Hats: closed present throughout, open enters at progress 0.75 - hat_d = 0.20 + 0.25 * ease(t, "ease_in_out") - hat_d *= (0.7 + hat_feel * 0.6) - parts = {"hi_hat_closed": hat_d} - if t >= 0.75: - hat_open_d = 0.02 + 0.06 * ease(min(1.0, (t - 0.75) / 0.25), "ease_in_out") - hat_open_d *= (0.5 + hat_feel * 0.5) - parts["hi_hat_open"] = hat_open_d - p.bresenham_poly( - parts=parts, - velocity={"hi_hat_closed": 68, "hi_hat_open": 80}, - ) - - # ═══════════════════════════════════════════════════════════════════ - # C: "PEAK" - # - # Full density from bar one. All voices active. No building - - # Perlin noise modulates density and velocity, keeping the section - # alive without ramping. CA hat texture adds fractal micro-detail. - # Toms sweep across the kit. Dense but not chaotic, - # everything interlocking. - # ═══════════════════════════════════════════════════════════════════ - elif section == "peak": - - if p.section.first_bar: - p.hit_steps("crash_1", [0], velocity=95) - - # Kick: full authority, constant - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=110) - - # Kick ghosts: maximum layers (CA + probability fill) - p.cellular_1d("kick_1", rule=30, velocity=38, - no_overlap=True, probability=0.65) - - gf_density = 0.20 * (0.6 + ghost_wander * 0.4) - p.ghost_fill("kick_1", density=gf_density, - velocity=(28, 45), bias="sixteenths", no_overlap=True) - - # Snare: full, constant - p.hit_steps("snare_1", [4, 12], velocity=100) - - # Snare ghosts: high density, Perlin-modulated - gs_density = 0.18 * (0.7 + ghost_wander * 0.3) - p.ghost_fill("snare_1", density=gs_density, - velocity=(20, 36), bias="before", no_overlap=True) - - # Hats: full density, Perlin-breathing toward every step - hat_d = 0.75 + 0.25 * hat_feel - hat_open_d = 0.12 * (0.5 + hat_feel * 0.5) - p.bresenham_poly( - parts={"hi_hat_closed": hat_d, "hi_hat_open": hat_open_d}, - velocity={"hi_hat_closed": 68, "hi_hat_open": 80}, - ) - - # CA hat texture: Sierpinski-like patterns - p.cellular_1d("hi_hat_closed", rule=90, velocity=40, - no_overlap=True, probability=0.55) - - # Toms: Perlin-driven sweeps - high, mid, low interlock - tom_d = 0.25 * (0.4 + tom_swell * 0.6) - if tom_d > 0.01: - p.bresenham_poly( - parts={ - "high_tom": tom_d * 0.50, - "low_mid_tom": tom_d * 0.30, - "low_tom": tom_d * 0.20, - }, - velocity={"high_tom": 85, "low_mid_tom": 80, "low_tom": 75}, - ) - - # ═══════════════════════════════════════════════════════════════════ - # D: "DISSOLVE" - # - # Strip back. Kick drops to half-time, opening space. Layers - # thin and fade. The final bar fills with pedal hat before the - # crash resets. Each dissolve is different because the Perlin - # noise has drifted. - # ═══════════════════════════════════════════════════════════════════ - elif section == "dissolve": - - if p.section.first_bar: - p.hit_steps("crash_1", [0], velocity=95) - - # Kick: half-time from the start, fading - kick_vel = round(100 - 15 * ease(t, "ease_out")) - p.hit_steps("kick_1", [0, 8], velocity=kick_vel) - - # Snare: fading backbeat - snare_vel = round(100 - 35 * ease(t, "ease_out")) - p.hit_steps("snare_1", [4, 12], velocity=snare_vel) - - # Snare ghosts: thinning out - gs_density = 0.22 * (1.0 - 0.9 * ease(t, "ease_out")) - gs_density *= (0.7 + ghost_wander * 0.3) - if gs_density > 0.02: - p.ghost_fill("snare_1", density=gs_density, - velocity=(20, 36), bias="before", no_overlap=True) - - # Hats: thinning - hat_d = 0.58 * (1.0 - 0.8 * ease(t, "ease_out")) - hat_d *= (0.7 + hat_feel * 0.6) - if hat_d > 0.01: - p.bresenham_poly( - parts={"hi_hat_closed": hat_d}, - velocity={"hi_hat_closed": 68}, - ) - - # Toms: fade out in first half of section - if t < 0.5: - tom_d = 0.35 * (1.0 - ease(t * 2.0, "ease_out")) - tom_d *= (0.4 + tom_swell * 0.6) - if tom_d > 0.01: - p.bresenham_poly( - parts={ - "high_tom": tom_d * 0.50, - "low_mid_tom": tom_d * 0.30, - "low_tom": tom_d * 0.20, - }, - velocity={"high_tom": 85, "low_mid_tom": 80, "low_tom": 75}, - ) - - # Final bar: transition fill into the next section - if p.section.last_bar: - p.hit_steps("hi_hat_pedal", range(16), velocity=55) - p.hit_steps("crash_1", [15], velocity=90) - - # ── Post-placement: groove template ─────────────────────────────── - # p.groove(groove) +def drums(p): + if not p.section: + return + + section = p.section.name + t = p.section.progress # 0.0 → ~1.0 within this section + + # ── Perlin-driven wandering parameters ──────────────────────────── + # Each wanders smoothly over time. Because p.cycle increments + # forever, each pass through the form starts at a different point + # in the noise field - "no sound ever plays the same way twice." + ghost_wander = subsequence.sequence_utils.perlin_1d(p.cycle * 0.07, seed=1) + hat_feel = subsequence.sequence_utils.perlin_1d(p.cycle * 0.05, seed=2) + tom_swell = subsequence.sequence_utils.perlin_1d(p.cycle * 0.04, seed=3) + + def ease(value: float, shape: str = "linear") -> float: + """Apply an easing curve to a 0-1 progress value.""" + return subsequence.easing.get_easing(shape)(value) + + # ═══════════════════════════════════════════════════════════════════ + # A: "PULSE" + # + # Minimal. Four-on-the-floor kick is the only constant. A single + # cellular automaton ghost layer slowly emerges, the snare enters + # mid-section, and hats begin to appear at the tail. Space and air. + # ═══════════════════════════════════════════════════════════════════ + if section == "pulse": + # Crash marks the top of the section + if p.section.first_bar: + p.hit_steps("crash_1", [0], velocity=120) + + # Kick: four on the floor, steady + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=90) + + # Snare backbeat enters at progress 0.25 (~bar 4), tentative + if t >= 0.25: + snare_vel = round(65 + 13 * ease(min(1.0, (t - 0.25) / 0.75), "ease_in")) + p.hit_steps("snare_1", [4, 12], velocity=snare_vel) + + # Cellular ghost kicks emerge at progress 0.5 - Rule 30 structured chaos + if t >= 0.5: + ca_probability = 0.1 + 0.2 * ease(min(1.0, (t - 0.5) / 0.5), "ease_in") + p.cellular_1d( + "kick_1", + rule=30, + velocity=35, + no_overlap=True, + probability=ca_probability, + ) + + # Hats begin to appear at progress 0.75 - just a whisper + if t >= 0.75: + hat_d = 0.10 + 0.10 * ease(min(1.0, (t - 0.75) / 0.25), "ease_in") + hat_d *= 0.7 + hat_feel * 0.6 + p.bresenham_poly( + parts={"hi_hat_closed": hat_d}, + velocity={"hi_hat_closed": 62}, + ) + + # ═══════════════════════════════════════════════════════════════════ + # B: "EMERGE" + # + # Layers enter and thicken. Ghost fills appear on kick and snare, + # driven by Perlin noise. Hats open up. Open hats begin to + # punctuate. The groove starts to breathe. + # ═══════════════════════════════════════════════════════════════════ + elif section == "emerge": + if p.section.first_bar: + p.hit_steps("crash_1", [0], velocity=95) + + # Kick: building confidence + kick_vel = round(96 + 11 * ease(t, "ease_in")) + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=kick_vel) + + # Kick ghosts: CA layer thickens through section + ca_probability = 0.3 + 0.2 * ease(t, "ease_in") + p.cellular_1d( + "kick_1", rule=30, velocity=38, no_overlap=True, probability=ca_probability + ) + + # Kick ghost fill: enters at progress 0.25, density builds + if t >= 0.25: + gf_density = 0.05 + 0.13 * ease(min(1.0, (t - 0.25) / 0.75)) + gf_density *= 0.6 + ghost_wander * 0.4 + if gf_density > 0.02: + p.ghost_fill( + "kick_1", + density=gf_density, + velocity=(28, 45), + bias="sixteenths", + no_overlap=True, + ) + + # Snare: gaining confidence through section + snare_vel = round(78 + 17 * ease(t)) + p.hit_steps("snare_1", [4, 12], velocity=snare_vel) + + # Snare ghosts: "before" bias - offset ghosts as groove glue + gs_density = 0.06 + 0.09 * ease(t, "ease_in_out") + gs_density *= 0.7 + ghost_wander * 0.3 + if gs_density > 0.02: + p.ghost_fill( + "snare_1", + density=gs_density, + velocity=(20, 36), + bias="before", + no_overlap=True, + ) + + # Hats: closed present throughout, open enters at progress 0.75 + hat_d = 0.20 + 0.25 * ease(t, "ease_in_out") + hat_d *= 0.7 + hat_feel * 0.6 + parts = {"hi_hat_closed": hat_d} + if t >= 0.75: + hat_open_d = 0.02 + 0.06 * ease(min(1.0, (t - 0.75) / 0.25), "ease_in_out") + hat_open_d *= 0.5 + hat_feel * 0.5 + parts["hi_hat_open"] = hat_open_d + p.bresenham_poly( + parts=parts, + velocity={"hi_hat_closed": 68, "hi_hat_open": 80}, + ) + + # ═══════════════════════════════════════════════════════════════════ + # C: "PEAK" + # + # Full density from bar one. All voices active. No building - + # Perlin noise modulates density and velocity, keeping the section + # alive without ramping. CA hat texture adds fractal micro-detail. + # Toms sweep across the kit. Dense but not chaotic, + # everything interlocking. + # ═══════════════════════════════════════════════════════════════════ + elif section == "peak": + if p.section.first_bar: + p.hit_steps("crash_1", [0], velocity=95) + + # Kick: full authority, constant + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=110) + + # Kick ghosts: maximum layers (CA + probability fill) + p.cellular_1d("kick_1", rule=30, velocity=38, no_overlap=True, probability=0.65) + + gf_density = 0.20 * (0.6 + ghost_wander * 0.4) + p.ghost_fill( + "kick_1", + density=gf_density, + velocity=(28, 45), + bias="sixteenths", + no_overlap=True, + ) + + # Snare: full, constant + p.hit_steps("snare_1", [4, 12], velocity=100) + + # Snare ghosts: high density, Perlin-modulated + gs_density = 0.18 * (0.7 + ghost_wander * 0.3) + p.ghost_fill( + "snare_1", + density=gs_density, + velocity=(20, 36), + bias="before", + no_overlap=True, + ) + + # Hats: full density, Perlin-breathing toward every step + hat_d = 0.75 + 0.25 * hat_feel + hat_open_d = 0.12 * (0.5 + hat_feel * 0.5) + p.bresenham_poly( + parts={"hi_hat_closed": hat_d, "hi_hat_open": hat_open_d}, + velocity={"hi_hat_closed": 68, "hi_hat_open": 80}, + ) + + # CA hat texture: Sierpinski-like patterns + p.cellular_1d( + "hi_hat_closed", rule=90, velocity=40, no_overlap=True, probability=0.55 + ) + + # Toms: Perlin-driven sweeps - high, mid, low interlock + tom_d = 0.25 * (0.4 + tom_swell * 0.6) + if tom_d > 0.01: + p.bresenham_poly( + parts={ + "high_tom": tom_d * 0.50, + "low_mid_tom": tom_d * 0.30, + "low_tom": tom_d * 0.20, + }, + velocity={"high_tom": 85, "low_mid_tom": 80, "low_tom": 75}, + ) + + # ═══════════════════════════════════════════════════════════════════ + # D: "DISSOLVE" + # + # Strip back. Kick drops to half-time, opening space. Layers + # thin and fade. The final bar fills with pedal hat before the + # crash resets. Each dissolve is different because the Perlin + # noise has drifted. + # ═══════════════════════════════════════════════════════════════════ + elif section == "dissolve": + if p.section.first_bar: + p.hit_steps("crash_1", [0], velocity=95) + + # Kick: half-time from the start, fading + kick_vel = round(100 - 15 * ease(t, "ease_out")) + p.hit_steps("kick_1", [0, 8], velocity=kick_vel) + + # Snare: fading backbeat + snare_vel = round(100 - 35 * ease(t, "ease_out")) + p.hit_steps("snare_1", [4, 12], velocity=snare_vel) + + # Snare ghosts: thinning out + gs_density = 0.22 * (1.0 - 0.9 * ease(t, "ease_out")) + gs_density *= 0.7 + ghost_wander * 0.3 + if gs_density > 0.02: + p.ghost_fill( + "snare_1", + density=gs_density, + velocity=(20, 36), + bias="before", + no_overlap=True, + ) + + # Hats: thinning + hat_d = 0.58 * (1.0 - 0.8 * ease(t, "ease_out")) + hat_d *= 0.7 + hat_feel * 0.6 + if hat_d > 0.01: + p.bresenham_poly( + parts={"hi_hat_closed": hat_d}, + velocity={"hi_hat_closed": 68}, + ) + + # Toms: fade out in first half of section + if t < 0.5: + tom_d = 0.35 * (1.0 - ease(t * 2.0, "ease_out")) + tom_d *= 0.4 + tom_swell * 0.6 + if tom_d > 0.01: + p.bresenham_poly( + parts={ + "high_tom": tom_d * 0.50, + "low_mid_tom": tom_d * 0.30, + "low_tom": tom_d * 0.20, + }, + velocity={"high_tom": 85, "low_mid_tom": 80, "low_tom": 75}, + ) + + # Final bar: transition fill into the next section + if p.section.last_bar: + p.hit_steps("hi_hat_pedal", range(16), velocity=55) + p.hit_steps("crash_1", [15], velocity=90) + + # ── Post-placement: groove template ─────────────────────────────── + # p.groove(groove) + if __name__ == "__main__": - composition.display(grid=True, grid_scale=5) - composition.play() + composition.display(grid=True, grid_scale=5) + composition.play() diff --git a/examples/demo.py b/examples/demo.py index 05228cf..5d90ce6 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -11,29 +11,33 @@ import subsequence.constants.instruments.gm_drums as gm_drums DRUMS_CHANNEL = 10 -BASS_CHANNEL = 6 +BASS_CHANNEL = 6 SYNTH_CHANNEL = 1 composition = subsequence.Composition(bpm=120, key="E") composition.harmony(style="aeolian_minor", cycle_beats=4, gravity=0.8) + @composition.pattern(channel=DRUMS_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) - p.hit_steps("snare_1", [4, 12], velocity=100) - p.hit_steps("hi_hat_closed", range(16), velocity=80) - p.velocity_shape(low=60, high=100) +def drums(p): + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) + p.hit_steps("snare_1", [4, 12], velocity=100) + p.hit_steps("hi_hat_closed", range(16), velocity=80) + p.velocity_shape(low=60, high=100) + @composition.pattern(channel=BASS_CHANNEL, beats=4) -def bass (p, chord): - root = chord.root_note(40) - p.sequence(steps=[0, 4, 8, 12], pitches=root) - p.legato(0.9) +def bass(p, chord): + root = chord.root_note(40) + p.sequence(steps=[0, 4, 8, 12], pitches=root) + p.legato(0.9) + @composition.pattern(channel=SYNTH_CHANNEL, beats=4) -def arp (p, chord): - pitches = chord.tones(root=60, count=4) - p.arpeggio(pitches, spacing=0.25, velocity=90, direction="up") +def arp(p, chord): + pitches = chord.tones(root=60, count=4) + p.arpeggio(pitches, spacing=0.25, velocity=90, direction="up") + if __name__ == "__main__": - composition.play() + composition.play() diff --git a/examples/demo_advanced.py b/examples/demo_advanced.py index a3de697..909491b 100644 --- a/examples/demo_advanced.py +++ b/examples/demo_advanced.py @@ -18,84 +18,88 @@ import subsequence.sequencer DRUMS_CHANNEL = 9 -BASS_CHANNEL = 5 +BASS_CHANNEL = 5 SYNTH_CHANNEL = 0 -class DrumPattern (subsequence.pattern.Pattern): - """Kick, snare, and hi-hats — built using the PatternBuilder bridge.""" +class DrumPattern(subsequence.pattern.Pattern): + """Kick, snare, and hi-hats — built using the PatternBuilder bridge.""" - def __init__ (self) -> None: - super().__init__(channel=DRUMS_CHANNEL, length=4) - self._build() + def __init__(self) -> None: + super().__init__(channel=DRUMS_CHANNEL, length=4) + self._build() - def _build (self) -> None: - self.steps = {} - p = subsequence.pattern_builder.PatternBuilder( - self, cycle=0, drum_note_map=gm_drums.GM_DRUM_MAP - ) - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) - p.hit_steps("snare_1", [4, 12], velocity=100) - p.hit_steps("hi_hat_closed", range(16), velocity=80) - p.velocity_shape(low=60, high=100) + def _build(self) -> None: + self.steps = {} + p = subsequence.pattern_builder.PatternBuilder( + self, cycle=0, drum_note_map=gm_drums.GM_DRUM_MAP + ) + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) + p.hit_steps("snare_1", [4, 12], velocity=100) + p.hit_steps("hi_hat_closed", range(16), velocity=80) + p.velocity_shape(low=60, high=100) - def on_reschedule (self) -> None: - self._build() + def on_reschedule(self) -> None: + self._build() -class BassPattern (subsequence.pattern.Pattern): - """Quarter-note bass following the harmony engine's current chord.""" +class BassPattern(subsequence.pattern.Pattern): + """Quarter-note bass following the harmony engine's current chord.""" - def __init__ (self, harmonic_state: subsequence.harmonic_state.HarmonicState) -> None: - super().__init__(channel=BASS_CHANNEL, length=4) - self.harmonic_state = harmonic_state - self._build() + def __init__( + self, harmonic_state: subsequence.harmonic_state.HarmonicState + ) -> None: + super().__init__(channel=BASS_CHANNEL, length=4) + self.harmonic_state = harmonic_state + self._build() - def _build (self) -> None: - self.steps = {} - chord = self.harmonic_state.get_current_chord() - root = chord.root_note(40) - for beat in range(4): - self.add_note_beats(beat, pitch=root, velocity=100, duration_beats=0.9) + def _build(self) -> None: + self.steps = {} + chord = self.harmonic_state.get_current_chord() + root = chord.root_note(40) + for beat in range(4): + self.add_note_beats(beat, pitch=root, velocity=100, duration_beats=0.9) - def on_reschedule (self) -> None: - self._build() + def on_reschedule(self) -> None: + self._build() -class ArpPattern (subsequence.pattern.Pattern): - """Ascending arpeggio cycling through the current chord's tones.""" +class ArpPattern(subsequence.pattern.Pattern): + """Ascending arpeggio cycling through the current chord's tones.""" - def __init__ (self, harmonic_state: subsequence.harmonic_state.HarmonicState) -> None: - super().__init__(channel=SYNTH_CHANNEL, length=4) - self.harmonic_state = harmonic_state - self._build() + def __init__( + self, harmonic_state: subsequence.harmonic_state.HarmonicState + ) -> None: + super().__init__(channel=SYNTH_CHANNEL, length=4) + self.harmonic_state = harmonic_state + self._build() - def _build (self) -> None: - self.steps = {} - chord = self.harmonic_state.get_current_chord() - pitches = chord.tones(root=60, count=4) - self.add_arpeggio_beats(pitches, spacing_beats=0.25, velocity=90) + def _build(self) -> None: + self.steps = {} + chord = self.harmonic_state.get_current_chord() + pitches = chord.tones(root=60, count=4) + self.add_arpeggio_beats(pitches, spacing_beats=0.25, velocity=90) - def on_reschedule (self) -> None: - self._build() + def on_reschedule(self) -> None: + self._build() -async def main () -> None: - seq = subsequence.sequencer.Sequencer(initial_bpm=120) - harmonic_state = subsequence.harmonic_state.HarmonicState( - key_name="E", graph_style="aeolian_minor", key_gravity_blend=0.8 - ) - await subsequence.composition.schedule_harmonic_clock( - seq, lambda: harmonic_state, cycle_beats=4 - ) +async def main() -> None: + seq = subsequence.sequencer.Sequencer(initial_bpm=120) + harmonic_state = subsequence.harmonic_state.HarmonicState( + key_name="E", graph_style="aeolian_minor", key_gravity_blend=0.8 + ) + await subsequence.composition.schedule_harmonic_clock( + seq, lambda: harmonic_state, cycle_beats=4 + ) - drums = DrumPattern() - bass = BassPattern(harmonic_state) - arp = ArpPattern(harmonic_state) + drums = DrumPattern() + bass = BassPattern(harmonic_state) + arp = ArpPattern(harmonic_state) - await subsequence.composition.schedule_patterns(seq, [drums, bass, arp]) - await subsequence.composition.run_until_stopped(seq) + await subsequence.composition.schedule_patterns(seq, [drums, bass, arp]) + await subsequence.composition.run_until_stopped(seq) if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) diff --git a/examples/emergence.py b/examples/emergence.py index 5737e50..5f975fa 100644 --- a/examples/emergence.py +++ b/examples/emergence.py @@ -52,374 +52,464 @@ # can repeat (pulse lingers, swarm sustains). The path through # form is never quite the same. -composition.form({ - "void": (8, [("pulse", 4), ("void", 1)]), - "pulse": (12, [("swarm", 3), ("pulse", 1)]), - "swarm": (16, [("fury", 3), ("swarm", 1), ("fracture", 1)]), - "fury": (8, [("dissolve", 3), ("fracture", 2)]), - "fracture": (4, [("dissolve", 2), ("pulse", 1), ("void", 1)]), - "dissolve": (12, [("void", 3), ("pulse", 1)]), -}, start="pulse") +composition.form( + { + "void": (8, [("pulse", 4), ("void", 1)]), + "pulse": (12, [("swarm", 3), ("pulse", 1)]), + "swarm": (16, [("fury", 3), ("swarm", 1), ("fracture", 1)]), + "fury": (8, [("dissolve", 3), ("fracture", 2)]), + "fracture": (4, [("dissolve", 2), ("pulse", 1), ("void", 1)]), + "dissolve": (12, [("void", 3), ("pulse", 1)]), + }, + start="pulse", +) + @composition.pattern(channel=DRUM_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): - - """Build one bar of drums for the current form section.""" - - if not p.section: - return - - section = p.section.name - t = p.section.progress # 0.0 -> ~1.0 within this section - - # ── Six Perlin fields - the wandering soul of the piece ─────── - # Each has a different speed (prime-ish multipliers) so they - # never synchronise. p.cycle increments every bar across all - # sections, so each pass through the form samples a fresh region - # of every noise field. - ghost_wander = subsequence.sequence_utils.perlin_1d(p.cycle * 0.07, seed=1) - hat_feel = subsequence.sequence_utils.perlin_1d(p.cycle * 0.05, seed=2) - tom_swell = subsequence.sequence_utils.perlin_1d(p.cycle * 0.04, seed=3) - kick_morph = subsequence.sequence_utils.perlin_1d(p.cycle * 0.09, seed=4) - space = subsequence.sequence_utils.perlin_1d(p.cycle * 0.06, seed=5) - chaos_spark = subsequence.sequence_utils.perlin_1d(p.cycle * 0.13, seed=6) - - def ease (value: float, shape: str = "linear") -> float: - - """Apply the named easing curve to a normalised value.""" - - return subsequence.easing.get_easing(shape)(value) - - # ═══════════════════════════════════════════════════════════════ - # VOID - # - # Near silence. A half-time kick fading in from nothing. - # Cellular automata whisper underneath - a rhythm heard through - # walls. A side stick appears and disappears, gated by Perlin. - # The most important instrument here is space itself. - # ═══════════════════════════════════════════════════════════════ - if section == "void": - - # Heartbeat: half-time kick - always audible, gently rising - kick_vel = round(62 + 18 * ease(t, "ease_in")) - p.hit_steps("kick_1", [0, 8], velocity=kick_vel) - - # Cellular whisper - low play-chance, evolving from bar 1 - ca_probability = 0.05 + 0.08 * ease(t, "ease_in") - p.cellular_1d("kick_1", rule=30, velocity=28, no_overlap=True, probability=ca_probability) - - # Side stick - Perlin-gated, only appears when noise allows - if ghost_wander > 0.65 and t >= 0.4: - p.hit_steps("side_stick", [4], velocity=round(28 + 18 * ghost_wander)) - - # A second side stick hints at the coming pulse - if hat_feel > 0.55 and t >= 0.75: - p.hit_steps("side_stick", [12], velocity=round(22 + 16 * hat_feel)) - - # ═══════════════════════════════════════════════════════════════ - # PULSE - # - # The kick finds its footing - half-time becoming four-on-the- - # floor. Snare enters tentatively. Hats appear as whispers. - # Everything is a promise of what's to come. Transition-aware: - # a snare roll builds if swarm is next. - # ═══════════════════════════════════════════════════════════════ - elif section == "pulse": - - if p.section.first_bar: - p.hit_steps("crash_1", [0], velocity=78) - - # Kick: half-time -> four-on-the-floor at progress 0.4 - if t < 0.4: - kick_vel = round(70 + 20 * ease(t / 0.4)) - p.hit_steps("kick_1", [0, 8], velocity=kick_vel) - else: - kick_vel = round(85 + 12 * ease((t - 0.4) / 0.6, "ease_in")) - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=kick_vel) - - # Cellular ghost kicks - evolving texture underneath - ca_probability = 0.08 + 0.15 * ease(t, "ease_in") - p.cellular_1d("kick_1", rule=30, velocity=28, no_overlap=True, probability=ca_probability) - - # Snare enters at progress 0.3 - if t >= 0.3: - snare_vel = round(52 + 28 * ease(min(1.0, (t - 0.3) / 0.7), "ease_in")) - p.hit_steps("snare_1", [4, 12], velocity=snare_vel) - - # Hats emerge in second half - sparse, Perlin-coloured - if t >= 0.5: - hat_d = 0.06 + 0.14 * ease(min(1.0, (t - 0.5) / 0.5), "ease_in") - hat_d *= (0.5 + hat_feel * 0.5) - p.bresenham_poly(parts={"hi_hat_closed": hat_d}, velocity={"hi_hat_closed": 50 + round(14 * hat_feel)}) - - # Transition: snare roll if heading to swarm - if (p.section.last_bar - and p.section.next_section == "swarm"): - p.hit_steps("snare_1", [8, 10, 12, 13, 14, 15], velocity=round(50 + 30 * ease(t))) - - # ═══════════════════════════════════════════════════════════════ - # SWARM - # - # The organism awakens. All voices present from bar one, their - # density breathing with Perlin. Ghost fills on kick and snare. - # Open hats punctuate. Toms stir when their Perlin field rises. - # Chaos sparks trigger occasional snare fills. - # ═══════════════════════════════════════════════════════════════ - elif section == "swarm": - - if p.section.first_bar: - p.hit_steps("crash_1", [0], velocity=100) - - # Kick: confident, building - kick_vel = round(90 + 14 * ease(t, "ease_in")) - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=kick_vel) - - # Kick ghosts: CA layer, Perlin-breathing density - ca_probability = 0.20 + 0.20 * ghost_wander - p.cellular_1d("kick_1", rule=30, velocity=34, no_overlap=True, probability=ca_probability) - - # Kick ghost fill: sixteenths bias, enters at 0.15 - if t >= 0.15: - gf_d = (0.05 + 0.15 * ease(min(1.0, (t - 0.15) / 0.85))) * (0.5 + ghost_wander * 0.5) - if gf_d > 0.02: - p.ghost_fill("kick_1", density=gf_d, velocity=(22, 42), bias="sixteenths", no_overlap=True) - - # Snare: gaining authority - snare_vel = round(76 + 20 * ease(t)) - p.hit_steps("snare_1", [4, 12], velocity=snare_vel) - - # Snare ghosts: "before" bias - groove glue - gs_d = (0.04 + 0.14 * ease(t, "ease_in_out")) - gs_d *= (0.6 + ghost_wander * 0.4) - if gs_d > 0.02: - p.ghost_fill("snare_1", density=gs_d, velocity=(18, 36), bias="before", no_overlap=True) - - # Hats: density builds, open hat enters at 0.55 - hat_d = 0.18 + 0.38 * ease(t, "ease_in_out") - hat_d *= (0.55 + hat_feel * 0.45) - parts = {"hi_hat_closed": hat_d} - if t >= 0.55: - hat_open_d = 0.02 + 0.09 * ease(min(1.0, (t - 0.55) / 0.45)) - hat_open_d *= (0.35 + hat_feel * 0.65) - parts["hi_hat_open"] = hat_open_d - p.bresenham_poly( - parts=parts, - velocity={"hi_hat_closed": 62, "hi_hat_open": 78}, ) - - # Toms: emerge when tom_swell Perlin rises - if t >= 0.4 and tom_swell > 0.35: - tom_d = 0.20 * ease(min(1.0, (t - 0.4) / 0.6)) * (tom_swell - 0.35) / 0.65 - if tom_d > 0.01: - p.bresenham_poly(parts={ - "high_tom": tom_d * 0.50, - "low_mid_tom": tom_d * 0.30, - "low_tom": tom_d * 0.20, }, - velocity={ - "high_tom": 76, "low_mid_tom": 71, "low_tom": 66, }, - ) - - # Chaos spark: snare fill on phrase boundaries - if chaos_spark > 0.78 and p.section.bar % 4 == 3: - p.hit_steps("snare_1", [13, 14, 15], velocity=round(52 + 38 * chaos_spark)) - - # Transition: ride swell if fury is next - if (p.section.last_bar - and p.section.next_section == "fury"): - p.hit_steps("ride_1", [0, 4, 8, 12], velocity=68) - - # ═══════════════════════════════════════════════════════════════ - # FURY - # - # Maximum intensity. Every voice at full power, every ghost - # layer active. Short - 8 bars - a controlled explosion. - # CA fractal textures on hats, tom cascades, snare fills - # ignited by the chaos spark. Dense but locked to the grid. - # ═══════════════════════════════════════════════════════════════ - elif section == "fury": - - # Crash on entry and every 4th bar to sustain energy - if p.section.first_bar or p.section.bar % 4 == 0: - p.hit_steps("crash_1", [0], velocity=120) - - # Kick: FULL POWER - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=112) - - # Maximum kick ghost layering: CA + probability fill - p.cellular_1d("kick_1", rule=30, velocity=40, no_overlap=True, probability=0.74) - gf_d = 0.22 * (0.5 + ghost_wander * 0.5) - p.ghost_fill("kick_1", density=gf_d, velocity=(28, 48), bias="sixteenths", no_overlap=True) - - # Snare: full authority - p.hit_steps("snare_1", [4, 12], velocity=108) - - # Thick snare ghosts - gs_d = 0.20 * (0.6 + ghost_wander * 0.4) - p.ghost_fill("snare_1", density=gs_d, velocity=(22, 42), bias="before", no_overlap=True) - - # Hats: near-maximum density, Perlin-breathing - hat_d = 0.78 + 0.22 * hat_feel - hat_open_d = 0.09 + 0.09 * hat_feel - p.bresenham_poly( - parts={"hi_hat_closed": hat_d, "hi_hat_open": hat_open_d}, - velocity={"hi_hat_closed": 72, "hi_hat_open": 86}, ) - - # CA hat fractal texture - Rule 90 Sierpinski patterns - p.cellular_1d("hi_hat_closed", rule=90, velocity=42, no_overlap=True, probability=0.64) - - # Tom cascades - tom_d = 0.28 * (0.3 + tom_swell * 0.7) - if tom_d > 0.01: - p.bresenham_poly(parts={ - "high_tom": tom_d * 0.40, - "low_mid_tom": tom_d * 0.35, - "low_tom": tom_d * 0.25, }, - velocity={ - "high_tom": 90, "low_mid_tom": 85, "low_tom": 80, }, - ) - - # Chaos spark: dense syncopated snare bursts - if chaos_spark > 0.6: - spark_density = 0.12 + 0.22 * (chaos_spark - 0.6) / 0.4 - spark_vel = round(45 + 35 * (chaos_spark - 0.6) / 0.4) - p.ghost_fill("snare_1", density=spark_density, - velocity=(spark_vel - 12, spark_vel), bias="offbeat", no_overlap=True) - - # Transition: tom cascade into fracture - if (p.section.last_bar - and p.section.next_section == "fracture"): - p.hit_steps("high_tom", [12, 13], velocity=110) - p.hit_steps("low_mid_tom", [14], velocity=105) - p.hit_steps("low_tom", [15], velocity=100) - - # ═══════════════════════════════════════════════════════════════ - # FRACTURE - # - # Musical chaos - four bars of controlled madness. The kick - # itself mutates: euclidean rhythms with Perlin-driven pulse - # counts instead of four-on-the-floor. CA Rule 110 (Turing- - # complete) drives hats - intricate, non-repeating, structured. - # Offbeat snare ghosts at high density. Tom barrage. - # Everything is algorithmically placed - structured chaos, - # not noise. - # ═══════════════════════════════════════════════════════════════ - elif section == "fracture": - - # No crash - fracture erupts mid-flow, not at a boundary - - # Kick: MUTATED - euclidean with Perlin-driven pulse count - # 3 pulses = angular, minimal. 8 pulses = dense machine-gun. - pulse_count = round(3 + 5 * kick_morph) - p.euclidean("kick_1", pulses=pulse_count, velocity=round(92 + 20 * kick_morph)) - - # Kick CA: Rule 110 - Turing-complete, complex - p.cellular_1d("kick_1", rule=110, velocity=44, no_overlap=True, probability=0.80) - - # Snare anchor + dense offbeat ghosts - p.hit_steps("snare_1", [4, 12], velocity=102) - p.ghost_fill("snare_1", density=0.28 * (0.5 + ghost_wander * 0.5), - velocity=(26, 55), bias="offbeat", no_overlap=True) - - # Hats: CA Rule 110 - intricate, unpredictable, structured - p.cellular_1d("hi_hat_closed", rule=110, velocity=round(50 + 24 * hat_feel), probability=0.84) - - # Open hat accents when Perlin allows - if hat_feel > 0.4: - oh_d = 0.10 * hat_feel - p.bresenham_poly(parts={"hi_hat_open": oh_d}, velocity={"hi_hat_open": 85}, ) - - # Tom barrage - all three voices at high density - tom_d = 0.36 * (0.3 + tom_swell * 0.7) - if tom_d > 0.01: - p.bresenham_poly(parts={ - "high_tom": tom_d * 0.35, - "low_mid_tom": tom_d * 0.35, - "low_tom": tom_d * 0.30, }, - velocity={ - "high_tom": 92, "low_mid_tom": 88, "low_tom": 82, }, - ) - - # Ride: CA chaos layer when spark is high - if chaos_spark > 0.42: - p.cellular_1d("ride_1", rule=30, velocity=round(38 + 30 * chaos_spark), probability=0.58) - - # ═══════════════════════════════════════════════════════════════ - # DISSOLVE - # - # The exhale. Kick drops to half-time. Layers thin and fade - # with eased curves. A CA whisper returns near the end - - # echoing the void. The final bars fill with pedal hat wash - # before silence takes over. - # ═══════════════════════════════════════════════════════════════ - elif section == "dissolve": - - if p.section.first_bar: - p.hit_steps("crash_1", [0], velocity=86) - - # Kick: half-time from the start, fading - kick_vel = round(90 - 28 * ease(t, "ease_out")) - p.hit_steps("kick_1", [0, 8], velocity=kick_vel) - - # Snare: fading backbeat - disappears around 75% through - snare_vel = round(86 - 55 * ease(t, "ease_out")) - if snare_vel > 36: - p.hit_steps("snare_1", [4, 12], velocity=snare_vel) - - # Snare ghosts thin out - gs_d = 0.20 * (1.0 - 0.92 * ease(t, "ease_out")) - gs_d *= (0.6 + ghost_wander * 0.4) - if gs_d > 0.02: - p.ghost_fill("snare_1", density=gs_d, velocity=(16, 30), bias="before", no_overlap=True) - - # Hats thin - hat_d = 0.45 * (1.0 - 0.88 * ease(t, "ease_out")) - hat_d *= (0.55 + hat_feel * 0.45) - if hat_d > 0.01: - p.bresenham_poly(parts={"hi_hat_closed": hat_d}, velocity={"hi_hat_closed": 56}, ) - - # Toms fade in first third - if t < 0.33: - tom_d = 0.16 * (1.0 - ease(t * 3.0, "ease_out")) - tom_d *= (0.3 + tom_swell * 0.7) - if tom_d > 0.01: - p.bresenham_poly(parts={ - "high_tom": tom_d * 0.50, - "low_mid_tom": tom_d * 0.30, - "low_tom": tom_d * 0.20, }, - velocity={ - "high_tom": 70, "low_mid_tom": 65, "low_tom": 60, }, - ) - - # CA whisper returns near the end - echoing the void - if t >= 0.7: - p.cellular_1d("kick_1", rule=30, velocity=18, no_overlap=True, probability=0.10) - - # Pedal hat wash in final 2 bars - if p.section.bar >= p.section.bars - 2: - wash_bar = p.section.bar - (p.section.bars - 2) - pedal_vel = round(46 - 20 * ease(wash_bar / 1.0, "ease_out")) - p.hit_steps("hi_hat_pedal", range(16), velocity=max(20, pedal_vel)) - - # Closing crash on the very last step - if p.section.last_bar: - p.hit_steps("crash_1", [15], velocity=70) - - # ── Lightning ───────────────────────────────────────────────── - # When the chaos_spark Perlin peaks above 0.92, a rare burst - # of maximum density fires on top of whatever section is - # playing. Occurs roughly once every 70-80 bars - a flash - # of transcendence. Void is exempt (silence is sacred). - if chaos_spark > 0.92 and section != "void": - p.ghost_fill("kick_1", density=0.32, velocity=(34, 55), bias="uniform", no_overlap=True) - p.ghost_fill("snare_1", density=0.22, velocity=(28, 48), bias="offbeat", no_overlap=True) - if section != "fracture": # fracture already has ride - p.cellular_1d("ride_1", rule=30, velocity=46, probability=0.52) - - # ── Groove template (optional) ──────────────────────────────── - # Uncomment to add swing feel from the .agr groove file: - # p.groove(groove) +def drums(p): + """Build one bar of drums for the current form section.""" + + if not p.section: + return + + section = p.section.name + t = p.section.progress # 0.0 -> ~1.0 within this section + + # ── Six Perlin fields - the wandering soul of the piece ─────── + # Each has a different speed (prime-ish multipliers) so they + # never synchronise. p.cycle increments every bar across all + # sections, so each pass through the form samples a fresh region + # of every noise field. + ghost_wander = subsequence.sequence_utils.perlin_1d(p.cycle * 0.07, seed=1) + hat_feel = subsequence.sequence_utils.perlin_1d(p.cycle * 0.05, seed=2) + tom_swell = subsequence.sequence_utils.perlin_1d(p.cycle * 0.04, seed=3) + kick_morph = subsequence.sequence_utils.perlin_1d(p.cycle * 0.09, seed=4) + space = subsequence.sequence_utils.perlin_1d(p.cycle * 0.06, seed=5) + chaos_spark = subsequence.sequence_utils.perlin_1d(p.cycle * 0.13, seed=6) + + def ease(value: float, shape: str = "linear") -> float: + """Apply the named easing curve to a normalised value.""" + + return subsequence.easing.get_easing(shape)(value) + + # ═══════════════════════════════════════════════════════════════ + # VOID + # + # Near silence. A half-time kick fading in from nothing. + # Cellular automata whisper underneath - a rhythm heard through + # walls. A side stick appears and disappears, gated by Perlin. + # The most important instrument here is space itself. + # ═══════════════════════════════════════════════════════════════ + if section == "void": + # Heartbeat: half-time kick - always audible, gently rising + kick_vel = round(62 + 18 * ease(t, "ease_in")) + p.hit_steps("kick_1", [0, 8], velocity=kick_vel) + + # Cellular whisper - low play-chance, evolving from bar 1 + ca_probability = 0.05 + 0.08 * ease(t, "ease_in") + p.cellular_1d( + "kick_1", rule=30, velocity=28, no_overlap=True, probability=ca_probability + ) + + # Side stick - Perlin-gated, only appears when noise allows + if ghost_wander > 0.65 and t >= 0.4: + p.hit_steps("side_stick", [4], velocity=round(28 + 18 * ghost_wander)) + + # A second side stick hints at the coming pulse + if hat_feel > 0.55 and t >= 0.75: + p.hit_steps("side_stick", [12], velocity=round(22 + 16 * hat_feel)) + + # ═══════════════════════════════════════════════════════════════ + # PULSE + # + # The kick finds its footing - half-time becoming four-on-the- + # floor. Snare enters tentatively. Hats appear as whispers. + # Everything is a promise of what's to come. Transition-aware: + # a snare roll builds if swarm is next. + # ═══════════════════════════════════════════════════════════════ + elif section == "pulse": + if p.section.first_bar: + p.hit_steps("crash_1", [0], velocity=78) + + # Kick: half-time -> four-on-the-floor at progress 0.4 + if t < 0.4: + kick_vel = round(70 + 20 * ease(t / 0.4)) + p.hit_steps("kick_1", [0, 8], velocity=kick_vel) + else: + kick_vel = round(85 + 12 * ease((t - 0.4) / 0.6, "ease_in")) + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=kick_vel) + + # Cellular ghost kicks - evolving texture underneath + ca_probability = 0.08 + 0.15 * ease(t, "ease_in") + p.cellular_1d( + "kick_1", rule=30, velocity=28, no_overlap=True, probability=ca_probability + ) + + # Snare enters at progress 0.3 + if t >= 0.3: + snare_vel = round(52 + 28 * ease(min(1.0, (t - 0.3) / 0.7), "ease_in")) + p.hit_steps("snare_1", [4, 12], velocity=snare_vel) + + # Hats emerge in second half - sparse, Perlin-coloured + if t >= 0.5: + hat_d = 0.06 + 0.14 * ease(min(1.0, (t - 0.5) / 0.5), "ease_in") + hat_d *= 0.5 + hat_feel * 0.5 + p.bresenham_poly( + parts={"hi_hat_closed": hat_d}, + velocity={"hi_hat_closed": 50 + round(14 * hat_feel)}, + ) + + # Transition: snare roll if heading to swarm + if p.section.last_bar and p.section.next_section == "swarm": + p.hit_steps( + "snare_1", [8, 10, 12, 13, 14, 15], velocity=round(50 + 30 * ease(t)) + ) + + # ═══════════════════════════════════════════════════════════════ + # SWARM + # + # The organism awakens. All voices present from bar one, their + # density breathing with Perlin. Ghost fills on kick and snare. + # Open hats punctuate. Toms stir when their Perlin field rises. + # Chaos sparks trigger occasional snare fills. + # ═══════════════════════════════════════════════════════════════ + elif section == "swarm": + if p.section.first_bar: + p.hit_steps("crash_1", [0], velocity=100) + + # Kick: confident, building + kick_vel = round(90 + 14 * ease(t, "ease_in")) + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=kick_vel) + + # Kick ghosts: CA layer, Perlin-breathing density + ca_probability = 0.20 + 0.20 * ghost_wander + p.cellular_1d( + "kick_1", rule=30, velocity=34, no_overlap=True, probability=ca_probability + ) + + # Kick ghost fill: sixteenths bias, enters at 0.15 + if t >= 0.15: + gf_d = (0.05 + 0.15 * ease(min(1.0, (t - 0.15) / 0.85))) * ( + 0.5 + ghost_wander * 0.5 + ) + if gf_d > 0.02: + p.ghost_fill( + "kick_1", + density=gf_d, + velocity=(22, 42), + bias="sixteenths", + no_overlap=True, + ) + + # Snare: gaining authority + snare_vel = round(76 + 20 * ease(t)) + p.hit_steps("snare_1", [4, 12], velocity=snare_vel) + + # Snare ghosts: "before" bias - groove glue + gs_d = 0.04 + 0.14 * ease(t, "ease_in_out") + gs_d *= 0.6 + ghost_wander * 0.4 + if gs_d > 0.02: + p.ghost_fill( + "snare_1", + density=gs_d, + velocity=(18, 36), + bias="before", + no_overlap=True, + ) + + # Hats: density builds, open hat enters at 0.55 + hat_d = 0.18 + 0.38 * ease(t, "ease_in_out") + hat_d *= 0.55 + hat_feel * 0.45 + parts = {"hi_hat_closed": hat_d} + if t >= 0.55: + hat_open_d = 0.02 + 0.09 * ease(min(1.0, (t - 0.55) / 0.45)) + hat_open_d *= 0.35 + hat_feel * 0.65 + parts["hi_hat_open"] = hat_open_d + p.bresenham_poly( + parts=parts, + velocity={"hi_hat_closed": 62, "hi_hat_open": 78}, + ) + + # Toms: emerge when tom_swell Perlin rises + if t >= 0.4 and tom_swell > 0.35: + tom_d = 0.20 * ease(min(1.0, (t - 0.4) / 0.6)) * (tom_swell - 0.35) / 0.65 + if tom_d > 0.01: + p.bresenham_poly( + parts={ + "high_tom": tom_d * 0.50, + "low_mid_tom": tom_d * 0.30, + "low_tom": tom_d * 0.20, + }, + velocity={ + "high_tom": 76, + "low_mid_tom": 71, + "low_tom": 66, + }, + ) + + # Chaos spark: snare fill on phrase boundaries + if chaos_spark > 0.78 and p.section.bar % 4 == 3: + p.hit_steps("snare_1", [13, 14, 15], velocity=round(52 + 38 * chaos_spark)) + + # Transition: ride swell if fury is next + if p.section.last_bar and p.section.next_section == "fury": + p.hit_steps("ride_1", [0, 4, 8, 12], velocity=68) + + # ═══════════════════════════════════════════════════════════════ + # FURY + # + # Maximum intensity. Every voice at full power, every ghost + # layer active. Short - 8 bars - a controlled explosion. + # CA fractal textures on hats, tom cascades, snare fills + # ignited by the chaos spark. Dense but locked to the grid. + # ═══════════════════════════════════════════════════════════════ + elif section == "fury": + # Crash on entry and every 4th bar to sustain energy + if p.section.first_bar or p.section.bar % 4 == 0: + p.hit_steps("crash_1", [0], velocity=120) + + # Kick: FULL POWER + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=112) + + # Maximum kick ghost layering: CA + probability fill + p.cellular_1d("kick_1", rule=30, velocity=40, no_overlap=True, probability=0.74) + gf_d = 0.22 * (0.5 + ghost_wander * 0.5) + p.ghost_fill( + "kick_1", + density=gf_d, + velocity=(28, 48), + bias="sixteenths", + no_overlap=True, + ) + + # Snare: full authority + p.hit_steps("snare_1", [4, 12], velocity=108) + + # Thick snare ghosts + gs_d = 0.20 * (0.6 + ghost_wander * 0.4) + p.ghost_fill( + "snare_1", density=gs_d, velocity=(22, 42), bias="before", no_overlap=True + ) + + # Hats: near-maximum density, Perlin-breathing + hat_d = 0.78 + 0.22 * hat_feel + hat_open_d = 0.09 + 0.09 * hat_feel + p.bresenham_poly( + parts={"hi_hat_closed": hat_d, "hi_hat_open": hat_open_d}, + velocity={"hi_hat_closed": 72, "hi_hat_open": 86}, + ) + + # CA hat fractal texture - Rule 90 Sierpinski patterns + p.cellular_1d( + "hi_hat_closed", rule=90, velocity=42, no_overlap=True, probability=0.64 + ) + + # Tom cascades + tom_d = 0.28 * (0.3 + tom_swell * 0.7) + if tom_d > 0.01: + p.bresenham_poly( + parts={ + "high_tom": tom_d * 0.40, + "low_mid_tom": tom_d * 0.35, + "low_tom": tom_d * 0.25, + }, + velocity={ + "high_tom": 90, + "low_mid_tom": 85, + "low_tom": 80, + }, + ) + + # Chaos spark: dense syncopated snare bursts + if chaos_spark > 0.6: + spark_density = 0.12 + 0.22 * (chaos_spark - 0.6) / 0.4 + spark_vel = round(45 + 35 * (chaos_spark - 0.6) / 0.4) + p.ghost_fill( + "snare_1", + density=spark_density, + velocity=(spark_vel - 12, spark_vel), + bias="offbeat", + no_overlap=True, + ) + + # Transition: tom cascade into fracture + if p.section.last_bar and p.section.next_section == "fracture": + p.hit_steps("high_tom", [12, 13], velocity=110) + p.hit_steps("low_mid_tom", [14], velocity=105) + p.hit_steps("low_tom", [15], velocity=100) + + # ═══════════════════════════════════════════════════════════════ + # FRACTURE + # + # Musical chaos - four bars of controlled madness. The kick + # itself mutates: euclidean rhythms with Perlin-driven pulse + # counts instead of four-on-the-floor. CA Rule 110 (Turing- + # complete) drives hats - intricate, non-repeating, structured. + # Offbeat snare ghosts at high density. Tom barrage. + # Everything is algorithmically placed - structured chaos, + # not noise. + # ═══════════════════════════════════════════════════════════════ + elif section == "fracture": + # No crash - fracture erupts mid-flow, not at a boundary + + # Kick: MUTATED - euclidean with Perlin-driven pulse count + # 3 pulses = angular, minimal. 8 pulses = dense machine-gun. + pulse_count = round(3 + 5 * kick_morph) + p.euclidean("kick_1", pulses=pulse_count, velocity=round(92 + 20 * kick_morph)) + + # Kick CA: Rule 110 - Turing-complete, complex + p.cellular_1d( + "kick_1", rule=110, velocity=44, no_overlap=True, probability=0.80 + ) + + # Snare anchor + dense offbeat ghosts + p.hit_steps("snare_1", [4, 12], velocity=102) + p.ghost_fill( + "snare_1", + density=0.28 * (0.5 + ghost_wander * 0.5), + velocity=(26, 55), + bias="offbeat", + no_overlap=True, + ) + + # Hats: CA Rule 110 - intricate, unpredictable, structured + p.cellular_1d( + "hi_hat_closed", + rule=110, + velocity=round(50 + 24 * hat_feel), + probability=0.84, + ) + + # Open hat accents when Perlin allows + if hat_feel > 0.4: + oh_d = 0.10 * hat_feel + p.bresenham_poly( + parts={"hi_hat_open": oh_d}, + velocity={"hi_hat_open": 85}, + ) + + # Tom barrage - all three voices at high density + tom_d = 0.36 * (0.3 + tom_swell * 0.7) + if tom_d > 0.01: + p.bresenham_poly( + parts={ + "high_tom": tom_d * 0.35, + "low_mid_tom": tom_d * 0.35, + "low_tom": tom_d * 0.30, + }, + velocity={ + "high_tom": 92, + "low_mid_tom": 88, + "low_tom": 82, + }, + ) + + # Ride: CA chaos layer when spark is high + if chaos_spark > 0.42: + p.cellular_1d( + "ride_1", + rule=30, + velocity=round(38 + 30 * chaos_spark), + probability=0.58, + ) + + # ═══════════════════════════════════════════════════════════════ + # DISSOLVE + # + # The exhale. Kick drops to half-time. Layers thin and fade + # with eased curves. A CA whisper returns near the end - + # echoing the void. The final bars fill with pedal hat wash + # before silence takes over. + # ═══════════════════════════════════════════════════════════════ + elif section == "dissolve": + if p.section.first_bar: + p.hit_steps("crash_1", [0], velocity=86) + + # Kick: half-time from the start, fading + kick_vel = round(90 - 28 * ease(t, "ease_out")) + p.hit_steps("kick_1", [0, 8], velocity=kick_vel) + + # Snare: fading backbeat - disappears around 75% through + snare_vel = round(86 - 55 * ease(t, "ease_out")) + if snare_vel > 36: + p.hit_steps("snare_1", [4, 12], velocity=snare_vel) + + # Snare ghosts thin out + gs_d = 0.20 * (1.0 - 0.92 * ease(t, "ease_out")) + gs_d *= 0.6 + ghost_wander * 0.4 + if gs_d > 0.02: + p.ghost_fill( + "snare_1", + density=gs_d, + velocity=(16, 30), + bias="before", + no_overlap=True, + ) + + # Hats thin + hat_d = 0.45 * (1.0 - 0.88 * ease(t, "ease_out")) + hat_d *= 0.55 + hat_feel * 0.45 + if hat_d > 0.01: + p.bresenham_poly( + parts={"hi_hat_closed": hat_d}, + velocity={"hi_hat_closed": 56}, + ) + + # Toms fade in first third + if t < 0.33: + tom_d = 0.16 * (1.0 - ease(t * 3.0, "ease_out")) + tom_d *= 0.3 + tom_swell * 0.7 + if tom_d > 0.01: + p.bresenham_poly( + parts={ + "high_tom": tom_d * 0.50, + "low_mid_tom": tom_d * 0.30, + "low_tom": tom_d * 0.20, + }, + velocity={ + "high_tom": 70, + "low_mid_tom": 65, + "low_tom": 60, + }, + ) + + # CA whisper returns near the end - echoing the void + if t >= 0.7: + p.cellular_1d( + "kick_1", rule=30, velocity=18, no_overlap=True, probability=0.10 + ) + + # Pedal hat wash in final 2 bars + if p.section.bar >= p.section.bars - 2: + wash_bar = p.section.bar - (p.section.bars - 2) + pedal_vel = round(46 - 20 * ease(wash_bar / 1.0, "ease_out")) + p.hit_steps("hi_hat_pedal", range(16), velocity=max(20, pedal_vel)) + + # Closing crash on the very last step + if p.section.last_bar: + p.hit_steps("crash_1", [15], velocity=70) + + # ── Lightning ───────────────────────────────────────────────── + # When the chaos_spark Perlin peaks above 0.92, a rare burst + # of maximum density fires on top of whatever section is + # playing. Occurs roughly once every 70-80 bars - a flash + # of transcendence. Void is exempt (silence is sacred). + if chaos_spark > 0.92 and section != "void": + p.ghost_fill( + "kick_1", density=0.32, velocity=(34, 55), bias="uniform", no_overlap=True + ) + p.ghost_fill( + "snare_1", density=0.22, velocity=(28, 48), bias="offbeat", no_overlap=True + ) + if section != "fracture": # fracture already has ride + p.cellular_1d("ride_1", rule=30, velocity=46, probability=0.52) + + # ── Groove template (optional) ──────────────────────────────── + # Uncomment to add swing feel from the .agr groove file: + # p.groove(groove) -if __name__ == "__main__": - composition.display(grid=True, grid_scale=2) - composition.web_ui() - composition.play() +if __name__ == "__main__": + composition.display(grid=True, grid_scale=2) + composition.web_ui() + composition.play() diff --git a/examples/frozen.py b/examples/frozen.py index 17a8065..396e861 100644 --- a/examples/frozen.py +++ b/examples/frozen.py @@ -25,8 +25,8 @@ logging.basicConfig(level=logging.INFO) DRUM_CHANNEL = 10 -BASS_CHANNEL = 6 -ARP_CHANNEL = 1 +BASS_CHANNEL = 6 +ARP_CHANNEL = 1 # --------------------------------------------------------------------------- # Composition @@ -44,43 +44,46 @@ # Verse: high gravity keeps chords close to the tonic; low NIR = settled feel. composition.harmony( - style="functional_major", - cycle_beats=4, - gravity=0.85, - nir_strength=0.2, + style="functional_major", + cycle_beats=4, + gravity=0.85, + nir_strength=0.2, ) -verse = composition.freeze(8) # 8 chord changes (one per bar) +verse = composition.freeze(8) # 8 chord changes (one per bar) # Chorus: looser gravity lets the engine wander; high NIR pushes motion forward. composition.harmony( - style="functional_major", - cycle_beats=4, - gravity=0.35, - nir_strength=0.8, + style="functional_major", + cycle_beats=4, + gravity=0.35, + nir_strength=0.8, ) chorus = composition.freeze(4) # 4 chord changes # Bridge: no freeze — the engine generates live chords every time it plays. # harmony() reconfigures for a suspended colour that suits improvisation. composition.harmony( - style="suspended", - cycle_beats=4, - gravity=0.5, - nir_strength=0.5, + style="suspended", + cycle_beats=4, + gravity=0.5, + nir_strength=0.5, ) # --------------------------------------------------------------------------- # Form # --------------------------------------------------------------------------- -composition.form({ - "verse": (8, [("verse", 2), ("chorus", 1)]), - "chorus": (4, [("bridge", 1)]), - "bridge": (4, [("verse", 1)]), -}, start="verse") +composition.form( + { + "verse": (8, [("verse", 2), ("chorus", 1)]), + "chorus": (4, [("bridge", 1)]), + "bridge": (4, [("verse", 1)]), + }, + start="verse", +) # Bind frozen progressions to sections. -composition.section_chords("verse", verse) +composition.section_chords("verse", verse) composition.section_chords("chorus", chorus) # bridge is intentionally unbound — it always generates live chords. @@ -88,52 +91,49 @@ # Patterns # --------------------------------------------------------------------------- -@composition.pattern(channel=DRUM_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): - p.hit_steps("kick_2", {0, 8, 12}, velocity=100) - p.hit_steps("snare_1", {4, 12}, velocity=90) - p.hit_steps("hi_hat_closed", range(16), velocity=65) +@composition.pattern(channel=DRUM_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) +def drums(p): + p.hit_steps("kick_2", {0, 8, 12}, velocity=100) + p.hit_steps("snare_1", {4, 12}, velocity=90) + p.hit_steps("hi_hat_closed", range(16), velocity=65) - if p.section and p.section.name == "chorus": - # Open hi-hat on the off-beat in the chorus for extra lift. - p.hit_steps("hi_hat_open", {6, 14}, velocity=75) + if p.section and p.section.name == "chorus": + # Open hi-hat on the off-beat in the chorus for extra lift. + p.hit_steps("hi_hat_open", {6, 14}, velocity=75) @composition.pattern(channel=BASS_CHANNEL, beats=4) -def bass (p, chord): +def bass(p, chord): + # Root note of the current chord, one octave below middle C. + root = chord.root_note(36) - # Root note of the current chord, one octave below middle C. - root = chord.root_note(36) + p.sequence(steps={0, 4, 8, 12}, pitches=root, velocities=95) + p.legato(0.9) - p.sequence(steps={0, 4, 8, 12}, pitches=root, velocities=95) - p.legato(0.9) - - if p.section and p.section.name == "chorus": - # Add a passing note on beat 3 in the chorus. - fifth = chord.root_note(36) + 7 - p.sequence(steps={10}, pitches=fifth, velocities=80) + if p.section and p.section.name == "chorus": + # Add a passing note on beat 3 in the chorus. + fifth = chord.root_note(36) + 7 + p.sequence(steps={10}, pitches=fifth, velocities=80) @composition.pattern(channel=ARP_CHANNEL, beats=4) -def arp (p, chord): - - if not p.section or p.section.name == "verse": - # Verse: simple root-chord arpeggio. - pitches = chord.tones(root=60, count=4) - p.arpeggio(pitches, spacing=0.5, velocity=75) +def arp(p, chord): + if not p.section or p.section.name == "verse": + # Verse: simple root-chord arpeggio. + pitches = chord.tones(root=60, count=4) + p.arpeggio(pitches, spacing=0.5, velocity=75) - elif p.section.name == "chorus": - # Chorus: faster sixteenth-note arpeggio across a wider range. - pitches = chord.tones(root=60, count=8) - p.arpeggio(pitches, spacing=0.25, velocity=80) + elif p.section.name == "chorus": + # Chorus: faster sixteenth-note arpeggio across a wider range. + pitches = chord.tones(root=60, count=8) + p.arpeggio(pitches, spacing=0.25, velocity=80) - # Bridge: silence — let the live harmony breathe on its own. + # Bridge: silence — let the live harmony breathe on its own. # --------------------------------------------------------------------------- if __name__ == "__main__": - - composition.display() - composition.play() + composition.display() + composition.play() diff --git a/examples/iss.py b/examples/iss.py index ce158fa..867dd1e 100644 --- a/examples/iss.py +++ b/examples/iss.py @@ -47,12 +47,12 @@ # requests is not a subsequence dependency — this example fetches live ISS # telemetry over HTTP and needs it installed separately. try: - import requests + import requests except ImportError: - raise SystemExit( - "This example fetches live ISS position data and needs the 'requests' " - "package — install it with: pip install requests" - ) + raise SystemExit( + "This example fetches live ISS position data and needs the 'requests' " + "package — install it with: pip install requests" + ) import subsequence import subsequence.constants.instruments.gm_drums as gm_drums @@ -62,14 +62,11 @@ logging.basicConfig(level=logging.INFO) DRUMS_CHANNEL = 10 -BASS_CHANNEL = 6 +BASS_CHANNEL = 6 CHORD_CHANNEL = 1 -ARP_CHANNEL = 4 +ARP_CHANNEL = 4 -composition = subsequence.Composition( - bpm=120, - key="E" -) +composition = subsequence.Composition(bpm=120, key="E") # Day/night determines the harmonic mode. # Major sounds open and functional; Dorian minor is darker and introspective. @@ -79,13 +76,13 @@ # EasedValues interpolate between API fetches, giving patterns a smooth curve # rather than a step jump every 32 seconds. No initial value : first fetch # sets the target immediately with no unintended ease from 0. -iss_lat = subsequence.easing.EasedValue() # 0=south pole, 0.5=equator, 1=north -iss_lon = subsequence.easing.EasedValue() # 0=180°W, 1=180°E -iss_alt = subsequence.easing.EasedValue() # 370–460 km : 0–1 -iss_vel = subsequence.easing.EasedValue() # 27,500–27,750 km/h (very stable) +iss_lat = subsequence.easing.EasedValue() # 0=south pole, 0.5=equator, 1=north +iss_lon = subsequence.easing.EasedValue() # 0=180°W, 1=180°E +iss_alt = subsequence.easing.EasedValue() # 370–460 km : 0–1 +iss_vel = subsequence.easing.EasedValue() # 27,500–27,750 km/h (very stable) iss_footprint = subsequence.easing.EasedValue() # Ground visibility diameter -iss_sol_lat = subsequence.easing.EasedValue() # Subsolar latitude (−23.4°–23.4°) -iss_sol_lon = subsequence.easing.EasedValue() # Subsolar longitude (−180°–180°) +iss_sol_lat = subsequence.easing.EasedValue() # Subsolar latitude (−23.4°–23.4°) +iss_sol_lon = subsequence.easing.EasedValue() # Subsolar longitude (−180°–180°) # Expose them to the Web UI composition.data["iss_lat"] = iss_lat @@ -106,72 +103,75 @@ composition.harmony(style=CHORD_GRAPH_DAYLIGHT, cycle_beats=32, gravity=0.5) -def fetch_iss (p) -> None: - - """Fetch ISS telemetry and update BPM, harmony style, and shared data.""" - - try: - body = requests.get("https://api.wheretheiss.at/v1/satellites/25544").json() - sc = subsequence.sequence_utils.scale_clamp # normalise any value to 0–1 - - lat = float(body["latitude"]) - lon = float(body["longitude"]) - alt = float(body["altitude"]) - vel = float(body["velocity"]) - vis = body["visibility"] - foot = float(body["footprint"]) - sol_lat = float(body["solar_lat"]) - sol_lon = float(body["solar_lon"]) - daynum = float(body["daynum"]) # Julian Day Number — logged for context - - # Normalise to 0–1 using each parameter's known physical range. - iss_lat.update(sc(lat, -51.6, 51.6)) # Orbital inclination bounds - iss_lon.update(sc(lon, -180, 180)) - iss_alt.update(sc(alt, 370, 460)) - iss_vel.update(sc(vel, 27500, 27750)) # Very stable; available for use - iss_footprint.update(sc(foot, 4400, 4600)) - iss_sol_lat.update(sc(sol_lat, -23.44, 23.44)) # Earth's axial tilt bounds - iss_sol_lon.update(sc(sol_lon, -180, 180)) - - composition.data["iss_visibility"] = 1.0 if vis == "daylight" else 0.0 - - # Solar longitude selects which 16th-note step gets an open hi-hat accent. - # It shifts ~2 steps per hour as the subsolar point circles the Earth. - composition.data["iss_hat_accent"] = int(iss_sol_lon.current * 15) - - logging.info( - f"ISS lat={lat:+.1f}° lon={lon:+.1f}° alt={alt:.0f}km " - f"vel={vel:.0f}km/h vis={vis} foot={foot:.0f}km " - f"sol=({sol_lat:+.1f}°,{sol_lon:+.1f}°) day={daynum:.1f}" - ) - - # pole_proximity: 0 at the equator, 1 at the orbital extremes (±51.6°). - # Latitude oscillates through its full range every ~92-minute orbit — - # the fastest-changing ISS parameter and the primary musical driver here. - pole_proximity = abs(iss_lat.current - 0.5) * 2 - - # BPM: the music accelerates as the ISS arcs toward the poles and relaxes - # at each equator crossing. Ramps smoothly over 4 bars. - target_bpm = 90 + (40 * pole_proximity) # 90 at equator, 130 at poles - if p.cycle == 0: - composition.set_bpm(target_bpm) # Instant on startup - else: - composition.target_bpm(target_bpm, bars=4, shape="ease_in_out") - - # Chord-graph gravity: near the equator, transitions make bolder leaps - # (low gravity). Near the poles they prefer strong resolution (high gravity). - gravity = 0.3 + (0.5 * pole_proximity) # 0.3 equator : 0.8 poles - - # The harmony engine picks a new chord every 32 beats (8 bars), completely - # independent of the fetch cycle. ISS data only steers the *style* and - # *character*. - if vis == "daylight": - composition.harmony(style=CHORD_GRAPH_DAYLIGHT, cycle_beats=32, gravity=gravity) - else: - composition.harmony(style=CHORD_GRAPH_ECLIPSED, cycle_beats=32, gravity=gravity) - - except Exception as exc: - logging.warning(f"ISS fetch failed (keeping last values): {exc}") +def fetch_iss(p) -> None: + """Fetch ISS telemetry and update BPM, harmony style, and shared data.""" + + try: + body = requests.get("https://api.wheretheiss.at/v1/satellites/25544").json() + sc = subsequence.sequence_utils.scale_clamp # normalise any value to 0–1 + + lat = float(body["latitude"]) + lon = float(body["longitude"]) + alt = float(body["altitude"]) + vel = float(body["velocity"]) + vis = body["visibility"] + foot = float(body["footprint"]) + sol_lat = float(body["solar_lat"]) + sol_lon = float(body["solar_lon"]) + daynum = float(body["daynum"]) # Julian Day Number — logged for context + + # Normalise to 0–1 using each parameter's known physical range. + iss_lat.update(sc(lat, -51.6, 51.6)) # Orbital inclination bounds + iss_lon.update(sc(lon, -180, 180)) + iss_alt.update(sc(alt, 370, 460)) + iss_vel.update(sc(vel, 27500, 27750)) # Very stable; available for use + iss_footprint.update(sc(foot, 4400, 4600)) + iss_sol_lat.update(sc(sol_lat, -23.44, 23.44)) # Earth's axial tilt bounds + iss_sol_lon.update(sc(sol_lon, -180, 180)) + + composition.data["iss_visibility"] = 1.0 if vis == "daylight" else 0.0 + + # Solar longitude selects which 16th-note step gets an open hi-hat accent. + # It shifts ~2 steps per hour as the subsolar point circles the Earth. + composition.data["iss_hat_accent"] = int(iss_sol_lon.current * 15) + + logging.info( + f"ISS lat={lat:+.1f}° lon={lon:+.1f}° alt={alt:.0f}km " + f"vel={vel:.0f}km/h vis={vis} foot={foot:.0f}km " + f"sol=({sol_lat:+.1f}°,{sol_lon:+.1f}°) day={daynum:.1f}" + ) + + # pole_proximity: 0 at the equator, 1 at the orbital extremes (±51.6°). + # Latitude oscillates through its full range every ~92-minute orbit — + # the fastest-changing ISS parameter and the primary musical driver here. + pole_proximity = abs(iss_lat.current - 0.5) * 2 + + # BPM: the music accelerates as the ISS arcs toward the poles and relaxes + # at each equator crossing. Ramps smoothly over 4 bars. + target_bpm = 90 + (40 * pole_proximity) # 90 at equator, 130 at poles + if p.cycle == 0: + composition.set_bpm(target_bpm) # Instant on startup + else: + composition.target_bpm(target_bpm, bars=4, shape="ease_in_out") + + # Chord-graph gravity: near the equator, transitions make bolder leaps + # (low gravity). Near the poles they prefer strong resolution (high gravity). + gravity = 0.3 + (0.5 * pole_proximity) # 0.3 equator : 0.8 poles + + # The harmony engine picks a new chord every 32 beats (8 bars), completely + # independent of the fetch cycle. ISS data only steers the *style* and + # *character*. + if vis == "daylight": + composition.harmony( + style=CHORD_GRAPH_DAYLIGHT, cycle_beats=32, gravity=gravity + ) + else: + composition.harmony( + style=CHORD_GRAPH_ECLIPSED, cycle_beats=32, gravity=gravity + ) + + except Exception as exc: + logging.warning(f"ISS fetch failed (keeping last values): {exc}") composition.schedule(fetch_iss, cycle_beats=FETCH_BARS * 4, wait_for_initial=True) @@ -179,113 +179,110 @@ def fetch_iss (p) -> None: # Core drums @composition.pattern(channel=DRUMS_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): +def drums(p): + progress = (p.cycle % FETCH_BARS) / FETCH_BARS + pole_proximity = abs(iss_lat.get(progress) - 0.5) * 2 + equator_prox = 1.0 - pole_proximity - progress = (p.cycle % FETCH_BARS) / FETCH_BARS - pole_proximity = abs(iss_lat.get(progress) - 0.5) * 2 - equator_prox = 1.0 - pole_proximity + # Kick: four-on-the-floor, but near the equator each hit has a 40% chance + # of dropping. This thins the pulse at the orbit's midpoint and fills it + # back in as the ISS climbs toward a pole. + p.hit_steps( + "kick_1", [0, 4, 8, 12], velocity=100, probability=1.0 - 0.4 * equator_prox + ) - # Kick: four-on-the-floor, but near the equator each hit has a 40% chance - # of dropping. This thins the pulse at the orbit's midpoint and fills it - # back in as the ISS climbs toward a pole. - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100, - probability=1.0 - 0.4 * equator_prox) + # Snare: a fresh coin flip every bar, weighted by pole proximity. + # The backbeat fades in near the poles and disappears near the equator. + if p.rng.random() < pole_proximity: + p.hit_steps("snare_1", [4, 12], velocity=100) - # Snare: a fresh coin flip every bar, weighted by pole proximity. - # The backbeat fades in near the poles and disappears near the equator. - if p.rng.random() < pole_proximity: - p.hit_steps("snare_1", [4, 12], velocity=100) + # Hi-hat: velocity follows latitude — louder in the northern hemisphere, + # quieter in the south. EasedValue.get() interpolates within each fetch window. + p.hit_steps("hi_hat_closed", range(16), velocity=int(100 * iss_lat.get(progress))) - # Hi-hat: velocity follows latitude — louder in the northern hemisphere, - # quieter in the south. EasedValue.get() interpolates within each fetch window. - p.hit_steps("hi_hat_closed", range(16), velocity=int(100 * iss_lat.get(progress))) - - # Open hi-hat: one accent per bar, placed by the sun's longitude. - # A very slow signal — but it IS shifting, and it IS orbital. - p.hit_steps("hi_hat_open", [p.data.get("iss_hat_accent", 6)], velocity=60) + # Open hi-hat: one accent per bar, placed by the sun's longitude. + # A very slow signal — but it IS shifting, and it IS orbital. + p.hit_steps("hi_hat_open", [p.data.get("iss_hat_accent", 6)], velocity=60) # Ride cymbal (daylight only) @composition.pattern(channel=DRUMS_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def ride (p): - - # The ride only plays in sunlight — it brightens the texture and disappears - # completely during eclipse, leaving the shaker to fill the space instead. - if p.data.get("iss_visibility") != 1.0: - return +def ride(p): + # The ride only plays in sunlight — it brightens the texture and disappears + # completely during eclipse, leaving the shaker to fill the space instead. + if p.data.get("iss_visibility") != 1.0: + return - progress = (p.cycle % FETCH_BARS) / FETCH_BARS + progress = (p.cycle % FETCH_BARS) / FETCH_BARS - # Footprint is the diameter of the ISS's ground visibility cone. - # A wider footprint (higher orbit) feels more expansive : more ride hits. - pulses = 3 + int(4 * iss_footprint.get(progress)) # 3–7 pulses - p.euclidean("ride_1", pulses) - p.velocity_shape(low=50, high=90) # Organic dynamics via low-discrepancy sequence + # Footprint is the diameter of the ISS's ground visibility cone. + # A wider footprint (higher orbit) feels more expansive : more ride hits. + pulses = 3 + int(4 * iss_footprint.get(progress)) # 3–7 pulses + p.euclidean("ride_1", pulses) + p.velocity_shape(low=50, high=90) # Organic dynamics via low-discrepancy sequence # Shaker (eclipse only) @composition.pattern(channel=DRUMS_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def shaker (p): - - # Shaker fills the darker eclipse texture — steady 16ths with subtle variation. - if p.data.get("iss_visibility") == 1.0: - return +def shaker(p): + # Shaker fills the darker eclipse texture — steady 16ths with subtle variation. + if p.data.get("iss_visibility") == 1.0: + return - p.hit_steps("shaker", range(16), velocity=45) - p.randomize(timing=0.02, velocity=0.1) # Slight timing and velocity imprecision + p.hit_steps("shaker", range(16), velocity=45) + p.randomize(timing=0.02, velocity=0.1) # Slight timing and velocity imprecision # Arpeggio @composition.pattern(channel=ARP_CHANNEL, beats=4) -def arp (p, chord): +def arp(p, chord): + # `chord` is injected by the harmony engine and changes every 8 bars automatically. + progress = (p.cycle % FETCH_BARS) / FETCH_BARS + arp_velocity = int(40 + 60 * iss_lon.get(progress)) # 40–100, louder heading east - # `chord` is injected by the harmony engine and changes every 8 bars automatically. - progress = (p.cycle % FETCH_BARS) / FETCH_BARS - arp_velocity = int(40 + 60 * iss_lon.get(progress)) # 40–100, louder heading east + # Direction mirrors the ISS's north/south heading — ascending when going north, + # descending when going south. iss_lat.delta is positive while climbing, negative + # while descending. The flip happens naturally at each pole (~every 46 minutes). + direction = "up" if iss_lat.delta >= 0 else "down" - # Direction mirrors the ISS's north/south heading — ascending when going north, - # descending when going south. iss_lat.delta is positive while climbing, negative - # while descending. The flip happens naturally at each pole (~every 46 minutes). - direction = "up" if iss_lat.delta >= 0 else "down" - - pitches = chord.tones(root=60, count=4) # Four chord tones from C4 upward - p.arpeggio(pitches, spacing=0.25, velocity=arp_velocity, duration=0.05, direction=direction) + pitches = chord.tones(root=60, count=4) # Four chord tones from C4 upward + p.arpeggio( + pitches, spacing=0.25, velocity=arp_velocity, duration=0.05, direction=direction + ) # Bass @composition.pattern(channel=BASS_CHANNEL, beats=4) -def bass (p, chord): - - # `chord` is injected by the harmony engine — new chord each 8 bars, same rhythm. - # bass_note() finds the chord's root nearest to E3 (MIDI 52), then drops one octave. - bass_root = chord.bass_note(52, octave_offset=-1) +def bass(p, chord): + # `chord` is injected by the harmony engine — new chord each 8 bars, same rhythm. + # bass_note() finds the chord's root nearest to E3 (MIDI 52), then drops one octave. + bass_root = chord.bass_note(52, octave_offset=-1) - # Eighth notes across the bar: a steady, unwavering orbital pulse. - p.sequence(steps=range(0, 16, 2), pitches=bass_root) - p.legato(0.9) + # Eighth notes across the bar: a steady, unwavering orbital pulse. + p.sequence(steps=range(0, 16, 2), pitches=bass_root) + p.legato(0.9) # Chord pad @composition.pattern(channel=CHORD_CHANNEL, beats=4) -def chords (p, chord): - - # `chord` is injected by the harmony engine and advances every 8 bars. - progress = (p.cycle % FETCH_BARS) / FETCH_BARS +def chords(p, chord): + # `chord` is injected by the harmony engine and advances every 8 bars. + progress = (p.cycle % FETCH_BARS) / FETCH_BARS - # Solar proximity: when the ISS's latitude aligns with the subsolar latitude, - # the ISS is near solar noon — chords swell louder and brighter. - solar_prox = 1.0 - abs(iss_lat.get(progress) - iss_sol_lat.get(progress)) - velocity = 65 + int(30 * solar_prox) # 65–95 + # Solar proximity: when the ISS's latitude aligns with the subsolar latitude, + # the ISS is near solar noon — chords swell louder and brighter. + solar_prox = 1.0 - abs(iss_lat.get(progress) - iss_sol_lat.get(progress)) + velocity = 65 + int(30 * solar_prox) # 65–95 - # Altitude controls voicing density. A higher orbit adds a fourth chord tone; - # at lower altitude the voicing is a simple triad. Very slow, but audible - # over a longer session as altitude drifts by tens of kilometres. - count = 4 if iss_alt.current > 0.5 else 3 + # Altitude controls voicing density. A higher orbit adds a fourth chord tone; + # at lower altitude the voicing is a simple triad. Very slow, but audible + # over a longer session as altitude drifts by tens of kilometres. + count = 4 if iss_alt.current > 0.5 else 3 - p.chord(chord, root=52, velocity=velocity, count=count, legato=0.975) + p.chord(chord, root=52, velocity=velocity, count=count, legato=0.975) if __name__ == "__main__": - composition.display(grid=True, grid_scale=2) - composition.web_ui() - composition.play() + composition.display(grid=True, grid_scale=2) + composition.web_ui() + composition.play() diff --git a/examples/labyrinth.py b/examples/labyrinth.py index ca9a999..5737394 100644 --- a/examples/labyrinth.py +++ b/examples/labyrinth.py @@ -128,8 +128,12 @@ # increasing probability. Mirrors Labyrinth CORRUPT above # 12 o'clock (BIT FLIP territory). # Labyrinth range: 0.0–1.0 (fully CCW to fully CW). -composition.conductor.lfo("SEQ1_CORRUPT", shape="triangle", cycle_beats=48, min_val=0.0, max_val=0.55) -composition.conductor.lfo("SEQ2_CORRUPT", shape="sine", cycle_beats=32, min_val=0.0, max_val=0.45) +composition.conductor.lfo( + "SEQ1_CORRUPT", shape="triangle", cycle_beats=48, min_val=0.0, max_val=0.55 +) +composition.conductor.lfo( + "SEQ2_CORRUPT", shape="sine", cycle_beats=32, min_val=0.0, max_val=0.45 +) # EG_TRIG_MIX: Velocity crossfader between SEQ1 and SEQ2 (0.0–1.0). # Maps directly to the Labyrinth's EG TRIG MIX panel knob: @@ -137,7 +141,9 @@ # 0.5 = both sequences at equal velocity. # 1.0 = SEQ2 at full velocity; SEQ1 output is suppressed. # Labyrinth range: 0.0–1.0 (fully CCW to fully CW). -composition.conductor.lfo("EG_TRIG_MIX", shape="triangle", cycle_beats=64, min_val=0.2, max_val=0.8) +composition.conductor.lfo( + "EG_TRIG_MIX", shape="triangle", cycle_beats=64, min_val=0.2, max_val=0.8 +) # ─── PHYSICAL CONTROLLER MAPPING (OPTIONAL) ─────────────────────────────────── @@ -177,159 +183,158 @@ # then clip the pool so that lower values compress pitch content toward the root, # faithfully replicating the Labyrinth's CV RANGE attenuator before its quantizer. -def _build_pitch_pool (root, mode, cv_range, base_octave=3): - """Build the list of MIDI note numbers available to a sequencer. - This replicates the Labyrinth's internal quantizer: random CV voltages are - snapped to the nearest note in the chosen scale. The CV RANGE attenuator - then compresses the available range toward the root - lower values mean - fewer pitches to choose from, so melodies stay closer to home. - """ +def _build_pitch_pool(root, mode, cv_range, base_octave=3): + """Build the list of MIDI note numbers available to a sequencer. - # Convert the root note name ("C", "F#", etc.) to a pitch class number - # (0 = C, 1 = C#, 2 = D, ... 11 = B). - root_pc = subsequence.chords.key_name_to_pc(root) + This replicates the Labyrinth's internal quantizer: random CV voltages are + snapped to the nearest note in the chosen scale. The CV RANGE attenuator + then compresses the available range toward the root - lower values mean + fewer pitches to choose from, so melodies stay closer to home. + """ - # Get the pitch classes that belong to this scale. For example, - # C minor pentatonic → [0, 3, 5, 7, 10] (the notes C, Eb, F, G, Bb). - # If no scale is selected, use all 12 chromatic semitones. - if mode is None: - scale_notes = list(range(12)) - else: - scale_notes = subsequence.intervals.scale_pitch_classes(root_pc, mode) + # Convert the root note name ("C", "F#", etc.) to a pitch class number + # (0 = C, 1 = C#, 2 = D, ... 11 = B). + root_pc = subsequence.chords.key_name_to_pc(root) - # Convert pitch classes to actual MIDI note numbers across two octaves. - # MIDI note formula: note = 12 × (octave + 1) + pitch_class. - # For example, C4 = 12 × 5 + 0 = 60. - base_midi = 12 * (base_octave + 1) + root_pc - pitches = [] - for octave_offset in range(2): - for pc in scale_notes: - midi = base_midi + octave_offset * 12 + (pc - root_pc) % 12 - if 0 <= midi <= 127: - pitches.append(midi) + # Get the pitch classes that belong to this scale. For example, + # C minor pentatonic → [0, 3, 5, 7, 10] (the notes C, Eb, F, G, Bb). + # If no scale is selected, use all 12 chromatic semitones. + if mode is None: + scale_notes = list(range(12)) + else: + scale_notes = subsequence.intervals.scale_pitch_classes(root_pc, mode) - # Remove any duplicates and sort low to high. - pitches = sorted(set(pitches)) + # Convert pitch classes to actual MIDI note numbers across two octaves. + # MIDI note formula: note = 12 × (octave + 1) + pitch_class. + # For example, C4 = 12 × 5 + 0 = 60. + base_midi = 12 * (base_octave + 1) + root_pc + pitches = [] + for octave_offset in range(2): + for pc in scale_notes: + midi = base_midi + octave_offset * 12 + (pc - root_pc) % 12 + if 0 <= midi <= 127: + pitches.append(midi) - # CV_RANGE clips the pool: 1.0 = full range, 0.0 = root pitch only. - n = max(1, round(len(pitches) * cv_range)) - return pitches[:n] + # Remove any duplicates and sort low to high. + pitches = sorted(set(pitches)) + # CV_RANGE clips the pool: 1.0 = full range, 0.0 = root pitch only. + n = max(1, round(len(pitches) * cv_range)) + return pitches[:n] -SEQ1_PITCHES = _build_pitch_pool(SCALE_ROOT, SCALE_MODE, SEQ1_CV_RANGE, base_octave=SEQ1_OCTAVE) -SEQ2_PITCHES = _build_pitch_pool(SCALE_ROOT, SCALE_MODE, SEQ2_CV_RANGE, base_octave=SEQ2_OCTAVE) + +SEQ1_PITCHES = _build_pitch_pool( + SCALE_ROOT, SCALE_MODE, SEQ1_CV_RANGE, base_octave=SEQ1_OCTAVE +) +SEQ2_PITCHES = _build_pitch_pool( + SCALE_ROOT, SCALE_MODE, SEQ2_CV_RANGE, base_octave=SEQ2_OCTAVE +) # ─── SEQUENCER PATTERNS ─────────────────────────────────────────────────────── if CHAIN_SEQ: + # CHAIN SEQ mode: SEQ1 and SEQ2 run as one longer loop. + # Merge both pitch pools into one combined set (removing duplicates). + CHAINED_PITCHES = sorted(set(SEQ1_PITCHES + SEQ2_PITCHES)) - # CHAIN SEQ mode: SEQ1 and SEQ2 run as one longer loop. - # Merge both pitch pools into one combined set (removing duplicates). - CHAINED_PITCHES = sorted(set(SEQ1_PITCHES + SEQ2_PITCHES)) - - @composition.pattern(channel=CHANNEL, steps=SEQ1_LENGTH + SEQ2_LENGTH, step_duration=SEQ1_UNIT) - def chained (p): - - corrupt = p.signal("SEQ1_CORRUPT") - trig_mix = p.signal("EG_TRIG_MIX") + @composition.pattern( + channel=CHANNEL, steps=SEQ1_LENGTH + SEQ2_LENGTH, step_duration=SEQ1_UNIT + ) + def chained(p): + corrupt = p.signal("SEQ1_CORRUPT") + trig_mix = p.signal("EG_TRIG_MIX") - # In chained mode, velocity is loudest at the extremes of the crossfader - # and quietest at centre (where the two sequences would cancel out). - vel_ceiling = round(110 * (0.5 + abs(trig_mix - 0.5))) + # In chained mode, velocity is loudest at the extremes of the crossfader + # and quietest at centre (where the two sequences would cancel out). + vel_ceiling = round(110 * (0.5 + abs(trig_mix - 0.5))) - # Rhythm mutation: CORRUPT above 0.5 starts suppressing steps. - rhythm_dropout = max(0.0, (corrupt - 0.5) * 2.0) + # Rhythm mutation: CORRUPT above 0.5 starts suppressing steps. + rhythm_dropout = max(0.0, (corrupt - 0.5) * 2.0) - for step in range(SEQ1_LENGTH + SEQ2_LENGTH): + for step in range(SEQ1_LENGTH + SEQ2_LENGTH): + if p.rng.random() < rhythm_dropout: + continue - if p.rng.random() < rhythm_dropout: - continue + pitch = p.rng.choice(CHAINED_PITCHES) - pitch = p.rng.choice(CHAINED_PITCHES) + # Pitch mutation: CORRUPT below 0.5 drifts pitch within the pool. + if corrupt > 0.0 and p.rng.random() < corrupt * 0.8: + pitch = p.rng.choice(CHAINED_PITCHES) - # Pitch mutation: CORRUPT below 0.5 drifts pitch within the pool. - if corrupt > 0.0 and p.rng.random() < corrupt * 0.8: - pitch = p.rng.choice(CHAINED_PITCHES) - - vel = round(p.rng.uniform(0.65, 1.0) * vel_ceiling) - p.hit_steps(pitch, [step], velocity=max(1, vel), duration=SEQ1_UNIT * 0.85) + vel = round(p.rng.uniform(0.65, 1.0) * vel_ceiling) + p.hit_steps(pitch, [step], velocity=max(1, vel), duration=SEQ1_UNIT * 0.85) else: + # Independent polymetric mode: SEQ1 and SEQ2 run on separate cycle lengths. + # With SEQ1_LENGTH=7 and SEQ2_LENGTH=5 using EIGHTH notes, the full pattern + # repeats every LCM(7,5)=35 eighth notes (8.75 bars at 4/4). - # Independent polymetric mode: SEQ1 and SEQ2 run on separate cycle lengths. - # With SEQ1_LENGTH=7 and SEQ2_LENGTH=5 using EIGHTH notes, the full pattern - # repeats every LCM(7,5)=35 eighth notes (8.75 bars at 4/4). - - @composition.pattern(channel=CHANNEL, steps=SEQ1_LENGTH, step_duration=SEQ1_UNIT) - def seq1 (p): + @composition.pattern(channel=CHANNEL, steps=SEQ1_LENGTH, step_duration=SEQ1_UNIT) + def seq1(p): + # Read the current value of each Conductor LFO signal. + # These change slowly over time (see the LFO definitions above). + corrupt = p.signal("SEQ1_CORRUPT") # 0.0–0.55, sweeping + trig_mix = p.signal("EG_TRIG_MIX") # 0.2–0.8, sweeping - # Read the current value of each Conductor LFO signal. - # These change slowly over time (see the LFO definitions above). - corrupt = p.signal("SEQ1_CORRUPT") # 0.0–0.55, sweeping - trig_mix = p.signal("EG_TRIG_MIX") # 0.2–0.8, sweeping + # EG_TRIG_MIX: fully left (0.0) = SEQ1 loud; fully right (1.0) = SEQ1 quiet. + vel_ceiling = round(110 * (1.0 - trig_mix)) - # EG_TRIG_MIX: fully left (0.0) = SEQ1 loud; fully right (1.0) = SEQ1 quiet. - vel_ceiling = round(110 * (1.0 - trig_mix)) + # Rhythm mutation kicks in above CORRUPT 0.5 (BIT FLIP territory). + # Scale 0.5–1.0 into 0.0–1.0 so it ramps from "no dropout" to "all dropout". + rhythm_dropout = max(0.0, (corrupt - 0.5) * 2.0) - # Rhythm mutation kicks in above CORRUPT 0.5 (BIT FLIP territory). - # Scale 0.5–1.0 into 0.0–1.0 so it ramps from "no dropout" to "all dropout". - rhythm_dropout = max(0.0, (corrupt - 0.5) * 2.0) + for step in range(SEQ1_LENGTH): + # p.rng is this pattern's random number generator (seeded for + # reproducibility). p.rng.random() returns a float between 0.0 and 1.0. + # If that value falls below the dropout probability, this step is skipped. + if p.rng.random() < rhythm_dropout: + continue # This step's bit is suppressed this cycle. - for step in range(SEQ1_LENGTH): + # Pick a random pitch from the quantized pool for this step. + pitch = p.rng.choice(SEQ1_PITCHES) - # p.rng is this pattern's random number generator (seeded for - # reproducibility). p.rng.random() returns a float between 0.0 and 1.0. - # If that value falls below the dropout probability, this step is skipped. - if p.rng.random() < rhythm_dropout: - continue # This step's bit is suppressed this cycle. + # Pitch mutation: the higher the CORRUPT value, the more likely + # the original pitch gets swapped for a different one from the pool. + if corrupt > 0.0 and p.rng.random() < corrupt * 0.8: + pitch = p.rng.choice(SEQ1_PITCHES) - # Pick a random pitch from the quantized pool for this step. - pitch = p.rng.choice(SEQ1_PITCHES) + # Random velocity between 65% and 100% of the ceiling, for humanisation. + vel = round(p.rng.uniform(0.65, 1.0) * vel_ceiling) + p.hit_steps(pitch, [step], velocity=max(1, vel), duration=SEQ1_UNIT * 0.85) - # Pitch mutation: the higher the CORRUPT value, the more likely - # the original pitch gets swapped for a different one from the pool. - if corrupt > 0.0 and p.rng.random() < corrupt * 0.8: - pitch = p.rng.choice(SEQ1_PITCHES) + @composition.pattern(channel=CHANNEL, steps=SEQ2_LENGTH, step_duration=SEQ2_UNIT) + def seq2(p): + """SEQ2: mirrors SEQ1 logic (see comments there) but reads SEQ2_CORRUPT + and uses the opposite trig_mix polarity - SEQ2 is loudest when the + crossfader is fully right (1.0).""" - # Random velocity between 65% and 100% of the ceiling, for humanisation. - vel = round(p.rng.uniform(0.65, 1.0) * vel_ceiling) - p.hit_steps(pitch, [step], velocity=max(1, vel), duration=SEQ1_UNIT * 0.85) + corrupt = p.signal("SEQ2_CORRUPT") + trig_mix = p.signal("EG_TRIG_MIX") - @composition.pattern(channel=CHANNEL, steps=SEQ2_LENGTH, step_duration=SEQ2_UNIT) - def seq2 (p): - """SEQ2: mirrors SEQ1 logic (see comments there) but reads SEQ2_CORRUPT - and uses the opposite trig_mix polarity - SEQ2 is loudest when the - crossfader is fully right (1.0).""" + # EG_TRIG_MIX: SEQ2 is loud when the crossfader is fully right (1.0). + # This is the inverse of SEQ1's formula (1.0 - trig_mix). + vel_ceiling = round(110 * trig_mix) - corrupt = p.signal("SEQ2_CORRUPT") - trig_mix = p.signal("EG_TRIG_MIX") + # Rhythm dropout: same logic as SEQ1 - CORRUPT above 0.5 starts + # suppressing steps. + rhythm_dropout = max(0.0, (corrupt - 0.5) * 2.0) - # EG_TRIG_MIX: SEQ2 is loud when the crossfader is fully right (1.0). - # This is the inverse of SEQ1's formula (1.0 - trig_mix). - vel_ceiling = round(110 * trig_mix) + for step in range(SEQ2_LENGTH): + if p.rng.random() < rhythm_dropout: + continue - # Rhythm dropout: same logic as SEQ1 - CORRUPT above 0.5 starts - # suppressing steps. - rhythm_dropout = max(0.0, (corrupt - 0.5) * 2.0) + pitch = p.rng.choice(SEQ2_PITCHES) - for step in range(SEQ2_LENGTH): + if corrupt > 0.0 and p.rng.random() < corrupt * 0.8: + pitch = p.rng.choice(SEQ2_PITCHES) - if p.rng.random() < rhythm_dropout: - continue - - pitch = p.rng.choice(SEQ2_PITCHES) - - if corrupt > 0.0 and p.rng.random() < corrupt * 0.8: - pitch = p.rng.choice(SEQ2_PITCHES) - - vel = round(p.rng.uniform(0.65, 1.0) * vel_ceiling) - p.hit_steps(pitch, [step], velocity=max(1, vel), duration=SEQ2_UNIT * 0.85) + vel = round(p.rng.uniform(0.65, 1.0) * vel_ceiling) + p.hit_steps(pitch, [step], velocity=max(1, vel), duration=SEQ2_UNIT * 0.85) if __name__ == "__main__": - - composition.display(grid=True, grid_scale=4) - composition.web_ui() - composition.play() + composition.display(grid=True, grid_scale=4) + composition.web_ui() + composition.play() diff --git a/examples/link_sync.py b/examples/link_sync.py index a2223f4..804aa63 100644 --- a/examples/link_sync.py +++ b/examples/link_sync.py @@ -33,20 +33,20 @@ @comp.pattern(channel=10, beats=4) -def kick (p): - p.hit(35, beats=[0, 2], velocity=110, duration=0.1) +def kick(p): + p.hit(35, beats=[0, 2], velocity=110, duration=0.1) @comp.pattern(channel=10, beats=4) -def hat (p): - density = p.data.get("density", 0.7) - p.hit(42, beats=[1, 3], velocity=int(80 * density), duration=0.05) +def hat(p): + density = p.data.get("density", 0.7) + p.hit(42, beats=[1, 3], velocity=int(80 * density), duration=0.05) @comp.pattern(channel=10, beats=8) -def snare (p): - p.hit(38, beats=[2, 6], velocity=100, duration=0.15) +def snare(p): + p.hit(38, beats=[2, 6], velocity=100, duration=0.15) if __name__ == "__main__": - comp.play() + comp.play() diff --git a/examples/live_init.py b/examples/live_init.py index 9ed256e..f809adf 100644 --- a/examples/live_init.py +++ b/examples/live_init.py @@ -43,4 +43,4 @@ if __name__ == "__main__": - composition.play() + composition.play() diff --git a/examples/live_patterns.py b/examples/live_patterns.py index fc2d348..13bc0c8 100644 --- a/examples/live_patterns.py +++ b/examples/live_patterns.py @@ -22,8 +22,7 @@ @composition.pattern(channel=DRUMS_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): - - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) - p.hit_steps("snare_1", [4, 12], velocity=90) - p.hit_steps("hi_hat_closed", range(16), velocity=70) +def drums(p): + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) + p.hit_steps("snare_1", [4, 12], velocity=90) + p.hit_steps("hi_hat_closed", range(16), velocity=70) diff --git a/examples/live_single_file.py b/examples/live_single_file.py index 5e1bb19..1c4c575 100644 --- a/examples/live_single_file.py +++ b/examples/live_single_file.py @@ -42,28 +42,26 @@ # ── One-time setup ────────────────────────────────────────────────────────── if __name__ == "__main__": + composition = subsequence.Composition(bpm=120, key="E") + composition.harmony(style="aeolian_minor", cycle_beats=4, gravity=0.8) - composition = subsequence.Composition(bpm=120, key="E") - composition.harmony(style="aeolian_minor", cycle_beats=4, gravity=0.8) - - # Self-watch — ``__file__`` is this file's path when run as a script. - composition.watch(__file__) + # Self-watch — ``__file__`` is this file's path when run as a script. + composition.watch(__file__) # ── Re-runs on every save ─────────────────────────────────────────────────── DRUMS_CHANNEL = 10 -@composition.pattern(channel=DRUMS_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) -def drums (p): - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) - p.hit_steps("snare_1", [4, 12], velocity=90) - p.hit_steps("hi_hat_closed", range(16), velocity=70) +@composition.pattern(channel=DRUMS_CHANNEL, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) +def drums(p): + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) + p.hit_steps("snare_1", [4, 12], velocity=90) + p.hit_steps("hi_hat_closed", range(16), velocity=70) # ── Start playing ────────────────────────────────────────────────────────── if __name__ == "__main__": - - composition.play() + composition.play() diff --git a/examples/load_patterns.py b/examples/load_patterns.py index a58639d..7f651e8 100644 --- a/examples/load_patterns.py +++ b/examples/load_patterns.py @@ -51,4 +51,4 @@ def drums (p): if __name__ == "__main__": - composition.play() + composition.play() diff --git a/examples/subharmonicon.py b/examples/subharmonicon.py index 98f9e09..99a3c05 100644 --- a/examples/subharmonicon.py +++ b/examples/subharmonicon.py @@ -53,13 +53,13 @@ # Default: two-channel setup (channels 1 and 2). # For six mono instruments, assign each a unique channel (e.g. 1 through 6). -VCO1_CHANNEL = 1 # VCO1 main oscillator -VCO1_SUB1_CHANNEL = 1 # VCO1 first subharmonic oscillator -VCO1_SUB2_CHANNEL = 1 # VCO1 second subharmonic oscillator +VCO1_CHANNEL = 1 # VCO1 main oscillator +VCO1_SUB1_CHANNEL = 1 # VCO1 first subharmonic oscillator +VCO1_SUB2_CHANNEL = 1 # VCO1 second subharmonic oscillator -VCO2_CHANNEL = 2 # VCO2 main oscillator -VCO2_SUB1_CHANNEL = 2 # VCO2 first subharmonic oscillator -VCO2_SUB2_CHANNEL = 2 # VCO2 second subharmonic oscillator +VCO2_CHANNEL = 2 # VCO2 main oscillator +VCO2_SUB1_CHANNEL = 2 # VCO2 first subharmonic oscillator +VCO2_SUB2_CHANNEL = 2 # VCO2 second subharmonic oscillator # ─── STATIC SUBHARMONICON CONTROLS ─────────────────────────────────────────── @@ -91,11 +91,11 @@ # Divisor 6 = two octaves + minor 7th below (−31) # Divisor 8 = three octaves down (−36) # Divisor 16 = four octaves down (−48) -VCO1_SUB1_FREQ = 2 # Octave below VCO1 -VCO1_SUB2_FREQ = 3 # A 12th below VCO1 (octave + fifth) +VCO1_SUB1_FREQ = 2 # Octave below VCO1 +VCO1_SUB2_FREQ = 3 # A 12th below VCO1 (octave + fifth) -VCO2_SUB1_FREQ = 2 # Octave below VCO2 -VCO2_SUB2_FREQ = 4 # Two octaves below VCO2 +VCO2_SUB1_FREQ = 2 # Octave below VCO2 +VCO2_SUB2_FREQ = 4 # Two octaves below VCO2 # SEQ1_STEPS / SEQ2_STEPS: The four absolute pitches of each sequencer as MIDI # note numbers. On the hardware, the four STEP knobs set relative offsets from @@ -103,17 +103,17 @@ # Stay within ±SEQ_OCT octaves of VCO_FREQ for authentic behaviour. # List of exactly 4 MIDI note numbers. SEQ1_STEPS = [ - midi_notes.C5, - midi_notes.E5, - midi_notes.G5, - midi_notes.C6, + midi_notes.C5, + midi_notes.E5, + midi_notes.G5, + midi_notes.C6, ] SEQ2_STEPS = [ - midi_notes.G4, - midi_notes.C5, - midi_notes.D5, - midi_notes.G5, + midi_notes.G4, + midi_notes.C5, + midi_notes.D5, + midi_notes.G5, ] # SEQ_OCT: Octave range available to the STEP knobs, shared by both sequencers. @@ -132,7 +132,7 @@ # None (free / continuous pitch) → None # Any valid subsequence scale mode string is accepted. SCALE_ROOT = "C" -SCALE_MODE = None # None for free pitch; e.g. "major", "minor_pentatonic" +SCALE_MODE = None # None for free pitch; e.g. "major", "minor_pentatonic" # SEQ1_ASSIGN_OSC1: When True, SEQ1 steps control VCO1's pitch. # When False, VCO1 plays at VCO1_FREQ every step (no pitch variation from SEQ1). @@ -203,8 +203,9 @@ # or the individual oscillator LEVEL knobs on the Subharmonicon during a # performance. Adjust cycle_beats for faster or slower swells. # Range: 0–127 (MIDI velocity). -composition.conductor.lfo("VELOCITY_SWELL", shape="sine", cycle_beats=16, - min_val=55, max_val=105) +composition.conductor.lfo( + "VELOCITY_SWELL", shape="sine", cycle_beats=16, min_val=55, max_val=105 +) # ─── PHYSICAL CONTROLLER MAPPING (OPTIONAL) ─────────────────────────────────── @@ -233,63 +234,63 @@ # ─── HELPERS ───────────────────────────────────────────────────────────────── -def _subharmonic_note (base_midi, divisor): - """Return the MIDI note of a subharmonic oscillator at base_midi ÷ divisor. - - The Subharmonicon's sub-oscillators generate undertones at integer fractions - of the parent VCO pitch. Divisor 1 = unison, 2 = one octave below, etc. - - MIDI is integer semitones, so irrational subharmonics (÷3, ÷5, ÷6, etc.) - are rounded to the nearest semitone - the same approximation the hardware - makes when MIDI CC controls the SUB FREQ integer value. - """ - if divisor <= 1: - return base_midi - # The formula: there are 12 semitones per octave, and dividing a frequency - # by N lowers the pitch by log₂(N) octaves. So the MIDI note offset is - # 12 × log₂(N) semitones below the fundamental. round() snaps to the - # nearest semitone (MIDI can only represent whole semitones). - return round(base_midi - 12 * math.log2(divisor)) - - -def _compute_triggers (divisors, logic="OR"): - """Return (trigger_steps, cycle_length) for a set of rhythm generator divisors. - - Each divisor N produces a clock that fires every N master ticks (quarter - notes). cycle_length is math.lcm(*divisors) - the full polyrhythmic period - in quarter notes. trigger_steps is the sorted list of tick positions within - that period at which the sequencer advances. - - logic="OR": advance on any tick from any generator (Subharmonicon default). - logic="XOR": advance only when exactly one generator fires; simultaneous - pulses from different generators cancel each other out. - """ - if not divisors: - return [], 1 - - # math.lcm() finds the Lowest Common Multiple of all divisors - this is - # the number of master clock ticks before the combined polyrhythm repeats. - # For example, divisors 3 and 4 → LCM = 12 (the pattern repeats every 12 - # quarter notes). - cycle = math.lcm(*divisors) - - # Build a set of tick positions for each rhythm generator. - # range(0, cycle, d) produces every d-th tick: divisor 3 over 12 ticks - # gives {0, 3, 6, 9}, divisor 4 gives {0, 4, 8}. - tick_sets = [set(range(0, cycle, d)) for d in divisors] - - if logic == "OR": - # OR: the sequencer advances on any tick from any generator. - # set().union() merges all tick sets into one; sorted() puts them in - # time order. Example: {0, 3, 6, 9} ∪ {0, 4, 8} = {0, 3, 4, 6, 8, 9}. - merged = sorted(set().union(*tick_sets)) - else: - # XOR: only ticks where exactly one generator fires count. - # If two generators fire at the same tick, they cancel out. - merged = sorted(t for t in range(cycle) - if sum(t in s for s in tick_sets) == 1) - - return merged, cycle + +def _subharmonic_note(base_midi, divisor): + """Return the MIDI note of a subharmonic oscillator at base_midi ÷ divisor. + + The Subharmonicon's sub-oscillators generate undertones at integer fractions + of the parent VCO pitch. Divisor 1 = unison, 2 = one octave below, etc. + + MIDI is integer semitones, so irrational subharmonics (÷3, ÷5, ÷6, etc.) + are rounded to the nearest semitone - the same approximation the hardware + makes when MIDI CC controls the SUB FREQ integer value. + """ + if divisor <= 1: + return base_midi + # The formula: there are 12 semitones per octave, and dividing a frequency + # by N lowers the pitch by log₂(N) octaves. So the MIDI note offset is + # 12 × log₂(N) semitones below the fundamental. round() snaps to the + # nearest semitone (MIDI can only represent whole semitones). + return round(base_midi - 12 * math.log2(divisor)) + + +def _compute_triggers(divisors, logic="OR"): + """Return (trigger_steps, cycle_length) for a set of rhythm generator divisors. + + Each divisor N produces a clock that fires every N master ticks (quarter + notes). cycle_length is math.lcm(*divisors) - the full polyrhythmic period + in quarter notes. trigger_steps is the sorted list of tick positions within + that period at which the sequencer advances. + + logic="OR": advance on any tick from any generator (Subharmonicon default). + logic="XOR": advance only when exactly one generator fires; simultaneous + pulses from different generators cancel each other out. + """ + if not divisors: + return [], 1 + + # math.lcm() finds the Lowest Common Multiple of all divisors - this is + # the number of master clock ticks before the combined polyrhythm repeats. + # For example, divisors 3 and 4 → LCM = 12 (the pattern repeats every 12 + # quarter notes). + cycle = math.lcm(*divisors) + + # Build a set of tick positions for each rhythm generator. + # range(0, cycle, d) produces every d-th tick: divisor 3 over 12 ticks + # gives {0, 3, 6, 9}, divisor 4 gives {0, 4, 8}. + tick_sets = [set(range(0, cycle, d)) for d in divisors] + + if logic == "OR": + # OR: the sequencer advances on any tick from any generator. + # set().union() merges all tick sets into one; sorted() puts them in + # time order. Example: {0, 3, 6, 9} ∪ {0, 4, 8} = {0, 3, 4, 6, 8, 9}. + merged = sorted(set().union(*tick_sets)) + else: + # XOR: only ticks where exactly one generator fires count. + # If two generators fire at the same tick, they cancel out. + merged = sorted(t for t in range(cycle) if sum(t in s for s in tick_sets) == 1) + + return merged, cycle # ─── POLYRHYTHM COMPUTATION ─────────────────────────────────────────────────── @@ -300,15 +301,27 @@ def _compute_triggers (divisors, logic="OR"): # RHYTHM*_DIV value with its RHYTHM*_SEQ1/SEQ2 switch, then keeps only the # divisors whose switch is True. For example, if RHYTHM1 (÷3) and RHYTHM2 # (÷4) are routed to SEQ1, seq1_divs will be [3, 4]. -seq1_divs = [d for d, on in [ - (RHYTHM1_DIV, RHYTHM1_SEQ1), (RHYTHM2_DIV, RHYTHM2_SEQ1), - (RHYTHM3_DIV, RHYTHM3_SEQ1), (RHYTHM4_DIV, RHYTHM4_SEQ1), -] if on] +seq1_divs = [ + d + for d, on in [ + (RHYTHM1_DIV, RHYTHM1_SEQ1), + (RHYTHM2_DIV, RHYTHM2_SEQ1), + (RHYTHM3_DIV, RHYTHM3_SEQ1), + (RHYTHM4_DIV, RHYTHM4_SEQ1), + ] + if on +] -seq2_divs = [d for d, on in [ - (RHYTHM1_DIV, RHYTHM1_SEQ2), (RHYTHM2_DIV, RHYTHM2_SEQ2), - (RHYTHM3_DIV, RHYTHM3_SEQ2), (RHYTHM4_DIV, RHYTHM4_SEQ2), -] if on] +seq2_divs = [ + d + for d, on in [ + (RHYTHM1_DIV, RHYTHM1_SEQ2), + (RHYTHM2_DIV, RHYTHM2_SEQ2), + (RHYTHM3_DIV, RHYTHM3_SEQ2), + (RHYTHM4_DIV, RHYTHM4_SEQ2), + ] + if on +] seq1_triggers, seq1_cycle = _compute_triggers(seq1_divs, RHYTHM_LOGIC) seq2_triggers, seq2_cycle = _compute_triggers(seq2_divs, RHYTHM_LOGIC) @@ -324,64 +337,68 @@ def _compute_triggers (divisors, logic="OR"): # pattern repeats on a cycle shorter than four steps. if seq1_triggers: - - if SEQ1_ASSIGN_OSC1: - - @composition.pattern(channel=VCO1_CHANNEL, steps=seq1_cycle, step_duration=dur.QUARTER) - def vco1 (p): - # p.signal() reads the current value of a Conductor LFO - a slowly - # changing parameter that evolves over time (see VELOCITY_SWELL above). - vel = round(p.signal("VELOCITY_SWELL")) - - # Walk through each trigger position in the polyrhythmic cycle. - # enumerate() gives us both the index (trigger_idx) and the beat - # position (tick) of each trigger. - for trigger_idx, tick in enumerate(seq1_triggers): - - # Step counter: the sequencer has 4 steps and cycles through them - # endlessly. p.cycle counts how many times this pattern has been - # rebuilt (i.e., which loop of the polyrhythmic cycle we're on). - # Multiplying by the number of triggers per cycle and adding the - # current trigger index gives a global trigger count, then % 4 - # wraps it back to a 4-step sequence. - step = (p.cycle * len(seq1_triggers) + trigger_idx) % 4 - pitch = SEQ1_STEPS[step] - p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) - - if SCALE_MODE: - p.snap_to_scale(SCALE_ROOT, SCALE_MODE) - - if SEQ1_ASSIGN_SUB1: - - @composition.pattern(channel=VCO1_SUB1_CHANNEL, steps=seq1_cycle, step_duration=dur.QUARTER) - def vco1_sub1 (p): - vel = round(p.signal("VELOCITY_SWELL")) - for trigger_idx, tick in enumerate(seq1_triggers): - # Step counter - same logic as vco1 above. - step = (p.cycle * len(seq1_triggers) + trigger_idx) % 4 - - # If SEQ1 is assigned to control VCO1's pitch, the sub-oscillator - # follows the stepped pitch. Otherwise it stays at VCO1_FREQ. - # (The "X if condition else Y" syntax is Python's inline if/else.) - vco_pitch = SEQ1_STEPS[step] if SEQ1_ASSIGN_OSC1 else VCO1_FREQ - pitch = _subharmonic_note(vco_pitch, VCO1_SUB1_FREQ) - p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) - if SCALE_MODE: - p.snap_to_scale(SCALE_ROOT, SCALE_MODE) - - if SEQ1_ASSIGN_SUB2: - - @composition.pattern(channel=VCO1_SUB2_CHANNEL, steps=seq1_cycle, step_duration=dur.QUARTER) - def vco1_sub2 (p): - # Same structure as vco1_sub1 - see comments there. - vel = round(p.signal("VELOCITY_SWELL")) - for trigger_idx, tick in enumerate(seq1_triggers): - step = (p.cycle * len(seq1_triggers) + trigger_idx) % 4 - vco_pitch = SEQ1_STEPS[step] if SEQ1_ASSIGN_OSC1 else VCO1_FREQ - pitch = _subharmonic_note(vco_pitch, VCO1_SUB2_FREQ) - p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) - if SCALE_MODE: - p.snap_to_scale(SCALE_ROOT, SCALE_MODE) + if SEQ1_ASSIGN_OSC1: + + @composition.pattern( + channel=VCO1_CHANNEL, steps=seq1_cycle, step_duration=dur.QUARTER + ) + def vco1(p): + # p.signal() reads the current value of a Conductor LFO - a slowly + # changing parameter that evolves over time (see VELOCITY_SWELL above). + vel = round(p.signal("VELOCITY_SWELL")) + + # Walk through each trigger position in the polyrhythmic cycle. + # enumerate() gives us both the index (trigger_idx) and the beat + # position (tick) of each trigger. + for trigger_idx, tick in enumerate(seq1_triggers): + # Step counter: the sequencer has 4 steps and cycles through them + # endlessly. p.cycle counts how many times this pattern has been + # rebuilt (i.e., which loop of the polyrhythmic cycle we're on). + # Multiplying by the number of triggers per cycle and adding the + # current trigger index gives a global trigger count, then % 4 + # wraps it back to a 4-step sequence. + step = (p.cycle * len(seq1_triggers) + trigger_idx) % 4 + pitch = SEQ1_STEPS[step] + p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) + + if SCALE_MODE: + p.snap_to_scale(SCALE_ROOT, SCALE_MODE) + + if SEQ1_ASSIGN_SUB1: + + @composition.pattern( + channel=VCO1_SUB1_CHANNEL, steps=seq1_cycle, step_duration=dur.QUARTER + ) + def vco1_sub1(p): + vel = round(p.signal("VELOCITY_SWELL")) + for trigger_idx, tick in enumerate(seq1_triggers): + # Step counter - same logic as vco1 above. + step = (p.cycle * len(seq1_triggers) + trigger_idx) % 4 + + # If SEQ1 is assigned to control VCO1's pitch, the sub-oscillator + # follows the stepped pitch. Otherwise it stays at VCO1_FREQ. + # (The "X if condition else Y" syntax is Python's inline if/else.) + vco_pitch = SEQ1_STEPS[step] if SEQ1_ASSIGN_OSC1 else VCO1_FREQ + pitch = _subharmonic_note(vco_pitch, VCO1_SUB1_FREQ) + p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) + if SCALE_MODE: + p.snap_to_scale(SCALE_ROOT, SCALE_MODE) + + if SEQ1_ASSIGN_SUB2: + + @composition.pattern( + channel=VCO1_SUB2_CHANNEL, steps=seq1_cycle, step_duration=dur.QUARTER + ) + def vco1_sub2(p): + # Same structure as vco1_sub1 - see comments there. + vel = round(p.signal("VELOCITY_SWELL")) + for trigger_idx, tick in enumerate(seq1_triggers): + step = (p.cycle * len(seq1_triggers) + trigger_idx) % 4 + vco_pitch = SEQ1_STEPS[step] if SEQ1_ASSIGN_OSC1 else VCO1_FREQ + pitch = _subharmonic_note(vco_pitch, VCO1_SUB2_FREQ) + p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) + if SCALE_MODE: + p.snap_to_scale(SCALE_ROOT, SCALE_MODE) # ─── SEQ 2 GROUP: VCO2 · VCO2 SUB1 · VCO2 SUB2 ────────────────────────────── @@ -389,48 +406,52 @@ def vco1_sub2 (p): # the step counter, ternary pitch selection, and subharmonic calculation. if seq2_triggers: - - if SEQ2_ASSIGN_OSC2: - - @composition.pattern(channel=VCO2_CHANNEL, steps=seq2_cycle, step_duration=dur.QUARTER) - def vco2 (p): - vel = round(p.signal("VELOCITY_SWELL")) - for trigger_idx, tick in enumerate(seq2_triggers): - step = (p.cycle * len(seq2_triggers) + trigger_idx) % 4 - pitch = SEQ2_STEPS[step] - p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) - if SCALE_MODE: - p.snap_to_scale(SCALE_ROOT, SCALE_MODE) - - if SEQ2_ASSIGN_SUB1: - - @composition.pattern(channel=VCO2_SUB1_CHANNEL, steps=seq2_cycle, step_duration=dur.QUARTER) - def vco2_sub1 (p): - vel = round(p.signal("VELOCITY_SWELL")) - for trigger_idx, tick in enumerate(seq2_triggers): - step = (p.cycle * len(seq2_triggers) + trigger_idx) % 4 - vco_pitch = SEQ2_STEPS[step] if SEQ2_ASSIGN_OSC2 else VCO2_FREQ - pitch = _subharmonic_note(vco_pitch, VCO2_SUB1_FREQ) - p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) - if SCALE_MODE: - p.snap_to_scale(SCALE_ROOT, SCALE_MODE) - - if SEQ2_ASSIGN_SUB2: - - @composition.pattern(channel=VCO2_SUB2_CHANNEL, steps=seq2_cycle, step_duration=dur.QUARTER) - def vco2_sub2 (p): - vel = round(p.signal("VELOCITY_SWELL")) - for trigger_idx, tick in enumerate(seq2_triggers): - step = (p.cycle * len(seq2_triggers) + trigger_idx) % 4 - vco_pitch = SEQ2_STEPS[step] if SEQ2_ASSIGN_OSC2 else VCO2_FREQ - pitch = _subharmonic_note(vco_pitch, VCO2_SUB2_FREQ) - p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) - if SCALE_MODE: - p.snap_to_scale(SCALE_ROOT, SCALE_MODE) + if SEQ2_ASSIGN_OSC2: + + @composition.pattern( + channel=VCO2_CHANNEL, steps=seq2_cycle, step_duration=dur.QUARTER + ) + def vco2(p): + vel = round(p.signal("VELOCITY_SWELL")) + for trigger_idx, tick in enumerate(seq2_triggers): + step = (p.cycle * len(seq2_triggers) + trigger_idx) % 4 + pitch = SEQ2_STEPS[step] + p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) + if SCALE_MODE: + p.snap_to_scale(SCALE_ROOT, SCALE_MODE) + + if SEQ2_ASSIGN_SUB1: + + @composition.pattern( + channel=VCO2_SUB1_CHANNEL, steps=seq2_cycle, step_duration=dur.QUARTER + ) + def vco2_sub1(p): + vel = round(p.signal("VELOCITY_SWELL")) + for trigger_idx, tick in enumerate(seq2_triggers): + step = (p.cycle * len(seq2_triggers) + trigger_idx) % 4 + vco_pitch = SEQ2_STEPS[step] if SEQ2_ASSIGN_OSC2 else VCO2_FREQ + pitch = _subharmonic_note(vco_pitch, VCO2_SUB1_FREQ) + p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) + if SCALE_MODE: + p.snap_to_scale(SCALE_ROOT, SCALE_MODE) + + if SEQ2_ASSIGN_SUB2: + + @composition.pattern( + channel=VCO2_SUB2_CHANNEL, steps=seq2_cycle, step_duration=dur.QUARTER + ) + def vco2_sub2(p): + vel = round(p.signal("VELOCITY_SWELL")) + for trigger_idx, tick in enumerate(seq2_triggers): + step = (p.cycle * len(seq2_triggers) + trigger_idx) % 4 + vco_pitch = SEQ2_STEPS[step] if SEQ2_ASSIGN_OSC2 else VCO2_FREQ + pitch = _subharmonic_note(vco_pitch, VCO2_SUB2_FREQ) + p.hit_steps(pitch, [tick], velocity=vel, duration=NOTE_DURATION) + if SCALE_MODE: + p.snap_to_scale(SCALE_ROOT, SCALE_MODE) if __name__ == "__main__": - - composition.display(grid=True, grid_scale=4) - composition.web_ui() - composition.play() + composition.display(grid=True, grid_scale=4) + composition.web_ui() + composition.play() diff --git a/scripts/check_docstring_markup.py b/scripts/check_docstring_markup.py index eab243f..f4ff74b 100644 --- a/scripts/check_docstring_markup.py +++ b/scripts/check_docstring_markup.py @@ -43,90 +43,102 @@ _FENCE = re.compile(r"^\s*```") -def _docstrings (tree): +def _docstrings(tree): + """Yield (start_lineno, text) for each module/class/function docstring.""" - """Yield (start_lineno, text) for each module/class/function docstring.""" + for node in ast.walk(tree): + if ( + isinstance( + node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + ) + and node.body + ): + first = node.body[0] - for node in ast.walk(tree): + if ( + isinstance(first, ast.Expr) + and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str) + ): + yield (first.value.lineno, first.value.value) - if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and node.body: - first = node.body[0] +def check_file(path): + """Return a list of (lineno, message) findings for one .py file.""" - if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str): - yield (first.value.lineno, first.value.value) + source = path.read_text() + tree = ast.parse(source) + findings = [] -def check_file (path): + for start_lineno, text in _docstrings(tree): + in_fence = False - """Return a list of (lineno, message) findings for one .py file.""" + for offset, line in enumerate(text.splitlines()): + if _FENCE.match(line): + in_fence = not in_fence + continue - source = path.read_text() - tree = ast.parse(source) + if in_fence: + continue - findings = [] + lineno = start_lineno + offset - for start_lineno, text in _docstrings(tree): + # Blank out inline-literal spans before the single-backtick and + # varargs checks: a literal's contents are verbatim, so ``*args`` + # or ``def f(*args)`` inside one is correct and must not trip them. + masked = _LITERAL.sub(lambda match: " " * len(match.group(0)), line) - in_fence = False + if any(match.group(1) is not None for match in _SPAN.finditer(masked)): + findings.append( + (lineno, "single-backtick inline code — use double backticks") + ) - for offset, line in enumerate(text.splitlines()): + if _VARARGS.search(masked): + findings.append( + (lineno, "bare *args/**kwargs — wrap in double backticks") + ) - if _FENCE.match(line): - in_fence = not in_fence - continue + for match in _LITERAL.finditer(line): + before = line[match.start() - 1] if match.start() > 0 else "" + after = line[match.end()] if match.end() < len(line) else "" - if in_fence: - continue + if before.isalnum() or after.isalnum(): + findings.append( + ( + lineno, + "word character glued to inline-literal backticks — reword so they meet a space", + ) + ) + break - lineno = start_lineno + offset + return findings - # Blank out inline-literal spans before the single-backtick and - # varargs checks: a literal's contents are verbatim, so ``*args`` - # or ``def f(*args)`` inside one is correct and must not trip them. - masked = _LITERAL.sub(lambda match: " " * len(match.group(0)), line) - if any(match.group(1) is not None for match in _SPAN.finditer(masked)): - findings.append((lineno, "single-backtick inline code — use double backticks")) +def main(argv): + """Scan the given paths (default subsequence/) and report findings.""" - if _VARARGS.search(masked): - findings.append((lineno, "bare *args/**kwargs — wrap in double backticks")) + roots = [pathlib.Path(a) for a in argv] or [pathlib.Path("subsequence")] + files = [] - for match in _LITERAL.finditer(line): + for root in roots: + files.extend(sorted(root.rglob("*.py")) if root.is_dir() else [root]) - before = line[match.start() - 1] if match.start() > 0 else "" - after = line[match.end()] if match.end() < len(line) else "" + total = 0 - if before.isalnum() or after.isalnum(): - findings.append((lineno, "word character glued to inline-literal backticks — reword so they meet a space")) - break + for path in files: + for lineno, message in check_file(path): + print(f"{path}:{lineno}: {message}") + total += 1 - return findings + if total: + print( + f"\n{total} docstring markup issue(s) — see docs at scripts/check_docstring_markup.py" + ) + return 1 - -def main (argv): - - """Scan the given paths (default subsequence/) and report findings.""" - - roots = [pathlib.Path(a) for a in argv] or [pathlib.Path("subsequence")] - files = [] - - for root in roots: - files.extend(sorted(root.rglob("*.py")) if root.is_dir() else [root]) - - total = 0 - - for path in files: - for lineno, message in check_file(path): - print(f"{path}:{lineno}: {message}") - total += 1 - - if total: - print(f"\n{total} docstring markup issue(s) — see docs at scripts/check_docstring_markup.py") - return 1 - - return 0 + return 0 if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + sys.exit(main(sys.argv[1:])) diff --git a/scripts/generate_cheatsheet.py b/scripts/generate_cheatsheet.py index 70cf49e..6bdfe9c 100644 --- a/scripts/generate_cheatsheet.py +++ b/scripts/generate_cheatsheet.py @@ -12,7 +12,7 @@ import typing # Add the path so we can import subsequence -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) import subsequence import subsequence.chords @@ -31,271 +31,261 @@ import subsequence.tuning classes_to_document: typing.List[typing.Type] = [ - subsequence.composition.Composition, - subsequence.pattern_builder.PatternBuilder, - subsequence.groove.Groove, - subsequence.melodic_state.MelodicState, - subsequence.tuning.Tuning, - subsequence.chords.Chord, - subsequence.progressions.Progression, - subsequence.progressions.ChordSpan, - subsequence.progressions.PitchSet, - subsequence.motifs.Motif, - subsequence.motifs.Phrase, - subsequence.forms.Section, - subsequence.forms.Form, + subsequence.composition.Composition, + subsequence.pattern_builder.PatternBuilder, + subsequence.groove.Groove, + subsequence.melodic_state.MelodicState, + subsequence.tuning.Tuning, + subsequence.chords.Chord, + subsequence.progressions.Progression, + subsequence.progressions.ChordSpan, + subsequence.progressions.PitchSet, + subsequence.motifs.Motif, + subsequence.motifs.Phrase, + subsequence.forms.Section, + subsequence.forms.Form, ] functions_to_document: typing.List[typing.Callable] = [ - subsequence.intervals.register_scale, - subsequence.intervals.scale_notes, - subsequence.midi_utils.bank_select, - subsequence.harmonic_rhythm.between, - subsequence.chords.parse_chord, - subsequence.chords.register_chord_quality, - subsequence.progressions.progression, - subsequence.motifs.motif, - subsequence.motifs.sentence, - subsequence.motifs.period, - subsequence.cadences.cadence_formula, - subsequence.sequence_utils.vl_distance, - subsequence.sequence_utils.branch_sequence, - subsequence.sequence_utils.build_metric_weights, - subsequence.sequence_utils.sieve, - subsequence.sequence_utils.residual_class, - subsequence.sequence_utils.rhythmic_evenness, - subsequence.sequence_utils.offbeatness, - subsequence.sequence_utils.syncopation, + subsequence.intervals.register_scale, + subsequence.intervals.scale_notes, + subsequence.midi_utils.bank_select, + subsequence.harmonic_rhythm.between, + subsequence.chords.parse_chord, + subsequence.chords.register_chord_quality, + subsequence.progressions.progression, + subsequence.motifs.motif, + subsequence.motifs.sentence, + subsequence.motifs.period, + subsequence.cadences.cadence_formula, + subsequence.sequence_utils.vl_distance, + subsequence.sequence_utils.branch_sequence, + subsequence.sequence_utils.build_metric_weights, + subsequence.sequence_utils.sieve, + subsequence.sequence_utils.residual_class, + subsequence.sequence_utils.rhythmic_evenness, + subsequence.sequence_utils.offbeatness, + subsequence.sequence_utils.syncopation, ] -def get_first_line (doc: typing.Optional[str]) -> str: +def get_first_line(doc: typing.Optional[str]) -> str: + """Extract the first paragraph from a docstring, handling indentation and word-wrap.""" - """Extract the first paragraph from a docstring, handling indentation and word-wrap.""" + if not doc: + return "" - if not doc: - return "" + paragraphs = re.split(r"\n\s*\n", doc.strip()) - paragraphs = re.split(r'\n\s*\n', doc.strip()) + if not paragraphs: + return "" - if not paragraphs: - return "" + first_para = paragraphs[0] + first_para = first_para.replace("\n", " ") + first_para = re.sub(r"\s+", " ", first_para) - first_para = paragraphs[0] - first_para = first_para.replace('\n', ' ') - first_para = re.sub(r'\s+', ' ', first_para) + return re.sub(r"^[\s*`-]*", "", first_para).strip() - return re.sub(r'^[\s*`-]*', '', first_para).strip() +def format_signature(sig: inspect.Signature) -> str: + """Format a signature by removing 'self' and type annotations for a cleaner cheat sheet.""" -def format_signature (sig: inspect.Signature) -> str: + s = str(sig) + ret_part = "" - """Format a signature by removing 'self' and type annotations for a cleaner cheat sheet.""" + if ") -> " in s: + # Find the last ") -> " which separates params from return type + ret_part = s[s.rfind(") -> ") + 1 :] + # Forward-reference annotations ("PatternBuilder") render with their + # quote characters — strip them so the sheet shows `-> Groove`, not + # the confusing `-> "'Groove'"`. + ret_part = ret_part.replace('"', "").replace("'", "") - s = str(sig) - ret_part = "" + params = [] - if ") -> " in s: - # Find the last ") -> " which separates params from return type - ret_part = s[s.rfind(") -> ") + 1:] - # Forward-reference annotations ("PatternBuilder") render with their - # quote characters — strip them so the sheet shows `-> Groove`, not - # the confusing `-> "'Groove'"`. - ret_part = ret_part.replace('"', '').replace("'", "") + for name, param in sig.parameters.items(): + if name == "self": + continue - params = [] + if param.kind == inspect.Parameter.VAR_POSITIONAL: + params.append(f"*{name}") - for name, param in sig.parameters.items(): + elif param.kind == inspect.Parameter.VAR_KEYWORD: + params.append(f"**{name}") - if name == 'self': - continue + else: + params.append(name) - if param.kind == inspect.Parameter.VAR_POSITIONAL: - params.append(f"*{name}") + return f"({', '.join(params)}){ret_part}" - elif param.kind == inspect.Parameter.VAR_KEYWORD: - params.append(f"**{name}") - else: - params.append(name) +def escape_md(text: str) -> str: + """Escape characters that might break Markdown table formatting.""" - return f"({', '.join(params)}){ret_part}" + return text.replace("|", "\\|").replace("\n", " ") -def escape_md (text: str) -> str: +def is_public_method(name: str, member: typing.Any) -> bool: + """Determine if a class member should be included in the public API documentation.""" - """Escape characters that might break Markdown table formatting.""" + if name.startswith("_"): + if name != "__init__": + return False - return text.replace('|', '\\|').replace('\n', ' ') + return ( + inspect.isfunction(member) + or isinstance(member, property) + or inspect.ismethod(member) + ) -def is_public_method (name: str, member: typing.Any) -> bool: +def generate_markdown() -> str: + """Iterate through the public API surface and generate a Markdown cheat sheet.""" - """Determine if a class member should be included in the public API documentation.""" + output = ["# Subsequence API Cheat Sheet\n"] + output.append( + "This document provides a quick overview of the public classes, methods, and functions available in the Subsequence API.\n" + ) - if name.startswith('_'): + # The package-level inventory first — everything importable directly from + # `subsequence`, read from the live package so this table cannot go stale. + # (The per-class sections below cover the big classes in detail; several + # smaller exports appear ONLY here.) + output.append("## Package-level exports\n") + output.append("Everything importable as `subsequence.X`:\n") + output.append("| Export | Kind | Description |") + output.append("|---|---|---|") - if name != '__init__': - return False + for name in sorted(dir(subsequence)): + if name.startswith("_"): + continue - return inspect.isfunction(member) or isinstance(member, property) or inspect.ismethod(member) + member = getattr(subsequence, name) + if inspect.ismodule(member): + continue -def generate_markdown () -> str: + if inspect.isclass(member): + kind = "class" + elif callable(member): + kind = "function" + else: + kind = "value" - """Iterate through the public API surface and generate a Markdown cheat sheet.""" + desc = get_first_line(getattr(member, "__doc__", None)) + output.append(f"| `{name}` | {kind} | {escape_md(desc)} |") - output = ["# Subsequence API Cheat Sheet\n"] - output.append("This document provides a quick overview of the public classes, methods, and functions available in the Subsequence API.\n") + output.append("\n") - # The package-level inventory first — everything importable directly from - # `subsequence`, read from the live package so this table cannot go stale. - # (The per-class sections below cover the big classes in detail; several - # smaller exports appear ONLY here.) - output.append("## Package-level exports\n") - output.append("Everything importable as `subsequence.X`:\n") - output.append("| Export | Kind | Description |") - output.append("|---|---|---|") + for cls in classes_to_document: + output.append(f"## `{cls.__name__}`\n") + doc = get_first_line(cls.__doc__) - for name in sorted(dir(subsequence)): + if doc: + output.append(f"{doc}\n") - if name.startswith('_'): - continue + output.append("| Method | Description |") + output.append("|---|---|") - member = getattr(subsequence, name) + # Get public methods + methods = [] - if inspect.ismodule(member): - continue + for name, member in inspect.getmembers(cls): + if is_public_method(name, member): + methods.append((name, member)) - if inspect.isclass(member): - kind = "class" - elif callable(member): - kind = "function" - else: - kind = "value" + methods.sort(key=lambda x: x[0]) - desc = get_first_line(getattr(member, '__doc__', None)) - output.append(f"| `{name}` | {kind} | {escape_md(desc)} |") + for name, member in methods: + try: + if isinstance(member, property): + signature = " *(property)*" + desc = get_first_line(member.__doc__) - output.append("\n") + else: + try: + sig = inspect.signature(member) + signature = format_signature(sig) - for cls in classes_to_document: + except ValueError: + signature = "(...)" - output.append(f"## `{cls.__name__}`\n") - doc = get_first_line(cls.__doc__) + desc = get_first_line(member.__doc__) - if doc: - output.append(f"{doc}\n") + code_col = f"`{name}{signature}`" - output.append("| Method | Description |") - output.append("|---|---|") + # escape pipes if any + code_col = escape_md(code_col) + desc_md = escape_md(desc) - # Get public methods - methods = [] + output.append(f"| {code_col} | {desc_md} |") - for name, member in inspect.getmembers(cls): + except Exception: + # Fallback if something fails + output.append(f"| `{name}` | Error formatting |") - if is_public_method(name, member): - methods.append((name, member)) + output.append("\n") - methods.sort(key=lambda x: x[0]) + output.append("## Global Functions\n\n") + output.append("| Function | Description |") + output.append("|---|---|") - for name, member in methods: + for func in functions_to_document: + name = func.__name__ - try: - if isinstance(member, property): - signature = " *(property)*" - desc = get_first_line(member.__doc__) + try: + sig = inspect.signature(func) + signature = format_signature(sig) - else: + except Exception: + signature = "(...)" - try: - sig = inspect.signature(member) - signature = format_signature(sig) + desc = get_first_line(func.__doc__) - except ValueError: - signature = "(...)" + code_col = f"`{name}{signature}`" + output.append(f"| {escape_md(code_col)} | {escape_md(desc)} |") - desc = get_first_line(member.__doc__) + output.append("\n## Sequence Utilities (`subsequence.sequence_utils`)\n\n") + output.append("Functions for generating and transforming sequences.\n\n") + output.append("| Function | Description |") + output.append("|---|---|") - code_col = f"`{name}{signature}`" + seq_funcs = [] - # escape pipes if any - code_col = escape_md(code_col) - desc_md = escape_md(desc) + for name, member in inspect.getmembers(subsequence.sequence_utils): + if is_public_method(name, member): + seq_funcs.append((name, member)) - output.append(f"| {code_col} | {desc_md} |") + seq_funcs.sort(key=lambda x: x[0]) - except Exception: - # Fallback if something fails - output.append(f"| `{name}` | Error formatting |") + for name, func in seq_funcs: + # Ignore imported modules like typing, math, random, etc. - output.append("\n") + if func.__module__ != "subsequence.sequence_utils": + continue - output.append("## Global Functions\n\n") - output.append("| Function | Description |") - output.append("|---|---|") + try: + sig = inspect.signature(func) + signature = format_signature(sig) - for func in functions_to_document: + except Exception: + signature = "(...)" - name = func.__name__ + desc = get_first_line(func.__doc__) - try: - sig = inspect.signature(func) - signature = format_signature(sig) + code_col = f"`{name}{signature}`" + output.append(f"| {escape_md(code_col)} | {escape_md(desc)} |") - except Exception: - signature = "(...)" - - desc = get_first_line(func.__doc__) - - code_col = f"`{name}{signature}`" - output.append(f"| {escape_md(code_col)} | {escape_md(desc)} |") - - output.append("\n## Sequence Utilities (`subsequence.sequence_utils`)\n\n") - output.append("Functions for generating and transforming sequences.\n\n") - output.append("| Function | Description |") - output.append("|---|---|") - - seq_funcs = [] - - for name, member in inspect.getmembers(subsequence.sequence_utils): - - if is_public_method(name, member): - seq_funcs.append((name, member)) - - seq_funcs.sort(key=lambda x: x[0]) - - for name, func in seq_funcs: - # Ignore imported modules like typing, math, random, etc. - - if func.__module__ != 'subsequence.sequence_utils': - continue - - try: - sig = inspect.signature(func) - signature = format_signature(sig) - - except Exception: - signature = "(...)" - - desc = get_first_line(func.__doc__) - - code_col = f"`{name}{signature}`" - output.append(f"| {escape_md(code_col)} | {escape_md(desc)} |") - - return "\n".join(output) + return "\n".join(output) if __name__ == "__main__": + md_content = generate_markdown() - md_content = generate_markdown() - - docs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) - output_path = os.path.join(docs_dir, 'api-cheatsheet.md') - - with open(output_path, 'w') as f: - f.write(md_content) + docs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + output_path = os.path.join(docs_dir, "api-cheatsheet.md") - print(f"Generated cheatsheet at {output_path}") + with open(output_path, "w") as f: + f.write(md_content) + print(f"Generated cheatsheet at {output_path}") diff --git a/scripts/midi_in_observer.py b/scripts/midi_in_observer.py index 770f498..278400e 100644 --- a/scripts/midi_in_observer.py +++ b/scripts/midi_in_observer.py @@ -15,143 +15,144 @@ logger = logging.getLogger(__name__) -def _select_input_device () -> typing.Tuple[str, typing.Any]: +def _select_input_device() -> typing.Tuple[str, typing.Any]: + """Prompt the user to select a MIDI input device and open it.""" + + try: + inputs = mido.get_input_names() + logger.info("Available MIDI inputs: %s", inputs) + + if not inputs: + raise RuntimeError("No MIDI input devices found.") + + if len(inputs) == 1: + selected_name = inputs[0] + midi_in = mido.open_input(selected_name) + logger.info("One MIDI input found - using '%s'", selected_name) + return selected_name, midi_in + + print("\nAvailable MIDI input devices:\n") + for i, name in enumerate(inputs, 1): + print(f" {i}. {name}") + print() + + while True: + try: + choice = int(input(f"Select a device (1-{len(inputs)}): ")) + if 1 <= choice <= len(inputs): + break + except (ValueError, EOFError): + pass + print(f"Enter a number between 1 and {len(inputs)}.") + + selected_name = inputs[choice - 1] + midi_in = mido.open_input(selected_name) + logger.info("Opened MIDI input: %s", selected_name) + return selected_name, midi_in + except Exception as exc: + raise RuntimeError(f"Failed to open MIDI input: {exc}") from exc + + +def _format_message(message: typing.Any) -> str: + """Format a mido message into a compact, readable line.""" + + parts = [message.type] + + if hasattr(message, "channel"): + parts.append(f"ch={message.channel + 1}") + + if message.type in {"note_on", "note_off"}: + parts.append(f"note={message.note}") + parts.append(f"vel={message.velocity}") + elif message.type == "control_change": + parts.append(f"cc={message.control}") + parts.append(f"val={message.value}") + elif message.type == "program_change": + parts.append(f"program={message.program}") + elif message.type == "pitchwheel": + parts.append(f"pitch={message.pitch}") + elif message.type == "polytouch": + parts.append(f"note={message.note}") + parts.append(f"val={message.value}") + elif message.type == "aftertouch": + parts.append(f"val={message.value}") + elif message.type == "songpos": + parts.append(f"pos={message.pos}") + elif message.type == "song_select": + parts.append(f"song={message.song}") + elif message.type == "quarter_frame": + parts.append(f"type={message.frame_type}") + parts.append(f"val={message.frame_value}") + elif message.type == "sysex": + parts.append(f"len={len(message.data)}") + + return " ".join(parts) + + +def _run() -> int: + """Open a MIDI input device and print all incoming messages.""" + + print("\nThis tool prints all incoming MIDI messages from a selected input device.") + print( + "Use it to discover note, CC, channel, and transport data from your controller." + ) + print( + "Note: MIDI channels are displayed 1-indexed (1-16) to match hardware panels." + ) + + selected_name, midi_in = _select_input_device() + + print(f"\nListening on: {selected_name}") + print("Press Ctrl+C to quit.\n") + + clock_window_seconds = 5.0 + clock_tick_count = 0 + clock_window_start = time.time() + + def _on_message(message: typing.Any) -> None: + nonlocal clock_tick_count + nonlocal clock_window_start + + if message.type == "clock": + clock_tick_count += 1 + + if clock_tick_count == 1: + print("clock: detected (summary updated every 5 seconds)") + + now = time.time() + elapsed = now - clock_window_start + if elapsed >= clock_window_seconds: + ticks_per_second = clock_tick_count / elapsed + estimated_bpm = ticks_per_second * 60.0 / 24.0 + print( + f"clock: {ticks_per_second:.2f} ticks/s (≈{estimated_bpm:.1f} BPM) [updated every 5 seconds]" + ) + + clock_tick_count = 0 + clock_window_start = now + + return + + print(_format_message(message)) + + midi_in.callback = _on_message + + try: + while True: + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + finally: + midi_in.close() - """Prompt the user to select a MIDI input device and open it.""" + return 0 - try: - inputs = mido.get_input_names() - logger.info("Available MIDI inputs: %s", inputs) - - if not inputs: - raise RuntimeError("No MIDI input devices found.") - if len(inputs) == 1: - selected_name = inputs[0] - midi_in = mido.open_input(selected_name) - logger.info("One MIDI input found - using '%s'", selected_name) - return selected_name, midi_in +def main() -> None: + """Entry point for the MIDI input observer.""" - print("\nAvailable MIDI input devices:\n") - for i, name in enumerate(inputs, 1): - print(f" {i}. {name}") - print() - - while True: - try: - choice = int(input(f"Select a device (1-{len(inputs)}): ")) - if 1 <= choice <= len(inputs): - break - except (ValueError, EOFError): - pass - print(f"Enter a number between 1 and {len(inputs)}.") - - selected_name = inputs[choice - 1] - midi_in = mido.open_input(selected_name) - logger.info("Opened MIDI input: %s", selected_name) - return selected_name, midi_in - except Exception as exc: - raise RuntimeError(f"Failed to open MIDI input: {exc}") from exc - - -def _format_message (message: typing.Any) -> str: - - """Format a mido message into a compact, readable line.""" - - parts = [message.type] - - if hasattr(message, "channel"): - parts.append(f"ch={message.channel + 1}") - - if message.type in {"note_on", "note_off"}: - parts.append(f"note={message.note}") - parts.append(f"vel={message.velocity}") - elif message.type == "control_change": - parts.append(f"cc={message.control}") - parts.append(f"val={message.value}") - elif message.type == "program_change": - parts.append(f"program={message.program}") - elif message.type == "pitchwheel": - parts.append(f"pitch={message.pitch}") - elif message.type == "polytouch": - parts.append(f"note={message.note}") - parts.append(f"val={message.value}") - elif message.type == "aftertouch": - parts.append(f"val={message.value}") - elif message.type == "songpos": - parts.append(f"pos={message.pos}") - elif message.type == "song_select": - parts.append(f"song={message.song}") - elif message.type == "quarter_frame": - parts.append(f"type={message.frame_type}") - parts.append(f"val={message.frame_value}") - elif message.type == "sysex": - parts.append(f"len={len(message.data)}") - - return " ".join(parts) - - -def _run () -> int: - - """Open a MIDI input device and print all incoming messages.""" - - print("\nThis tool prints all incoming MIDI messages from a selected input device.") - print("Use it to discover note, CC, channel, and transport data from your controller.") - print("Note: MIDI channels are displayed 1-indexed (1-16) to match hardware panels.") - - selected_name, midi_in = _select_input_device() - - print(f"\nListening on: {selected_name}") - print("Press Ctrl+C to quit.\n") - - clock_window_seconds = 5.0 - clock_tick_count = 0 - clock_window_start = time.time() - - def _on_message (message: typing.Any) -> None: - - nonlocal clock_tick_count - nonlocal clock_window_start - - if message.type == "clock": - clock_tick_count += 1 - - if clock_tick_count == 1: - print("clock: detected (summary updated every 5 seconds)") - - now = time.time() - elapsed = now - clock_window_start - if elapsed >= clock_window_seconds: - ticks_per_second = clock_tick_count / elapsed - estimated_bpm = ticks_per_second * 60.0 / 24.0 - print(f"clock: {ticks_per_second:.2f} ticks/s (≈{estimated_bpm:.1f} BPM) [updated every 5 seconds]") - - clock_tick_count = 0 - clock_window_start = now - - return - - print(_format_message(message)) - - midi_in.callback = _on_message - - try: - while True: - time.sleep(0.1) - except KeyboardInterrupt: - print("\nStopping...") - finally: - midi_in.close() - - return 0 - - -def main () -> None: - - """Entry point for the MIDI input observer.""" - - raise SystemExit(_run()) + raise SystemExit(_run()) if __name__ == "__main__": - main() + main() diff --git a/scripts/simulation_gravity.py b/scripts/simulation_gravity.py index 2584dd9..5fadb55 100644 --- a/scripts/simulation_gravity.py +++ b/scripts/simulation_gravity.py @@ -4,7 +4,7 @@ Not a pytest file. Run directly with: - python scripts/simulation_gravity.py + python scripts/simulation_gravity.py """ import collections @@ -19,17 +19,17 @@ GRAPH_STYLES = [ - "functional_major", - "aeolian_minor", - "phrygian_minor", - "lydian_major", - "dorian_minor", - "chromatic_mediant", - "suspended", - "mixolydian", - "whole_tone", - "diminished", - "turnaround", + "functional_major", + "aeolian_minor", + "phrygian_minor", + "lydian_major", + "dorian_minor", + "chromatic_mediant", + "suspended", + "mixolydian", + "whole_tone", + "diminished", + "turnaround", ] # Thresholds for pass/fail. @@ -38,112 +38,123 @@ MAX_STREAK = 10 -def analyze_style ( - style: str, - gravity: float, - nir_strength: float = 0.5, - steps: int = 1000, - seed: int = 42, +def analyze_style( + style: str, + gravity: float, + nir_strength: float = 0.5, + steps: int = 1000, + seed: int = 42, ) -> typing.Optional[typing.Dict[str, typing.Any]]: - - """Run one simulation and return a summary dict, or None on init failure.""" - - rng = random.Random(seed) - - try: - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", - graph_style=style, - include_dominant_7th=True, - key_gravity_blend=gravity, - nir_strength=nir_strength, - rng=rng, - ) - except Exception as e: - print(f"Failed to init {style}: {e}") - return None - - history_roots = [] - - current = hs.current_chord - history_roots.append(current.root_pc) - - for _ in range(steps): - chord = hs.step() - history_roots.append(chord.root_pc) - - # Analyze streaks (same root) - streaks = [] - current_streak = 1 - max_streak = 0 - - for i in range(1, len(history_roots)): - if history_roots[i] == history_roots[i - 1]: - current_streak += 1 - else: - streaks.append(current_streak) - max_streak = max(max_streak, current_streak) - current_streak = 1 - - max_streak = max(max_streak, current_streak) - - avg_streak = statistics.mean(streaks) if streaks else 0 - unique_roots = len(set(history_roots)) - - # Analyze distribution - counts = collections.Counter(history_roots) - if not counts: - return None - - total_steps = len(history_roots) - sorted_common = counts.most_common() - top_1_pct = sorted_common[0][1] / total_steps - top_2_pct = (sorted_common[0][1] + sorted_common[1][1]) / total_steps if len(sorted_common) > 1 else top_1_pct - - return { - "style": style, - "gravity": gravity, - "nir_strength": nir_strength, - "max_streak": max_streak, - "avg_streak": avg_streak, - "unique_roots": unique_roots, - "top_1_pct": top_1_pct, - "top_2_pct": top_2_pct, - } - - -def run_simulation () -> None: - - """Print a table of results across styles, gravities, and NIR settings.""" - - print(f"{'Style':<20} | {'Grav':<4} | {'NIR':<4} | {'Max Strk':<8} | {'Top 1%':<6} | {'Top 2%':<6} | {'Unique':<6}") - print("-" * 75) - - results = [] - failures = 0 - - for style in GRAPH_STYLES: - for gravity in [0.0, 0.5, 0.8, 1.0]: - for nir in [0.0, 0.5, 1.0]: - res = analyze_style(style, gravity, nir_strength=nir) - if res: - results.append(res) - - flag = "" - if res["top_1_pct"] > MAX_TOP_1_PCT or res["top_2_pct"] > MAX_TOP_2_PCT or res["max_streak"] > MAX_STREAK: - flag = " FAIL" - failures += 1 - - print(f"{style:<20} | {gravity:<4.1f} | {nir:<4.1f} | {res['max_streak']:<8} | {res['top_1_pct']:<6.2f} | {res['top_2_pct']:<6.2f} | {res['unique_roots']:<6}{flag}") - - print() - - if failures: - print(f"RESULT: {failures} combination(s) exceeded thresholds (top_1>{MAX_TOP_1_PCT}, top_2>{MAX_TOP_2_PCT}, streak>{MAX_STREAK})") - else: - print(f"RESULT: All {len(results)} combinations passed.") + """Run one simulation and return a summary dict, or None on init failure.""" + + rng = random.Random(seed) + + try: + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", + graph_style=style, + include_dominant_7th=True, + key_gravity_blend=gravity, + nir_strength=nir_strength, + rng=rng, + ) + except Exception as e: + print(f"Failed to init {style}: {e}") + return None + + history_roots = [] + + current = hs.current_chord + history_roots.append(current.root_pc) + + for _ in range(steps): + chord = hs.step() + history_roots.append(chord.root_pc) + + # Analyze streaks (same root) + streaks = [] + current_streak = 1 + max_streak = 0 + + for i in range(1, len(history_roots)): + if history_roots[i] == history_roots[i - 1]: + current_streak += 1 + else: + streaks.append(current_streak) + max_streak = max(max_streak, current_streak) + current_streak = 1 + + max_streak = max(max_streak, current_streak) + + avg_streak = statistics.mean(streaks) if streaks else 0 + unique_roots = len(set(history_roots)) + + # Analyze distribution + counts = collections.Counter(history_roots) + if not counts: + return None + + total_steps = len(history_roots) + sorted_common = counts.most_common() + top_1_pct = sorted_common[0][1] / total_steps + top_2_pct = ( + (sorted_common[0][1] + sorted_common[1][1]) / total_steps + if len(sorted_common) > 1 + else top_1_pct + ) + + return { + "style": style, + "gravity": gravity, + "nir_strength": nir_strength, + "max_streak": max_streak, + "avg_streak": avg_streak, + "unique_roots": unique_roots, + "top_1_pct": top_1_pct, + "top_2_pct": top_2_pct, + } + + +def run_simulation() -> None: + """Print a table of results across styles, gravities, and NIR settings.""" + + print( + f"{'Style':<20} | {'Grav':<4} | {'NIR':<4} | {'Max Strk':<8} | {'Top 1%':<6} | {'Top 2%':<6} | {'Unique':<6}" + ) + print("-" * 75) + + results = [] + failures = 0 + + for style in GRAPH_STYLES: + for gravity in [0.0, 0.5, 0.8, 1.0]: + for nir in [0.0, 0.5, 1.0]: + res = analyze_style(style, gravity, nir_strength=nir) + if res: + results.append(res) + + flag = "" + if ( + res["top_1_pct"] > MAX_TOP_1_PCT + or res["top_2_pct"] > MAX_TOP_2_PCT + or res["max_streak"] > MAX_STREAK + ): + flag = " FAIL" + failures += 1 + + print( + f"{style:<20} | {gravity:<4.1f} | {nir:<4.1f} | {res['max_streak']:<8} | {res['top_1_pct']:<6.2f} | {res['top_2_pct']:<6.2f} | {res['unique_roots']:<6}{flag}" + ) + + print() + + if failures: + print( + f"RESULT: {failures} combination(s) exceeded thresholds (top_1>{MAX_TOP_1_PCT}, top_2>{MAX_TOP_2_PCT}, streak>{MAX_STREAK})" + ) + else: + print(f"RESULT: All {len(results)} combinations passed.") if __name__ == "__main__": - - run_simulation() + run_simulation() diff --git a/scripts/tuner_tone.py b/scripts/tuner_tone.py index b8cea9c..a99c029 100644 --- a/scripts/tuner_tone.py +++ b/scripts/tuner_tone.py @@ -3,10 +3,10 @@ This script plays a pulsed E3 note on all 16 MIDI channels of a selected output device. This utility is used to assess whether instruments are in tune with each -other and allow tuning against a reference. This is particularly useful +other and allow tuning against a reference. This is particularly useful for analogue instruments which may drift in pitch over time. -The note is triggered once per second to accommodate instruments with +The note is triggered once per second to accommodate instruments with short non-sustaining envelopes. """ @@ -21,89 +21,91 @@ logger = logging.getLogger(__name__) -def _select_output_device () -> typing.Tuple[str, typing.Any]: - - """Prompt the user to select a MIDI output device and open it.""" - - try: - outputs = mido.get_output_names() - logger.info("Available MIDI outputs: %s", outputs) - - if not outputs: - raise RuntimeError("No MIDI output devices found.") - - if len(outputs) == 1: - selected_name = outputs[0] - midi_out = mido.open_output(selected_name) - logger.info("One MIDI output found - using '%s'", selected_name) - return selected_name, midi_out - - print("\nAvailable MIDI output devices:\n") - for i, name in enumerate(outputs, 1): - print(f" {i}. {name}") - print() - - while True: - try: - choice = int(input(f"Select a device (1-{len(outputs)}): ")) - if 1 <= choice <= len(outputs): - break - except (ValueError, EOFError): - pass - print(f"Enter a number between 1 and {len(outputs)}.") - - selected_name = outputs[choice - 1] - midi_out = mido.open_output(selected_name) - logger.info("Opened MIDI output: %s", selected_name) - return selected_name, midi_out - except Exception as exc: - raise RuntimeError(f"Failed to open MIDI output: {exc}") from exc - - -def main () -> None: - - """Run the tuner tone script.""" - - logging.basicConfig(level=logging.WARNING, format='%(message)s') - - print("Subsequence Tuner Tone\n") - - try: - device_name, midi_out = _select_output_device() - except RuntimeError as exc: - print(exc) - sys.exit(1) - - # E3 is MIDI note 52 (assuming C4 = 60). - pitch = 52 - velocity = 100 - - print(f"\nSending E3 (note {pitch}) to all 16 channels on '{device_name}'...") - print("Press CTRL-C to stop.") - - try: - # Keep running until interrupted, repeatedly triggering the notes - while True: - for channel in range(16): - msg_on = mido.Message('note_on', note=pitch, velocity=velocity, channel=channel) - midi_out.send(msg_on) - - time.sleep(1.0) - - for channel in range(16): - msg_off = mido.Message('note_off', note=pitch, velocity=0, channel=channel) - midi_out.send(msg_off) - - except KeyboardInterrupt: - print("\nStopping notes...") - finally: - # Send note_off to all channels - for channel in range(16): - msg_off = mido.Message('note_off', note=pitch, velocity=0, channel=channel) - midi_out.send(msg_off) - midi_out.close() - print("Done.") - - -if __name__ == '__main__': - main() +def _select_output_device() -> typing.Tuple[str, typing.Any]: + """Prompt the user to select a MIDI output device and open it.""" + + try: + outputs = mido.get_output_names() + logger.info("Available MIDI outputs: %s", outputs) + + if not outputs: + raise RuntimeError("No MIDI output devices found.") + + if len(outputs) == 1: + selected_name = outputs[0] + midi_out = mido.open_output(selected_name) + logger.info("One MIDI output found - using '%s'", selected_name) + return selected_name, midi_out + + print("\nAvailable MIDI output devices:\n") + for i, name in enumerate(outputs, 1): + print(f" {i}. {name}") + print() + + while True: + try: + choice = int(input(f"Select a device (1-{len(outputs)}): ")) + if 1 <= choice <= len(outputs): + break + except (ValueError, EOFError): + pass + print(f"Enter a number between 1 and {len(outputs)}.") + + selected_name = outputs[choice - 1] + midi_out = mido.open_output(selected_name) + logger.info("Opened MIDI output: %s", selected_name) + return selected_name, midi_out + except Exception as exc: + raise RuntimeError(f"Failed to open MIDI output: {exc}") from exc + + +def main() -> None: + """Run the tuner tone script.""" + + logging.basicConfig(level=logging.WARNING, format="%(message)s") + + print("Subsequence Tuner Tone\n") + + try: + device_name, midi_out = _select_output_device() + except RuntimeError as exc: + print(exc) + sys.exit(1) + + # E3 is MIDI note 52 (assuming C4 = 60). + pitch = 52 + velocity = 100 + + print(f"\nSending E3 (note {pitch}) to all 16 channels on '{device_name}'...") + print("Press CTRL-C to stop.") + + try: + # Keep running until interrupted, repeatedly triggering the notes + while True: + for channel in range(16): + msg_on = mido.Message( + "note_on", note=pitch, velocity=velocity, channel=channel + ) + midi_out.send(msg_on) + + time.sleep(1.0) + + for channel in range(16): + msg_off = mido.Message( + "note_off", note=pitch, velocity=0, channel=channel + ) + midi_out.send(msg_off) + + except KeyboardInterrupt: + print("\nStopping notes...") + finally: + # Send note_off to all channels + for channel in range(16): + msg_off = mido.Message("note_off", note=pitch, velocity=0, channel=channel) + midi_out.send(msg_off) + midi_out.close() + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/subsequence/__init__.py b/subsequence/__init__.py index 9f853e6..8ca9e90 100644 --- a/subsequence/__init__.py +++ b/subsequence/__init__.py @@ -1,4 +1,3 @@ - """ Subsequence - an algorithmic composition framework for Python. @@ -140,8 +139,8 @@ distinct at a glance. Add ``grid_scale=2`` to zoom in horizontally, revealing swing and groove micro-timing. -- **Web UI Dashboard (Beta).** Enable with ``composition.web_ui()`` to - broadcast live composition metadata and visualize piano-roll pattern +- **Web UI Dashboard (Beta).** Enable with ``composition.web_ui()`` to + broadcast live composition metadata and visualize piano-roll pattern grids in a reactive HTTP/WebSocket browser dashboard. - **Ableton Link.** Industry-standard wireless tempo/phase sync (``comp.link()``; requires ``pip install subsequence[link]``). diff --git a/subsequence/__main__.py b/subsequence/__main__.py index e55ad7e..c6263bd 100644 --- a/subsequence/__main__.py +++ b/subsequence/__main__.py @@ -10,15 +10,14 @@ logger = logging.getLogger(__name__) -def main () -> None: +def main() -> None: + """ + Main entry point for the subsequence module. + """ - """ - Main entry point for the subsequence module. - """ - - logger.info("Subsequence module loaded.") - logger.info("To run the demo, execute: python examples/demo.py") + logger.info("Subsequence module loaded.") + logger.info("To run the demo, execute: python examples/demo.py") if __name__ == "__main__": - main() + main() diff --git a/subsequence/cadences.py b/subsequence/cadences.py index e9cc731..7fea794 100644 --- a/subsequence/cadences.py +++ b/subsequence/cadences.py @@ -28,86 +28,86 @@ @dataclasses.dataclass(frozen=True) class Cadence: + """One cadence formula — a named tail plus its melodic close. - """One cadence formula — a named tail plus its melodic close. + Attributes: + name: The producer name (the primary key in the table). + theory_name: The traditional name, for the curious. + formula: The chord tail, in progression-element grammar, ending on + the arrival chord. + close_degree: The scale degree a melody lands on at this cadence + (1 for full closes; 5 for the open half — and 1 for the + fakeout too: the melody resolves as promised while the + harmony swerves, which is the trick of it). + """ - Attributes: - name: The producer name (the primary key in the table). - theory_name: The traditional name, for the curious. - formula: The chord tail, in progression-element grammar, ending on - the arrival chord. - close_degree: The scale degree a melody lands on at this cadence - (1 for full closes; 5 for the open half — and 1 for the - fakeout too: the melody resolves as promised while the - harmony swerves, which is the trick of it). - """ - - name: str - theory_name: str - formula: typing.Tuple[typing.Any, ...] - close_degree: int + name: str + theory_name: str + formula: typing.Tuple[typing.Any, ...] + close_degree: int # The curated table — producer names primary. Two-chord tails throughout: # a cadence is an arrival WITH its approach, and two chords is the smallest # honest spelling of that. CADENCES: typing.Dict[str, Cadence] = { - "strong": Cadence( - name = "strong", - theory_name = "authentic", - formula = ("V", 1), - close_degree = 1, - ), - "soft": Cadence( - name = "soft", - theory_name = "plagal", - formula = (4, 1), - close_degree = 1, - ), - "open": Cadence( - name = "open", - theory_name = "half", - formula = (4, "V"), - close_degree = 5, - ), - "fakeout": Cadence( - name = "fakeout", - theory_name = "deceptive", - formula = ("V", 6), - close_degree = 1, - ), + "strong": Cadence( + name="strong", + theory_name="authentic", + formula=("V", 1), + close_degree=1, + ), + "soft": Cadence( + name="soft", + theory_name="plagal", + formula=(4, 1), + close_degree=1, + ), + "open": Cadence( + name="open", + theory_name="half", + formula=(4, "V"), + close_degree=5, + ), + "fakeout": Cadence( + name="fakeout", + theory_name="deceptive", + formula=("V", 6), + close_degree=1, + ), } # Theory names as aliases — accuracy costs nothing here, the words name the # same formulas. _ALIASES: typing.Dict[str, str] = { - "authentic": "strong", - "perfect": "strong", - "plagal": "soft", - "half": "open", - "deceptive": "fakeout", - "interrupted": "fakeout", + "authentic": "strong", + "perfect": "strong", + "plagal": "soft", + "half": "open", + "deceptive": "fakeout", + "interrupted": "fakeout", } -def cadence_formula (name: str) -> Cadence: - - """Look up a cadence by producer name or theory alias, loudly. +def cadence_formula(name: str) -> Cadence: + """Look up a cadence by producer name or theory alias, loudly. - Raises: - ValueError: If the name is unknown — the error lists every valid - name and alias. - """ + Raises: + ValueError: If the name is unknown — the error lists every valid + name and alias. + """ - if not isinstance(name, str): - raise TypeError(f"a cadence is named by string, got {name!r}") + if not isinstance(name, str): + raise TypeError(f"a cadence is named by string, got {name!r}") - key = name.strip().lower() - key = _ALIASES.get(key, key) + key = name.strip().lower() + key = _ALIASES.get(key, key) - if key not in CADENCES: - names = ", ".join(sorted(CADENCES)) - aliases = ", ".join(sorted(_ALIASES)) - raise ValueError(f"Unknown cadence {name!r}. Cadences: {names} (aliases: {aliases}).") + if key not in CADENCES: + names = ", ".join(sorted(CADENCES)) + aliases = ", ".join(sorted(_ALIASES)) + raise ValueError( + f"Unknown cadence {name!r}. Cadences: {names} (aliases: {aliases})." + ) - return CADENCES[key] + return CADENCES[key] diff --git a/subsequence/chord_graphs/__init__.py b/subsequence/chord_graphs/__init__.py index ce4eb31..75e3288 100644 --- a/subsequence/chord_graphs/__init__.py +++ b/subsequence/chord_graphs/__init__.py @@ -18,83 +18,94 @@ WEIGHT_WEAK = 1 -class ChordGraph (abc.ABC): +class ChordGraph(abc.ABC): + """Abstract base for chord transition graphs.""" - """Abstract base for chord transition graphs.""" + @abc.abstractmethod + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build the weighted graph and return it with the tonic chord.""" - @abc.abstractmethod - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: + ... - """Build the weighted graph and return it with the tonic chord.""" + @abc.abstractmethod + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return (diatonic_set, functional_set) for key gravity weighting.""" - ... + ... - @abc.abstractmethod - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - """Return (diatonic_set, functional_set) for key gravity weighting.""" +def validate_key_name(key_name: str) -> int: + """Validate a key name and return its pitch class. - ... + Raises ValueError if the key name is not recognised. + Parameters: + key_name: Note name (e.g., ``"C"``, ``"F#"``, ``"Bb"``) -def validate_key_name (key_name: str) -> int: + Returns: + Pitch class integer (0-11) + """ - """Validate a key name and return its pitch class. + return subsequence.chords.key_name_to_pc(key_name) - Raises ValueError if the key name is not recognised. - Parameters: - key_name: Note name (e.g., ``"C"``, ``"F#"``, ``"Bb"``) +def build_diatonic_chords( + scale_pcs: typing.List[int], degree_qualities: typing.List[str] +) -> typing.List[subsequence.chords.Chord]: + """Build chords for each scale degree. - Returns: - Pitch class integer (0-11) - """ + Parameters: + scale_pcs: Pitch class for each degree (e.g., from ``scale_pitch_classes(key_pc, mode)``) + degree_qualities: Chord quality for each degree (e.g., ``["major", "minor", ...]``) - return subsequence.chords.key_name_to_pc(key_name) + Returns: + List of Chord objects, one per scale degree + """ + chords: typing.List[subsequence.chords.Chord] = [] -def build_diatonic_chords (scale_pcs: typing.List[int], degree_qualities: typing.List[str]) -> typing.List[subsequence.chords.Chord]: + for root_pc, quality in zip(scale_pcs, degree_qualities): + chords.append(subsequence.chords.Chord(root_pc=root_pc, quality=quality)) - """Build chords for each scale degree. + return chords - Parameters: - scale_pcs: Pitch class for each degree (e.g., from ``scale_pitch_classes(key_pc, mode)``) - degree_qualities: Chord quality for each degree (e.g., ``["major", "minor", ...]``) - Returns: - List of Chord objects, one per scale degree - """ +def _major_key_gravity_sets( + key_name: str, +) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] +]: + """Return diatonic and functional chord sets for a major key.""" - chords: typing.List[subsequence.chords.Chord] = [] + key_pc = validate_key_name(key_name) + scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "ionian") - for root_pc, quality in zip(scale_pcs, degree_qualities): - chords.append(subsequence.chords.Chord(root_pc=root_pc, quality=quality)) + chords = build_diatonic_chords(scale_pcs, subsequence.intervals.IONIAN_QUALITIES) + diatonic: typing.Set[subsequence.chords.Chord] = set(chords) - return chords + # Functional set: I, ii, V, V7. + function_intervals = [0, 2, 7] + function_qualities = ["major", "minor", "major"] + function_chords: typing.Set[subsequence.chords.Chord] = set() -def _major_key_gravity_sets (key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: + for interval, quality in zip(function_intervals, function_qualities): + root_pc = (key_pc + interval) % 12 + function_chords.add(subsequence.chords.Chord(root_pc=root_pc, quality=quality)) - """Return diatonic and functional chord sets for a major key.""" + dominant_7th = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="dominant_7th" + ) + function_chords.add(dominant_7th) + diatonic.add(dominant_7th) - key_pc = validate_key_name(key_name) - scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "ionian") - - chords = build_diatonic_chords(scale_pcs, subsequence.intervals.IONIAN_QUALITIES) - diatonic: typing.Set[subsequence.chords.Chord] = set(chords) - - # Functional set: I, ii, V, V7. - function_intervals = [0, 2, 7] - function_qualities = ["major", "minor", "major"] - - function_chords: typing.Set[subsequence.chords.Chord] = set() - - for interval, quality in zip(function_intervals, function_qualities): - root_pc = (key_pc + interval) % 12 - function_chords.add(subsequence.chords.Chord(root_pc=root_pc, quality=quality)) - - dominant_7th = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="dominant_7th") - function_chords.add(dominant_7th) - diatonic.add(dominant_7th) - - return diatonic, function_chords + return diatonic, function_chords diff --git a/subsequence/chord_graphs/aeolian_minor.py b/subsequence/chord_graphs/aeolian_minor.py index 750c0a0..3bbb60c 100644 --- a/subsequence/chord_graphs/aeolian_minor.py +++ b/subsequence/chord_graphs/aeolian_minor.py @@ -17,142 +17,169 @@ WEIGHT_PLAGAL = 4 -class AeolianMinor (subsequence.chord_graphs.ChordGraph): - - """Natural minor (Aeolian) graph with Phrygian and harmonic minor elements. - - Focuses on the interplay between the natural minor scale and its - common variations: - - - Aeolian (i, iv, v, bVI, bVII) - - Harmonic Minor (V, vii°) for stronger cadences - - Phrygian (bII) for tension - - Suitable for a wide range of minor-key styles. - """ - - def __init__ (self, include_dominant_7th: bool = True) -> None: - - """Configure whether to include dominant seventh chords.""" - - self.include_dominant_7th = include_dominant_7th - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build an Aeolian minor-key graph.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # Natural minor scale: 0, 2, 3, 5, 7, 8, 10 - tonic = subsequence.chords.Chord(root_pc=key_pc, quality="minor") - supertonic_dim = subsequence.chords.Chord(root_pc=(key_pc + 2) % 12, quality="diminished") - mediant = subsequence.chords.Chord(root_pc=(key_pc + 3) % 12, quality="major") - subdominant = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="minor") - natural_dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="minor") - submediant = subsequence.chords.Chord(root_pc=(key_pc + 8) % 12, quality="major") - subtonic = subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major") - - # Harmonic minor additions. - dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="major") - leading_dim = subsequence.chords.Chord(root_pc=(key_pc + 11) % 12, quality="diminished") - - # Phrygian/Neapolitan flat-two. - flat_two = subsequence.chords.Chord(root_pc=(key_pc + 1) % 12, quality="major") - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- Tonic departures --- - graph.add_transition(tonic, subdominant, WEIGHT_PLAGAL) - graph.add_transition(tonic, submediant, WEIGHT_COMMON) - graph.add_transition(tonic, subtonic, WEIGHT_COMMON) - graph.add_transition(tonic, flat_two, WEIGHT_WEAK) - graph.add_transition(tonic, natural_dominant, WEIGHT_COMMON) - - # --- Natural dominant (v) departures --- - graph.add_transition(natural_dominant, submediant, WEIGHT_COMMON) - graph.add_transition(natural_dominant, subdominant, WEIGHT_COMMON) - graph.add_transition(natural_dominant, tonic, WEIGHT_WEAK) - - # --- Minor plagal --- - graph.add_transition(subdominant, tonic, WEIGHT_PLAGAL, label="soft") - graph.add_transition(subdominant, dominant, WEIGHT_MEDIUM, label="open") - graph.add_transition(subdominant, submediant, WEIGHT_WEAK) - - # --- Aeolian cycle: i -> bVI -> bVII -> i --- - graph.add_transition(submediant, subtonic, WEIGHT_MEDIUM) - graph.add_transition(subtonic, tonic, WEIGHT_MEDIUM) - - # --- Andalusian descent: i -> bVII -> bVI -> V --- - graph.add_transition(subtonic, submediant, WEIGHT_COMMON) - graph.add_transition(submediant, dominant, WEIGHT_MEDIUM) - - # --- Phrygian cadence: bII -> i --- - graph.add_transition(flat_two, tonic, WEIGHT_PHRYGIAN) - graph.add_transition(flat_two, dominant, WEIGHT_COMMON) - - # --- Harmonic minor cadence: V -> i --- - graph.add_transition(dominant, tonic, WEIGHT_STRONG, label="strong") - graph.add_transition(dominant, submediant, WEIGHT_DECEPTIVE, label="fakeout") - - # --- Chromatic connectors --- - graph.add_transition(supertonic_dim, dominant, WEIGHT_MEDIUM) - graph.add_transition(leading_dim, tonic, WEIGHT_STRONG) - graph.add_transition(mediant, submediant, WEIGHT_COMMON) - graph.add_transition(mediant, subdominant, WEIGHT_WEAK) - - # Reach the diatonic chords that previously had only outgoing edges, as - # occasional colour: - # i → bIII — the relative major, a staple minor-key move. - # iv → ii° — predominant into the supertonic dim (which then goes to V). - # iv → vii° — predominant into the harmonic-minor leading-tone triad (→ i). - graph.add_transition(tonic, mediant, WEIGHT_WEAK) - graph.add_transition(subdominant, supertonic_dim, WEIGHT_WEAK) - graph.add_transition(subdominant, leading_dim, WEIGHT_WEAK) - - # --- Optional dominant seventh --- - if self.include_dominant_7th: - dominant_7th = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="dominant_7th") - - graph.add_transition(dominant, dominant_7th, WEIGHT_WEAK) - graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG, label="strong") - graph.add_transition(dominant_7th, submediant, WEIGHT_DECEPTIVE, label="fakeout") - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return minor-key diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - diatonic: typing.Set[subsequence.chords.Chord] = set( - subsequence.chord_graphs.build_diatonic_chords( - subsequence.intervals.scale_pitch_classes(key_pc, "aeolian"), - subsequence.intervals.AEOLIAN_QUALITIES - ) - ) - - # Harmonic minor V (major) and bII (Phrygian). - dominant_pc = (key_pc + 7) % 12 - flat_two_pc = (key_pc + 1) % 12 - - diatonic.add(subsequence.chords.Chord(root_pc=dominant_pc, quality="major")) - diatonic.add(subsequence.chords.Chord(root_pc=flat_two_pc, quality="major")) - - # Harmonic-minor leading-tone triad vii° — now reachable in the graph, so - # list it as a diatonic candidate for gravity weighting too. - diatonic.add(subsequence.chords.Chord(root_pc=(key_pc + 11) % 12, quality="diminished")) - - dominant_7th = subsequence.chords.Chord(root_pc=dominant_pc, quality="dominant_7th") - diatonic.add(dominant_7th) - - # Functional set: i, iv, V(/V7), bII. - functional: typing.Set[subsequence.chords.Chord] = set() - - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="minor")) - functional.add(subsequence.chords.Chord(root_pc=dominant_pc, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=flat_two_pc, quality="major")) - functional.add(dominant_7th) - - return diatonic, functional +class AeolianMinor(subsequence.chord_graphs.ChordGraph): + """Natural minor (Aeolian) graph with Phrygian and harmonic minor elements. + + Focuses on the interplay between the natural minor scale and its + common variations: + + - Aeolian (i, iv, v, bVI, bVII) + - Harmonic Minor (V, vii°) for stronger cadences + - Phrygian (bII) for tension + + Suitable for a wide range of minor-key styles. + """ + + def __init__(self, include_dominant_7th: bool = True) -> None: + """Configure whether to include dominant seventh chords.""" + + self.include_dominant_7th = include_dominant_7th + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build an Aeolian minor-key graph.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # Natural minor scale: 0, 2, 3, 5, 7, 8, 10 + tonic = subsequence.chords.Chord(root_pc=key_pc, quality="minor") + supertonic_dim = subsequence.chords.Chord( + root_pc=(key_pc + 2) % 12, quality="diminished" + ) + mediant = subsequence.chords.Chord(root_pc=(key_pc + 3) % 12, quality="major") + subdominant = subsequence.chords.Chord( + root_pc=(key_pc + 5) % 12, quality="minor" + ) + natural_dominant = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="minor" + ) + submediant = subsequence.chords.Chord( + root_pc=(key_pc + 8) % 12, quality="major" + ) + subtonic = subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major") + + # Harmonic minor additions. + dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="major") + leading_dim = subsequence.chords.Chord( + root_pc=(key_pc + 11) % 12, quality="diminished" + ) + + # Phrygian/Neapolitan flat-two. + flat_two = subsequence.chords.Chord(root_pc=(key_pc + 1) % 12, quality="major") + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- Tonic departures --- + graph.add_transition(tonic, subdominant, WEIGHT_PLAGAL) + graph.add_transition(tonic, submediant, WEIGHT_COMMON) + graph.add_transition(tonic, subtonic, WEIGHT_COMMON) + graph.add_transition(tonic, flat_two, WEIGHT_WEAK) + graph.add_transition(tonic, natural_dominant, WEIGHT_COMMON) + + # --- Natural dominant (v) departures --- + graph.add_transition(natural_dominant, submediant, WEIGHT_COMMON) + graph.add_transition(natural_dominant, subdominant, WEIGHT_COMMON) + graph.add_transition(natural_dominant, tonic, WEIGHT_WEAK) + + # --- Minor plagal --- + graph.add_transition(subdominant, tonic, WEIGHT_PLAGAL, label="soft") + graph.add_transition(subdominant, dominant, WEIGHT_MEDIUM, label="open") + graph.add_transition(subdominant, submediant, WEIGHT_WEAK) + + # --- Aeolian cycle: i -> bVI -> bVII -> i --- + graph.add_transition(submediant, subtonic, WEIGHT_MEDIUM) + graph.add_transition(subtonic, tonic, WEIGHT_MEDIUM) + + # --- Andalusian descent: i -> bVII -> bVI -> V --- + graph.add_transition(subtonic, submediant, WEIGHT_COMMON) + graph.add_transition(submediant, dominant, WEIGHT_MEDIUM) + + # --- Phrygian cadence: bII -> i --- + graph.add_transition(flat_two, tonic, WEIGHT_PHRYGIAN) + graph.add_transition(flat_two, dominant, WEIGHT_COMMON) + + # --- Harmonic minor cadence: V -> i --- + graph.add_transition(dominant, tonic, WEIGHT_STRONG, label="strong") + graph.add_transition(dominant, submediant, WEIGHT_DECEPTIVE, label="fakeout") + + # --- Chromatic connectors --- + graph.add_transition(supertonic_dim, dominant, WEIGHT_MEDIUM) + graph.add_transition(leading_dim, tonic, WEIGHT_STRONG) + graph.add_transition(mediant, submediant, WEIGHT_COMMON) + graph.add_transition(mediant, subdominant, WEIGHT_WEAK) + + # Reach the diatonic chords that previously had only outgoing edges, as + # occasional colour: + # i → bIII — the relative major, a staple minor-key move. + # iv → ii° — predominant into the supertonic dim (which then goes to V). + # iv → vii° — predominant into the harmonic-minor leading-tone triad (→ i). + graph.add_transition(tonic, mediant, WEIGHT_WEAK) + graph.add_transition(subdominant, supertonic_dim, WEIGHT_WEAK) + graph.add_transition(subdominant, leading_dim, WEIGHT_WEAK) + + # --- Optional dominant seventh --- + if self.include_dominant_7th: + dominant_7th = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="dominant_7th" + ) + + graph.add_transition(dominant, dominant_7th, WEIGHT_WEAK) + graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG, label="strong") + graph.add_transition( + dominant_7th, submediant, WEIGHT_DECEPTIVE, label="fakeout" + ) + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return minor-key diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + diatonic: typing.Set[subsequence.chords.Chord] = set( + subsequence.chord_graphs.build_diatonic_chords( + subsequence.intervals.scale_pitch_classes(key_pc, "aeolian"), + subsequence.intervals.AEOLIAN_QUALITIES, + ) + ) + + # Harmonic minor V (major) and bII (Phrygian). + dominant_pc = (key_pc + 7) % 12 + flat_two_pc = (key_pc + 1) % 12 + + diatonic.add(subsequence.chords.Chord(root_pc=dominant_pc, quality="major")) + diatonic.add(subsequence.chords.Chord(root_pc=flat_two_pc, quality="major")) + + # Harmonic-minor leading-tone triad vii° — now reachable in the graph, so + # list it as a diatonic candidate for gravity weighting too. + diatonic.add( + subsequence.chords.Chord(root_pc=(key_pc + 11) % 12, quality="diminished") + ) + + dominant_7th = subsequence.chords.Chord( + root_pc=dominant_pc, quality="dominant_7th" + ) + diatonic.add(dominant_7th) + + # Functional set: i, iv, V(/V7), bII. + functional: typing.Set[subsequence.chords.Chord] = set() + + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="minor") + ) + functional.add(subsequence.chords.Chord(root_pc=dominant_pc, quality="major")) + functional.add(subsequence.chords.Chord(root_pc=flat_two_pc, quality="major")) + functional.add(dominant_7th) + + return diatonic, functional diff --git a/subsequence/chord_graphs/chromatic_mediant.py b/subsequence/chord_graphs/chromatic_mediant.py index 328d6f9..0a30177 100644 --- a/subsequence/chord_graphs/chromatic_mediant.py +++ b/subsequence/chord_graphs/chromatic_mediant.py @@ -13,101 +13,127 @@ WEIGHT_MEDIANT = 5 -class ChromaticMediant (subsequence.chord_graphs.ChordGraph): - - """Chromatic third-related harmony - roots move by major or minor thirds. - - Many transitions connect chords whose roots are a major or minor third - apart (the characteristic motion); others are chromatic, fourth-, or - fifth-related, creating dramatic, colorful shifts that sound both surprising - and connected through shared common tones. No dominant-tonic functional - motion is used; the one subdominant-tonic edge is the minor-plagal - iv → I close from the subdominant-minor connector. - - Good for cinematic, ambient, soundtrack, and experimental music. - """ - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a chromatic mediant graph with third-related root movement.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # Core chords - major and minor triads at third-related intervals. - tonic = subsequence.chords.Chord(root_pc=key_pc, quality="major") - flat_mediant = subsequence.chords.Chord(root_pc=(key_pc + 3) % 12, quality="major") - mediant = subsequence.chords.Chord(root_pc=(key_pc + 4) % 12, quality="major") - flat_submediant = subsequence.chords.Chord(root_pc=(key_pc + 8) % 12, quality="major") - submediant = subsequence.chords.Chord(root_pc=(key_pc + 9) % 12, quality="major") - - # Minor variants for color contrast. - tonic_minor = subsequence.chords.Chord(root_pc=key_pc, quality="minor") - subdominant_minor = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="minor") - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- Tonic departures (by thirds) --- - graph.add_transition(tonic, flat_mediant, WEIGHT_MEDIANT) - graph.add_transition(tonic, mediant, WEIGHT_MEDIANT) - graph.add_transition(tonic, flat_submediant, WEIGHT_COMMON) - graph.add_transition(tonic, submediant, WEIGHT_COMMON) - graph.add_transition(tonic, tonic_minor, WEIGHT_WEAK) - - # --- Flat mediant (bIII) departures --- - graph.add_transition(flat_mediant, tonic, WEIGHT_MEDIANT) - graph.add_transition(flat_mediant, flat_submediant, WEIGHT_COMMON) - graph.add_transition(flat_mediant, submediant, WEIGHT_MEDIUM) - - # --- Mediant (III) departures --- - graph.add_transition(mediant, tonic, WEIGHT_MEDIANT) - graph.add_transition(mediant, flat_submediant, WEIGHT_MEDIUM) - graph.add_transition(mediant, submediant, WEIGHT_COMMON) - graph.add_transition(mediant, flat_mediant, WEIGHT_WEAK) - - # --- Flat submediant (bVI) departures --- - graph.add_transition(flat_submediant, tonic, WEIGHT_MEDIANT) - graph.add_transition(flat_submediant, mediant, WEIGHT_COMMON) - graph.add_transition(flat_submediant, flat_mediant, WEIGHT_COMMON) - graph.add_transition(flat_submediant, subdominant_minor, WEIGHT_WEAK) - - # --- Submediant (VI) departures --- - graph.add_transition(submediant, tonic, WEIGHT_MEDIUM) - graph.add_transition(submediant, mediant, WEIGHT_COMMON) - graph.add_transition(submediant, flat_mediant, WEIGHT_COMMON) - graph.add_transition(submediant, flat_submediant, WEIGHT_WEAK) - - # --- Tonic minor departures --- - graph.add_transition(tonic_minor, flat_mediant, WEIGHT_MEDIANT) - graph.add_transition(tonic_minor, flat_submediant, WEIGHT_COMMON) - graph.add_transition(tonic_minor, tonic, WEIGHT_MEDIUM) - - # --- Subdominant minor (iv) connector --- - graph.add_transition(subdominant_minor, tonic, WEIGHT_MEDIUM) - graph.add_transition(subdominant_minor, flat_submediant, WEIGHT_COMMON) - graph.add_transition(subdominant_minor, flat_mediant, WEIGHT_WEAK) - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return chromatic mediant diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # All chords in the graph form the "diatonic" set for gravity. - diatonic: typing.Set[subsequence.chords.Chord] = set() - - for interval in [0, 3, 4, 8, 9]: - diatonic.add(subsequence.chords.Chord(root_pc=(key_pc + interval) % 12, quality="major")) - - diatonic.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) - diatonic.add(subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="minor")) - - # Functional set: I, bIII, bVI (strongest mediant poles). - functional: typing.Set[subsequence.chords.Chord] = set() - - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 3) % 12, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 8) % 12, quality="major")) - - return diatonic, functional +class ChromaticMediant(subsequence.chord_graphs.ChordGraph): + """Chromatic third-related harmony - roots move by major or minor thirds. + + Many transitions connect chords whose roots are a major or minor third + apart (the characteristic motion); others are chromatic, fourth-, or + fifth-related, creating dramatic, colorful shifts that sound both surprising + and connected through shared common tones. No dominant-tonic functional + motion is used; the one subdominant-tonic edge is the minor-plagal + iv → I close from the subdominant-minor connector. + + Good for cinematic, ambient, soundtrack, and experimental music. + """ + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build a chromatic mediant graph with third-related root movement.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # Core chords - major and minor triads at third-related intervals. + tonic = subsequence.chords.Chord(root_pc=key_pc, quality="major") + flat_mediant = subsequence.chords.Chord( + root_pc=(key_pc + 3) % 12, quality="major" + ) + mediant = subsequence.chords.Chord(root_pc=(key_pc + 4) % 12, quality="major") + flat_submediant = subsequence.chords.Chord( + root_pc=(key_pc + 8) % 12, quality="major" + ) + submediant = subsequence.chords.Chord( + root_pc=(key_pc + 9) % 12, quality="major" + ) + + # Minor variants for color contrast. + tonic_minor = subsequence.chords.Chord(root_pc=key_pc, quality="minor") + subdominant_minor = subsequence.chords.Chord( + root_pc=(key_pc + 5) % 12, quality="minor" + ) + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- Tonic departures (by thirds) --- + graph.add_transition(tonic, flat_mediant, WEIGHT_MEDIANT) + graph.add_transition(tonic, mediant, WEIGHT_MEDIANT) + graph.add_transition(tonic, flat_submediant, WEIGHT_COMMON) + graph.add_transition(tonic, submediant, WEIGHT_COMMON) + graph.add_transition(tonic, tonic_minor, WEIGHT_WEAK) + + # --- Flat mediant (bIII) departures --- + graph.add_transition(flat_mediant, tonic, WEIGHT_MEDIANT) + graph.add_transition(flat_mediant, flat_submediant, WEIGHT_COMMON) + graph.add_transition(flat_mediant, submediant, WEIGHT_MEDIUM) + + # --- Mediant (III) departures --- + graph.add_transition(mediant, tonic, WEIGHT_MEDIANT) + graph.add_transition(mediant, flat_submediant, WEIGHT_MEDIUM) + graph.add_transition(mediant, submediant, WEIGHT_COMMON) + graph.add_transition(mediant, flat_mediant, WEIGHT_WEAK) + + # --- Flat submediant (bVI) departures --- + graph.add_transition(flat_submediant, tonic, WEIGHT_MEDIANT) + graph.add_transition(flat_submediant, mediant, WEIGHT_COMMON) + graph.add_transition(flat_submediant, flat_mediant, WEIGHT_COMMON) + graph.add_transition(flat_submediant, subdominant_minor, WEIGHT_WEAK) + + # --- Submediant (VI) departures --- + graph.add_transition(submediant, tonic, WEIGHT_MEDIUM) + graph.add_transition(submediant, mediant, WEIGHT_COMMON) + graph.add_transition(submediant, flat_mediant, WEIGHT_COMMON) + graph.add_transition(submediant, flat_submediant, WEIGHT_WEAK) + + # --- Tonic minor departures --- + graph.add_transition(tonic_minor, flat_mediant, WEIGHT_MEDIANT) + graph.add_transition(tonic_minor, flat_submediant, WEIGHT_COMMON) + graph.add_transition(tonic_minor, tonic, WEIGHT_MEDIUM) + + # --- Subdominant minor (iv) connector --- + graph.add_transition(subdominant_minor, tonic, WEIGHT_MEDIUM) + graph.add_transition(subdominant_minor, flat_submediant, WEIGHT_COMMON) + graph.add_transition(subdominant_minor, flat_mediant, WEIGHT_WEAK) + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return chromatic mediant diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # All chords in the graph form the "diatonic" set for gravity. + diatonic: typing.Set[subsequence.chords.Chord] = set() + + for interval in [0, 3, 4, 8, 9]: + diatonic.add( + subsequence.chords.Chord( + root_pc=(key_pc + interval) % 12, quality="major" + ) + ) + + diatonic.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) + diatonic.add( + subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="minor") + ) + + # Functional set: I, bIII, bVI (strongest mediant poles). + functional: typing.Set[subsequence.chords.Chord] = set() + + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="major")) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 3) % 12, quality="major") + ) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 8) % 12, quality="major") + ) + + return diatonic, functional diff --git a/subsequence/chord_graphs/diminished.py b/subsequence/chord_graphs/diminished.py index 6888f67..2aade00 100644 --- a/subsequence/chord_graphs/diminished.py +++ b/subsequence/chord_graphs/diminished.py @@ -11,87 +11,112 @@ WEIGHT_COMMON = subsequence.chord_graphs.WEIGHT_COMMON -class Diminished (subsequence.chord_graphs.ChordGraph): - - """Diminished chord graph with minor-third symmetry and chromatic escapes. - - Two chord types interlock: - - - 4 diminished triads at roots 0, 3, 6, 9 (the symmetry backbone — the - minor-third cycle that also underpins the half-whole diminished scale) - - 4 dominant 7th chords at roots 1, 4, 7, 10 (escape chords) - - Diminished chords connect to each other by minor thirds (the defining - rotation). Each dominant 7th sits a half step ABOVE its diminished - chord as a chromatic tension point — deliberately outside the - half-whole scale on the key root, which is what gives the escape its - lift before the half-step-down resolve. The result is angular, - disorienting, and cyclical - useful for dark techno, industrial, and - experimental electronic music. - """ - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build an octatonic graph with diminished and dominant 7th chords.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # Four diminished triads, each a minor third apart. - dim_roots = [(key_pc + i) % 12 for i in [0, 3, 6, 9]] - dim_chords = [subsequence.chords.Chord(root_pc=r, quality="diminished") for r in dim_roots] - - # Four dominant 7th chords, each a half step above a diminished chord. - dom_roots = [(key_pc + i) % 12 for i in [1, 4, 7, 10]] - dom_chords = [subsequence.chords.Chord(root_pc=r, quality="dominant_7th") for r in dom_roots] - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- Diminished ↔ diminished (minor third rotation) --- - for i, source in enumerate(dim_chords): - for j, target in enumerate(dim_chords): - if i != j: - graph.add_transition(source, target, WEIGHT_SYMMETRY) - - # --- Diminished → dominant 7th (half step up = escape) --- - for i in range(4): - graph.add_transition(dim_chords[i], dom_chords[i], WEIGHT_ESCAPE) - - # --- Dominant 7th → diminished (half step down = resolve) --- - for i in range(4): - graph.add_transition(dom_chords[i], dim_chords[i], WEIGHT_RESOLVE) - - # --- Dominant 7th ↔ dominant 7th (minor third rotation) --- - for i, source in enumerate(dom_chords): - for j, target in enumerate(dom_chords): - if i != j: - graph.add_transition(source, target, WEIGHT_COMMON) - - tonic = dim_chords[0] - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return octatonic diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - diatonic: typing.Set[subsequence.chords.Chord] = set() - - # Gravity treats the full 8-chord palette as "home": the dominant - # escapes are included by design even though they sit outside the - # half-whole scale on the key root (they are the graph's tension - # vocabulary, not foreign territory to be damped). - for i in [0, 3, 6, 9]: - diatonic.add(subsequence.chords.Chord(root_pc=(key_pc + i) % 12, quality="diminished")) - - for i in [1, 4, 7, 10]: - diatonic.add(subsequence.chords.Chord(root_pc=(key_pc + i) % 12, quality="dominant_7th")) - - # Functional: the 4 diminished chords (symmetry backbone). - functional: typing.Set[subsequence.chords.Chord] = set() - - for i in [0, 3, 6, 9]: - functional.add(subsequence.chords.Chord(root_pc=(key_pc + i) % 12, quality="diminished")) - - return diatonic, functional +class Diminished(subsequence.chord_graphs.ChordGraph): + """Diminished chord graph with minor-third symmetry and chromatic escapes. + + Two chord types interlock: + + - 4 diminished triads at roots 0, 3, 6, 9 (the symmetry backbone — the + minor-third cycle that also underpins the half-whole diminished scale) + - 4 dominant 7th chords at roots 1, 4, 7, 10 (escape chords) + + Diminished chords connect to each other by minor thirds (the defining + rotation). Each dominant 7th sits a half step ABOVE its diminished + chord as a chromatic tension point — deliberately outside the + half-whole scale on the key root, which is what gives the escape its + lift before the half-step-down resolve. The result is angular, + disorienting, and cyclical - useful for dark techno, industrial, and + experimental electronic music. + """ + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build an octatonic graph with diminished and dominant 7th chords.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # Four diminished triads, each a minor third apart. + dim_roots = [(key_pc + i) % 12 for i in [0, 3, 6, 9]] + dim_chords = [ + subsequence.chords.Chord(root_pc=r, quality="diminished") for r in dim_roots + ] + + # Four dominant 7th chords, each a half step above a diminished chord. + dom_roots = [(key_pc + i) % 12 for i in [1, 4, 7, 10]] + dom_chords = [ + subsequence.chords.Chord(root_pc=r, quality="dominant_7th") + for r in dom_roots + ] + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- Diminished ↔ diminished (minor third rotation) --- + for i, source in enumerate(dim_chords): + for j, target in enumerate(dim_chords): + if i != j: + graph.add_transition(source, target, WEIGHT_SYMMETRY) + + # --- Diminished → dominant 7th (half step up = escape) --- + for i in range(4): + graph.add_transition(dim_chords[i], dom_chords[i], WEIGHT_ESCAPE) + + # --- Dominant 7th → diminished (half step down = resolve) --- + for i in range(4): + graph.add_transition(dom_chords[i], dim_chords[i], WEIGHT_RESOLVE) + + # --- Dominant 7th ↔ dominant 7th (minor third rotation) --- + for i, source in enumerate(dom_chords): + for j, target in enumerate(dom_chords): + if i != j: + graph.add_transition(source, target, WEIGHT_COMMON) + + tonic = dim_chords[0] + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return octatonic diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + diatonic: typing.Set[subsequence.chords.Chord] = set() + + # Gravity treats the full 8-chord palette as "home": the dominant + # escapes are included by design even though they sit outside the + # half-whole scale on the key root (they are the graph's tension + # vocabulary, not foreign territory to be damped). + for i in [0, 3, 6, 9]: + diatonic.add( + subsequence.chords.Chord( + root_pc=(key_pc + i) % 12, quality="diminished" + ) + ) + + for i in [1, 4, 7, 10]: + diatonic.add( + subsequence.chords.Chord( + root_pc=(key_pc + i) % 12, quality="dominant_7th" + ) + ) + + # Functional: the 4 diminished chords (symmetry backbone). + functional: typing.Set[subsequence.chords.Chord] = set() + + for i in [0, 3, 6, 9]: + functional.add( + subsequence.chords.Chord( + root_pc=(key_pc + i) % 12, quality="diminished" + ) + ) + + return diatonic, functional diff --git a/subsequence/chord_graphs/dorian_minor.py b/subsequence/chord_graphs/dorian_minor.py index 21ae22b..a1cd159 100644 --- a/subsequence/chord_graphs/dorian_minor.py +++ b/subsequence/chord_graphs/dorian_minor.py @@ -15,113 +15,138 @@ WEIGHT_DORIAN = 5 -class DorianMinor (subsequence.chord_graphs.ChordGraph): - - """Dorian-flavored minor harmony with a natural sixth degree. - - The natural 6th (instead of the aeolian b6) gives minor harmony a - warmer, more hopeful quality. The IV chord (major subdominant in a - minor key) is the defining Dorian sound. No harmonic-minor dominant V - is used - the graph stays purely modal. - - Good for lo-fi, neo-soul, chill electronic, jazz, and funk. - """ - - def __init__ (self, include_dominant_7th: bool = False) -> None: - - """Configure whether to include dominant seventh chords. - - Defaults to False because Dorian is modal - dominant 7ths - introduce functional tonal pull that weakens the modal feel. - """ - - self.include_dominant_7th = include_dominant_7th - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a Dorian minor-key graph for the given key.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # Dorian scale: 0, 2, 3, 5, 7, 9, 10 - # Degrees: i, ii, bIII, IV, v, vi°, bVII - tonic = subsequence.chords.Chord(root_pc=key_pc, quality="minor") - supertonic = subsequence.chords.Chord(root_pc=(key_pc + 2) % 12, quality="minor") - mediant = subsequence.chords.Chord(root_pc=(key_pc + 3) % 12, quality="major") - subdominant = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="major") - natural_dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="minor") - submediant_dim = subsequence.chords.Chord(root_pc=(key_pc + 9) % 12, quality="diminished") - subtonic = subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major") - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- The Dorian signature: i ↔ IV --- - graph.add_transition(tonic, subdominant, WEIGHT_DORIAN) - graph.add_transition(subdominant, tonic, WEIGHT_DORIAN) - - # --- Tonic departures --- - graph.add_transition(tonic, supertonic, WEIGHT_COMMON) - graph.add_transition(tonic, subtonic, WEIGHT_COMMON) - graph.add_transition(tonic, natural_dominant, WEIGHT_WEAK) - graph.add_transition(tonic, mediant, WEIGHT_WEAK) - - # --- Supertonic departures --- - graph.add_transition(supertonic, natural_dominant, WEIGHT_MEDIUM) - graph.add_transition(supertonic, subdominant, WEIGHT_COMMON) - graph.add_transition(supertonic, tonic, WEIGHT_WEAK) - - # --- Mediant (bIII) departures --- - graph.add_transition(mediant, subdominant, WEIGHT_COMMON) - graph.add_transition(mediant, subtonic, WEIGHT_COMMON) - graph.add_transition(mediant, tonic, WEIGHT_WEAK) - - # --- Subdominant departures (beyond → tonic) --- - graph.add_transition(subdominant, natural_dominant, WEIGHT_COMMON) - graph.add_transition(subdominant, subtonic, WEIGHT_WEAK) - graph.add_transition(subdominant, submediant_dim, WEIGHT_WEAK) # IV → vi° (passing dim; vi° resolves to v / i) - - # --- Natural dominant (v) departures --- - graph.add_transition(natural_dominant, tonic, WEIGHT_MEDIUM) - graph.add_transition(natural_dominant, subdominant, WEIGHT_COMMON) - graph.add_transition(natural_dominant, mediant, WEIGHT_WEAK) - - # --- Subtonic (bVII) departures --- - graph.add_transition(subtonic, tonic, WEIGHT_MEDIUM) - graph.add_transition(subtonic, subdominant, WEIGHT_COMMON) - graph.add_transition(subtonic, supertonic, WEIGHT_WEAK) - - # --- Submediant dim (vi°) connectors --- - graph.add_transition(submediant_dim, natural_dominant, WEIGHT_MEDIUM) - graph.add_transition(submediant_dim, tonic, WEIGHT_WEAK) - - if self.include_dominant_7th: - # Harmonic minor dominant - optional tonal color. - dominant_7th = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="dominant_7th") - - graph.add_transition(natural_dominant, dominant_7th, WEIGHT_WEAK) - graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG) - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return Dorian diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - diatonic: typing.Set[subsequence.chords.Chord] = set( - subsequence.chord_graphs.build_diatonic_chords( - subsequence.intervals.scale_pitch_classes(key_pc, "dorian"), - subsequence.intervals.DORIAN_QUALITIES - ) - ) - - # Functional set: i, IV (Dorian signature), v, bVII. - functional: typing.Set[subsequence.chords.Chord] = set() - - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="minor")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major")) - - return diatonic, functional +class DorianMinor(subsequence.chord_graphs.ChordGraph): + """Dorian-flavored minor harmony with a natural sixth degree. + + The natural 6th (instead of the aeolian b6) gives minor harmony a + warmer, more hopeful quality. The IV chord (major subdominant in a + minor key) is the defining Dorian sound. No harmonic-minor dominant V + is used - the graph stays purely modal. + + Good for lo-fi, neo-soul, chill electronic, jazz, and funk. + """ + + def __init__(self, include_dominant_7th: bool = False) -> None: + """Configure whether to include dominant seventh chords. + + Defaults to False because Dorian is modal - dominant 7ths + introduce functional tonal pull that weakens the modal feel. + """ + + self.include_dominant_7th = include_dominant_7th + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build a Dorian minor-key graph for the given key.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # Dorian scale: 0, 2, 3, 5, 7, 9, 10 + # Degrees: i, ii, bIII, IV, v, vi°, bVII + tonic = subsequence.chords.Chord(root_pc=key_pc, quality="minor") + supertonic = subsequence.chords.Chord( + root_pc=(key_pc + 2) % 12, quality="minor" + ) + mediant = subsequence.chords.Chord(root_pc=(key_pc + 3) % 12, quality="major") + subdominant = subsequence.chords.Chord( + root_pc=(key_pc + 5) % 12, quality="major" + ) + natural_dominant = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="minor" + ) + submediant_dim = subsequence.chords.Chord( + root_pc=(key_pc + 9) % 12, quality="diminished" + ) + subtonic = subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major") + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- The Dorian signature: i ↔ IV --- + graph.add_transition(tonic, subdominant, WEIGHT_DORIAN) + graph.add_transition(subdominant, tonic, WEIGHT_DORIAN) + + # --- Tonic departures --- + graph.add_transition(tonic, supertonic, WEIGHT_COMMON) + graph.add_transition(tonic, subtonic, WEIGHT_COMMON) + graph.add_transition(tonic, natural_dominant, WEIGHT_WEAK) + graph.add_transition(tonic, mediant, WEIGHT_WEAK) + + # --- Supertonic departures --- + graph.add_transition(supertonic, natural_dominant, WEIGHT_MEDIUM) + graph.add_transition(supertonic, subdominant, WEIGHT_COMMON) + graph.add_transition(supertonic, tonic, WEIGHT_WEAK) + + # --- Mediant (bIII) departures --- + graph.add_transition(mediant, subdominant, WEIGHT_COMMON) + graph.add_transition(mediant, subtonic, WEIGHT_COMMON) + graph.add_transition(mediant, tonic, WEIGHT_WEAK) + + # --- Subdominant departures (beyond → tonic) --- + graph.add_transition(subdominant, natural_dominant, WEIGHT_COMMON) + graph.add_transition(subdominant, subtonic, WEIGHT_WEAK) + graph.add_transition( + subdominant, submediant_dim, WEIGHT_WEAK + ) # IV → vi° (passing dim; vi° resolves to v / i) + + # --- Natural dominant (v) departures --- + graph.add_transition(natural_dominant, tonic, WEIGHT_MEDIUM) + graph.add_transition(natural_dominant, subdominant, WEIGHT_COMMON) + graph.add_transition(natural_dominant, mediant, WEIGHT_WEAK) + + # --- Subtonic (bVII) departures --- + graph.add_transition(subtonic, tonic, WEIGHT_MEDIUM) + graph.add_transition(subtonic, subdominant, WEIGHT_COMMON) + graph.add_transition(subtonic, supertonic, WEIGHT_WEAK) + + # --- Submediant dim (vi°) connectors --- + graph.add_transition(submediant_dim, natural_dominant, WEIGHT_MEDIUM) + graph.add_transition(submediant_dim, tonic, WEIGHT_WEAK) + + if self.include_dominant_7th: + # Harmonic minor dominant - optional tonal color. + dominant_7th = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="dominant_7th" + ) + + graph.add_transition(natural_dominant, dominant_7th, WEIGHT_WEAK) + graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG) + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return Dorian diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + diatonic: typing.Set[subsequence.chords.Chord] = set( + subsequence.chord_graphs.build_diatonic_chords( + subsequence.intervals.scale_pitch_classes(key_pc, "dorian"), + subsequence.intervals.DORIAN_QUALITIES, + ) + ) + + # Functional set: i, IV (Dorian signature), v, bVII. + functional: typing.Set[subsequence.chords.Chord] = set() + + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="major") + ) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="minor") + ) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major") + ) + + return diatonic, functional diff --git a/subsequence/chord_graphs/functional_major.py b/subsequence/chord_graphs/functional_major.py index 2d97946..d2061e1 100644 --- a/subsequence/chord_graphs/functional_major.py +++ b/subsequence/chord_graphs/functional_major.py @@ -12,91 +12,108 @@ WEIGHT_WEAK = subsequence.chord_graphs.WEIGHT_WEAK -class DiatonicMajor (subsequence.chord_graphs.ChordGraph): - - """Single-key functional major harmony graph.""" - - def __init__ (self, include_dominant_7th: bool = True) -> None: - - """Configure whether to include dominant seventh chords.""" - - self.include_dominant_7th = include_dominant_7th - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build the graph for a given major key.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - chords = subsequence.chord_graphs.build_diatonic_chords( - subsequence.intervals.scale_pitch_classes(key_pc, "ionian"), - subsequence.intervals.IONIAN_QUALITIES - ) - - tonic = chords[0] - supertonic = chords[1] - mediant = chords[2] - subdominant = chords[3] - dominant = chords[4] - submediant = chords[5] - leading = chords[6] - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - graph.add_transition(tonic, subdominant, WEIGHT_COMMON) - graph.add_transition(tonic, dominant, WEIGHT_COMMON) - graph.add_transition(tonic, submediant, WEIGHT_COMMON) - graph.add_transition(tonic, supertonic, WEIGHT_WEAK) - - graph.add_transition(supertonic, dominant, WEIGHT_STRONG) - - graph.add_transition(mediant, submediant, WEIGHT_COMMON) - graph.add_transition(mediant, subdominant, WEIGHT_WEAK) - - graph.add_transition(subdominant, dominant, WEIGHT_STRONG, label="open") - graph.add_transition(subdominant, supertonic, WEIGHT_COMMON) - # The plagal close (IV → I, the "Amen") — outside strict functional - # motion, so weak; it also makes the "soft" cadence formula walkable. - graph.add_transition(subdominant, tonic, WEIGHT_WEAK, label="soft") - - graph.add_transition(dominant, tonic, WEIGHT_STRONG, label="strong") - graph.add_transition(dominant, submediant, WEIGHT_DECEPTIVE, label="fakeout") - - graph.add_transition(submediant, supertonic, WEIGHT_COMMON) - graph.add_transition(submediant, subdominant, WEIGHT_COMMON) - graph.add_transition(submediant, dominant, WEIGHT_WEAK) - - graph.add_transition(leading, tonic, WEIGHT_STRONG) - - # Make the two remaining diatonic triads reachable as occasional colour — - # they were defined with resolving edges but nothing led into them: - # vi → iii — the descending-thirds sequence (I–V–vi–iii–IV). - # IV → vii° — predominant into the leading-tone triad (a dominant - # substitute), which then resolves via its vii° → I edge. - graph.add_transition(submediant, mediant, WEIGHT_WEAK) - graph.add_transition(subdominant, leading, WEIGHT_WEAK) - - if self.include_dominant_7th: - # Decision path: optional dominant seventh color for stronger cadences. - dominant_7th = subsequence.chords.Chord(root_pc=dominant.root_pc, quality="dominant_7th") - - graph.add_transition(dominant, dominant_7th, WEIGHT_WEAK) - graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG, label="strong") - graph.add_transition(dominant_7th, submediant, WEIGHT_DECEPTIVE, label="fakeout") - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return major-key diatonic and functional chord sets.""" - - return subsequence.chord_graphs._major_key_gravity_sets(key_name) - - -def build_graph (key_name: str, include_dominant_7th: bool = True, minor_turnaround_weight: float = 0.0) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a functional major-key graph and return it with the tonic chord.""" - - graph_obj = DiatonicMajor(include_dominant_7th=include_dominant_7th) - - return graph_obj.build(key_name) +class DiatonicMajor(subsequence.chord_graphs.ChordGraph): + """Single-key functional major harmony graph.""" + + def __init__(self, include_dominant_7th: bool = True) -> None: + """Configure whether to include dominant seventh chords.""" + + self.include_dominant_7th = include_dominant_7th + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build the graph for a given major key.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + chords = subsequence.chord_graphs.build_diatonic_chords( + subsequence.intervals.scale_pitch_classes(key_pc, "ionian"), + subsequence.intervals.IONIAN_QUALITIES, + ) + + tonic = chords[0] + supertonic = chords[1] + mediant = chords[2] + subdominant = chords[3] + dominant = chords[4] + submediant = chords[5] + leading = chords[6] + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + graph.add_transition(tonic, subdominant, WEIGHT_COMMON) + graph.add_transition(tonic, dominant, WEIGHT_COMMON) + graph.add_transition(tonic, submediant, WEIGHT_COMMON) + graph.add_transition(tonic, supertonic, WEIGHT_WEAK) + + graph.add_transition(supertonic, dominant, WEIGHT_STRONG) + + graph.add_transition(mediant, submediant, WEIGHT_COMMON) + graph.add_transition(mediant, subdominant, WEIGHT_WEAK) + + graph.add_transition(subdominant, dominant, WEIGHT_STRONG, label="open") + graph.add_transition(subdominant, supertonic, WEIGHT_COMMON) + # The plagal close (IV → I, the "Amen") — outside strict functional + # motion, so weak; it also makes the "soft" cadence formula walkable. + graph.add_transition(subdominant, tonic, WEIGHT_WEAK, label="soft") + + graph.add_transition(dominant, tonic, WEIGHT_STRONG, label="strong") + graph.add_transition(dominant, submediant, WEIGHT_DECEPTIVE, label="fakeout") + + graph.add_transition(submediant, supertonic, WEIGHT_COMMON) + graph.add_transition(submediant, subdominant, WEIGHT_COMMON) + graph.add_transition(submediant, dominant, WEIGHT_WEAK) + + graph.add_transition(leading, tonic, WEIGHT_STRONG) + + # Make the two remaining diatonic triads reachable as occasional colour — + # they were defined with resolving edges but nothing led into them: + # vi → iii — the descending-thirds sequence (I–V–vi–iii–IV). + # IV → vii° — predominant into the leading-tone triad (a dominant + # substitute), which then resolves via its vii° → I edge. + graph.add_transition(submediant, mediant, WEIGHT_WEAK) + graph.add_transition(subdominant, leading, WEIGHT_WEAK) + + if self.include_dominant_7th: + # Decision path: optional dominant seventh color for stronger cadences. + dominant_7th = subsequence.chords.Chord( + root_pc=dominant.root_pc, quality="dominant_7th" + ) + + graph.add_transition(dominant, dominant_7th, WEIGHT_WEAK) + graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG, label="strong") + graph.add_transition( + dominant_7th, submediant, WEIGHT_DECEPTIVE, label="fakeout" + ) + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return major-key diatonic and functional chord sets.""" + + return subsequence.chord_graphs._major_key_gravity_sets(key_name) + + +def build_graph( + key_name: str, + include_dominant_7th: bool = True, + minor_turnaround_weight: float = 0.0, +) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, +]: + """Build a functional major-key graph and return it with the tonic chord.""" + + graph_obj = DiatonicMajor(include_dominant_7th=include_dominant_7th) + + return graph_obj.build(key_name) diff --git a/subsequence/chord_graphs/hooktheory_major.py b/subsequence/chord_graphs/hooktheory_major.py index 06c8472..8a4d9c0 100644 --- a/subsequence/chord_graphs/hooktheory_major.py +++ b/subsequence/chord_graphs/hooktheory_major.py @@ -16,73 +16,88 @@ # strongly to I and deceptively to vi; IV→I and IV→V; vi→IV/V/ii; ii→V; iii is # rare and usually heads to vi or IV; vii°→I. _DEGREE_TRANSITIONS: typing.Dict[int, typing.List[typing.Tuple[int, int]]] = { - 1: [(5, 8), (4, 8), (6, 7), (2, 4), (3, 2), (7, 1)], - 2: [(5, 9), (4, 4), (1, 3), (6, 2), (7, 1), (3, 1)], - 3: [(6, 7), (4, 7), (1, 3), (2, 2), (5, 2)], - 4: [(1, 8), (5, 7), (6, 4), (2, 4), (3, 1)], - 5: [(1, 9), (6, 7), (4, 6), (2, 2), (3, 1)], - 6: [(4, 7), (5, 6), (2, 5), (1, 4), (3, 2)], - 7: [(1, 9), (6, 3), (5, 2), (3, 1)], + 1: [(5, 8), (4, 8), (6, 7), (2, 4), (3, 2), (7, 1)], + 2: [(5, 9), (4, 4), (1, 3), (6, 2), (7, 1), (3, 1)], + 3: [(6, 7), (4, 7), (1, 3), (2, 2), (5, 2)], + 4: [(1, 8), (5, 7), (6, 4), (2, 4), (3, 1)], + 5: [(1, 9), (6, 7), (4, 6), (2, 2), (3, 1)], + 6: [(4, 7), (5, 6), (2, 5), (1, 4), (3, 2)], + 7: [(1, 9), (6, 3), (5, 2), (3, 1)], } -class HooktheoryMajor (subsequence.chord_graphs.ChordGraph): - - """Major-key graph weighted by pop/rock corpus tendencies. - - The same seven diatonic triads as :class:`DiatonicMajor`, but the - transition weights follow real songwriting frequencies (Hooktheory-informed) - rather than textbook function — so the walk gravitates to the four-chord - loops that dominate popular music (I–V–vi–IV and its rotations) and uses - iii sparingly. Optionally colours the dominant with a seventh. - """ - - def __init__ (self, include_dominant_7th: bool = True) -> None: - - """Configure whether to add a dominant-seventh colour on V.""" - - self.include_dominant_7th = include_dominant_7th - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build the corpus-weighted graph for a given major key.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - chords = subsequence.chord_graphs.build_diatonic_chords( - subsequence.intervals.scale_pitch_classes(key_pc, "ionian"), - subsequence.intervals.IONIAN_QUALITIES - ) - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - for source_degree, targets in _DEGREE_TRANSITIONS.items(): - source = chords[source_degree - 1] - for target_degree, weight in targets: - graph.add_transition(source, chords[target_degree - 1], weight) - - if self.include_dominant_7th: - # A V7 colour the corpus reaches from V and that resolves home or - # deceptively, mirroring the V row's strongest moves. - dominant = chords[4] - dominant_7th = subsequence.chords.Chord(root_pc=dominant.root_pc, quality="dominant_7th") - - graph.add_transition(dominant, dominant_7th, subsequence.chord_graphs.WEIGHT_MEDIUM) - graph.add_transition(dominant_7th, chords[0], 9) # V7 → I - graph.add_transition(dominant_7th, chords[5], 7) # V7 → vi (deceptive) - graph.add_transition(dominant_7th, chords[3], 6) # V7 → IV - - return graph, chords[0] - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return major-key diatonic and functional chord sets.""" - - return subsequence.chord_graphs._major_key_gravity_sets(key_name) - - -def build_graph (key_name: str, include_dominant_7th: bool = True) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a corpus-weighted major-key graph and return it with the tonic chord.""" - - return HooktheoryMajor(include_dominant_7th=include_dominant_7th).build(key_name) +class HooktheoryMajor(subsequence.chord_graphs.ChordGraph): + """Major-key graph weighted by pop/rock corpus tendencies. + + The same seven diatonic triads as :class:`DiatonicMajor`, but the + transition weights follow real songwriting frequencies (Hooktheory-informed) + rather than textbook function — so the walk gravitates to the four-chord + loops that dominate popular music (I–V–vi–IV and its rotations) and uses + iii sparingly. Optionally colours the dominant with a seventh. + """ + + def __init__(self, include_dominant_7th: bool = True) -> None: + """Configure whether to add a dominant-seventh colour on V.""" + + self.include_dominant_7th = include_dominant_7th + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build the corpus-weighted graph for a given major key.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + chords = subsequence.chord_graphs.build_diatonic_chords( + subsequence.intervals.scale_pitch_classes(key_pc, "ionian"), + subsequence.intervals.IONIAN_QUALITIES, + ) + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + for source_degree, targets in _DEGREE_TRANSITIONS.items(): + source = chords[source_degree - 1] + for target_degree, weight in targets: + graph.add_transition(source, chords[target_degree - 1], weight) + + if self.include_dominant_7th: + # A V7 colour the corpus reaches from V and that resolves home or + # deceptively, mirroring the V row's strongest moves. + dominant = chords[4] + dominant_7th = subsequence.chords.Chord( + root_pc=dominant.root_pc, quality="dominant_7th" + ) + + graph.add_transition( + dominant, dominant_7th, subsequence.chord_graphs.WEIGHT_MEDIUM + ) + graph.add_transition(dominant_7th, chords[0], 9) # V7 → I + graph.add_transition(dominant_7th, chords[5], 7) # V7 → vi (deceptive) + graph.add_transition(dominant_7th, chords[3], 6) # V7 → IV + + return graph, chords[0] + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return major-key diatonic and functional chord sets.""" + + return subsequence.chord_graphs._major_key_gravity_sets(key_name) + + +def build_graph( + key_name: str, include_dominant_7th: bool = True +) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, +]: + """Build a corpus-weighted major-key graph and return it with the tonic chord.""" + + return HooktheoryMajor(include_dominant_7th=include_dominant_7th).build(key_name) diff --git a/subsequence/chord_graphs/lydian_major.py b/subsequence/chord_graphs/lydian_major.py index 41d3098..fbebeef 100644 --- a/subsequence/chord_graphs/lydian_major.py +++ b/subsequence/chord_graphs/lydian_major.py @@ -16,115 +16,136 @@ WEIGHT_LYDIAN = 5 -class LydianMajor (subsequence.chord_graphs.ChordGraph): - - """Lydian-flavored major harmony with a raised fourth degree. - - The raised 4th (#4) gives a bright, floating quality. The II chord - (major, a whole step above tonic) is the defining Lydian sound - - the I ↔ II "shimmer" is the strongest motion in the graph. The - natural IV chord is absent entirely, keeping the harmony purely modal. - - Good for ambient, cinematic, and progressive electronic music. - """ - - def __init__ (self, include_dominant_7th: bool = True) -> None: - - """Configure whether to include dominant seventh chords.""" - - self.include_dominant_7th = include_dominant_7th - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a Lydian major-key graph for the given key.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # Lydian scale: 0, 2, 4, 6, 7, 9, 11 - # Degrees: I, II, iii, #iv°, V, vi, vii - tonic = subsequence.chords.Chord(root_pc=key_pc, quality="major") - supertonic = subsequence.chords.Chord(root_pc=(key_pc + 2) % 12, quality="major") - mediant = subsequence.chords.Chord(root_pc=(key_pc + 4) % 12, quality="minor") - sharp_four_dim = subsequence.chords.Chord(root_pc=(key_pc + 6) % 12, quality="diminished") - dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="major") - submediant = subsequence.chords.Chord(root_pc=(key_pc + 9) % 12, quality="minor") - leading = subsequence.chords.Chord(root_pc=(key_pc + 11) % 12, quality="minor") - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- The Lydian shimmer: I ↔ II --- - graph.add_transition(tonic, supertonic, WEIGHT_LYDIAN) - graph.add_transition(supertonic, tonic, WEIGHT_LYDIAN) - - # --- Tonic departures --- - graph.add_transition(tonic, dominant, WEIGHT_COMMON) - graph.add_transition(tonic, submediant, WEIGHT_COMMON) - graph.add_transition(tonic, mediant, WEIGHT_WEAK) - - # --- Supertonic departures (beyond → tonic) --- - graph.add_transition(supertonic, dominant, WEIGHT_COMMON) - graph.add_transition(supertonic, mediant, WEIGHT_WEAK) - - # --- Mediant departures --- - graph.add_transition(mediant, submediant, WEIGHT_COMMON) - graph.add_transition(mediant, tonic, WEIGHT_WEAK) - - # --- Dominant departures --- - graph.add_transition(dominant, tonic, WEIGHT_STRONG) - graph.add_transition(dominant, submediant, WEIGHT_DECEPTIVE) - - # --- Submediant departures --- - graph.add_transition(submediant, supertonic, WEIGHT_COMMON) - graph.add_transition(submediant, dominant, WEIGHT_COMMON) - graph.add_transition(submediant, tonic, WEIGHT_WEAK) - - # --- Leading tone (vii) departures --- - graph.add_transition(leading, tonic, WEIGHT_STRONG) - graph.add_transition(leading, supertonic, WEIGHT_WEAK) - - # --- #iv° connectors --- - graph.add_transition(sharp_four_dim, dominant, WEIGHT_MEDIUM) - graph.add_transition(sharp_four_dim, tonic, WEIGHT_WEAK) - - # Make the two diatonic chords that had only outgoing edges reachable, as - # occasional colour: - # I → #iv° — passing into the signature raised-4 dim (→ V / I). - # V → vii — into the minor leading-tone triad, which resolves vii → I. - graph.add_transition(tonic, sharp_four_dim, WEIGHT_WEAK) - graph.add_transition(dominant, leading, WEIGHT_WEAK) - - if self.include_dominant_7th: - dominant_7th = subsequence.chords.Chord(root_pc=dominant.root_pc, quality="dominant_7th") - - graph.add_transition(dominant, dominant_7th, WEIGHT_WEAK) - graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG) - graph.add_transition(dominant_7th, submediant, WEIGHT_DECEPTIVE) - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return Lydian diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - diatonic: typing.Set[subsequence.chords.Chord] = set( - subsequence.chord_graphs.build_diatonic_chords( - subsequence.intervals.scale_pitch_classes(key_pc, "lydian"), - subsequence.intervals.LYDIAN_QUALITIES - ) - ) - - # Add dominant 7th to diatonic set. - dominant_7th = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="dominant_7th") - diatonic.add(dominant_7th) - - # Functional set: I, II (Lydian signature), V. - functional: typing.Set[subsequence.chords.Chord] = set() - - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 2) % 12, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="major")) - functional.add(dominant_7th) - - return diatonic, functional +class LydianMajor(subsequence.chord_graphs.ChordGraph): + """Lydian-flavored major harmony with a raised fourth degree. + + The raised 4th (#4) gives a bright, floating quality. The II chord + (major, a whole step above tonic) is the defining Lydian sound - + the I ↔ II "shimmer" is the strongest motion in the graph. The + natural IV chord is absent entirely, keeping the harmony purely modal. + + Good for ambient, cinematic, and progressive electronic music. + """ + + def __init__(self, include_dominant_7th: bool = True) -> None: + """Configure whether to include dominant seventh chords.""" + + self.include_dominant_7th = include_dominant_7th + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build a Lydian major-key graph for the given key.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # Lydian scale: 0, 2, 4, 6, 7, 9, 11 + # Degrees: I, II, iii, #iv°, V, vi, vii + tonic = subsequence.chords.Chord(root_pc=key_pc, quality="major") + supertonic = subsequence.chords.Chord( + root_pc=(key_pc + 2) % 12, quality="major" + ) + mediant = subsequence.chords.Chord(root_pc=(key_pc + 4) % 12, quality="minor") + sharp_four_dim = subsequence.chords.Chord( + root_pc=(key_pc + 6) % 12, quality="diminished" + ) + dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="major") + submediant = subsequence.chords.Chord( + root_pc=(key_pc + 9) % 12, quality="minor" + ) + leading = subsequence.chords.Chord(root_pc=(key_pc + 11) % 12, quality="minor") + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- The Lydian shimmer: I ↔ II --- + graph.add_transition(tonic, supertonic, WEIGHT_LYDIAN) + graph.add_transition(supertonic, tonic, WEIGHT_LYDIAN) + + # --- Tonic departures --- + graph.add_transition(tonic, dominant, WEIGHT_COMMON) + graph.add_transition(tonic, submediant, WEIGHT_COMMON) + graph.add_transition(tonic, mediant, WEIGHT_WEAK) + + # --- Supertonic departures (beyond → tonic) --- + graph.add_transition(supertonic, dominant, WEIGHT_COMMON) + graph.add_transition(supertonic, mediant, WEIGHT_WEAK) + + # --- Mediant departures --- + graph.add_transition(mediant, submediant, WEIGHT_COMMON) + graph.add_transition(mediant, tonic, WEIGHT_WEAK) + + # --- Dominant departures --- + graph.add_transition(dominant, tonic, WEIGHT_STRONG) + graph.add_transition(dominant, submediant, WEIGHT_DECEPTIVE) + + # --- Submediant departures --- + graph.add_transition(submediant, supertonic, WEIGHT_COMMON) + graph.add_transition(submediant, dominant, WEIGHT_COMMON) + graph.add_transition(submediant, tonic, WEIGHT_WEAK) + + # --- Leading tone (vii) departures --- + graph.add_transition(leading, tonic, WEIGHT_STRONG) + graph.add_transition(leading, supertonic, WEIGHT_WEAK) + + # --- #iv° connectors --- + graph.add_transition(sharp_four_dim, dominant, WEIGHT_MEDIUM) + graph.add_transition(sharp_four_dim, tonic, WEIGHT_WEAK) + + # Make the two diatonic chords that had only outgoing edges reachable, as + # occasional colour: + # I → #iv° — passing into the signature raised-4 dim (→ V / I). + # V → vii — into the minor leading-tone triad, which resolves vii → I. + graph.add_transition(tonic, sharp_four_dim, WEIGHT_WEAK) + graph.add_transition(dominant, leading, WEIGHT_WEAK) + + if self.include_dominant_7th: + dominant_7th = subsequence.chords.Chord( + root_pc=dominant.root_pc, quality="dominant_7th" + ) + + graph.add_transition(dominant, dominant_7th, WEIGHT_WEAK) + graph.add_transition(dominant_7th, tonic, WEIGHT_STRONG) + graph.add_transition(dominant_7th, submediant, WEIGHT_DECEPTIVE) + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return Lydian diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + diatonic: typing.Set[subsequence.chords.Chord] = set( + subsequence.chord_graphs.build_diatonic_chords( + subsequence.intervals.scale_pitch_classes(key_pc, "lydian"), + subsequence.intervals.LYDIAN_QUALITIES, + ) + ) + + # Add dominant 7th to diatonic set. + dominant_7th = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="dominant_7th" + ) + diatonic.add(dominant_7th) + + # Functional set: I, II (Lydian signature), V. + functional: typing.Set[subsequence.chords.Chord] = set() + + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="major")) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 2) % 12, quality="major") + ) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="major") + ) + functional.add(dominant_7th) + + return diatonic, functional diff --git a/subsequence/chord_graphs/mixolydian.py b/subsequence/chord_graphs/mixolydian.py index 3c5df52..ad79529 100644 --- a/subsequence/chord_graphs/mixolydian.py +++ b/subsequence/chord_graphs/mixolydian.py @@ -13,86 +13,110 @@ WEIGHT_MIXOLYDIAN = 5 -class Mixolydian (subsequence.chord_graphs.ChordGraph): - - """Major-mode chord graph with a flat seventh degree. - - Scale: 1, 2, 3, 4, 5, 6, b7. - Chords: I (major), ii (minor), iii° (diminished), IV (major), - v (minor), vi (minor), bVII (major). - - The I ↔ bVII shuttle is the defining colour. The minor v avoids - dominant function, keeping progressions open and unresolved. - Common in EDM, synthwave, and rock-influenced electronic music. - """ - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a Mixolydian mode graph.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - tonic = subsequence.chords.Chord(root_pc=key_pc, quality="major") - supertonic = subsequence.chords.Chord(root_pc=(key_pc + 2) % 12, quality="minor") - mediant = subsequence.chords.Chord(root_pc=(key_pc + 4) % 12, quality="diminished") - subdominant = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="major") - minor_dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="minor") - submediant = subsequence.chords.Chord(root_pc=(key_pc + 9) % 12, quality="minor") - flat_seven = subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major") - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- I ↔ bVII: the Mixolydian shuttle --- - graph.add_transition(tonic, flat_seven, WEIGHT_MIXOLYDIAN) - graph.add_transition(flat_seven, tonic, WEIGHT_MIXOLYDIAN) - - # --- Plagal motion --- - graph.add_transition(subdominant, tonic, WEIGHT_STRONG) - graph.add_transition(tonic, subdominant, WEIGHT_MEDIUM) - - # --- bVII ↔ IV: common EDM chain --- - graph.add_transition(flat_seven, subdominant, WEIGHT_MEDIUM) - graph.add_transition(subdominant, flat_seven, WEIGHT_COMMON) - - # --- Minor dominant --- - graph.add_transition(minor_dominant, tonic, WEIGHT_MEDIUM) - graph.add_transition(tonic, minor_dominant, WEIGHT_COMMON) - - # --- Supertonic --- - graph.add_transition(supertonic, minor_dominant, WEIGHT_COMMON) - graph.add_transition(supertonic, subdominant, WEIGHT_COMMON) - graph.add_transition(tonic, supertonic, WEIGHT_WEAK) - - # --- Submediant --- - graph.add_transition(submediant, flat_seven, WEIGHT_COMMON) - graph.add_transition(submediant, subdominant, WEIGHT_WEAK) - graph.add_transition(tonic, submediant, WEIGHT_WEAK) - graph.add_transition(minor_dominant, submediant, WEIGHT_WEAK) - - # --- Mediant (diminished - rare, colour) --- - graph.add_transition(mediant, subdominant, WEIGHT_WEAK) - graph.add_transition(mediant, tonic, WEIGHT_WEAK) - graph.add_transition(supertonic, mediant, WEIGHT_WEAK) - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return Mixolydian diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - diatonic: typing.Set[subsequence.chords.Chord] = set( - subsequence.chord_graphs.build_diatonic_chords( - subsequence.intervals.scale_pitch_classes(key_pc, "mixolydian"), - subsequence.intervals.MIXOLYDIAN_QUALITIES - ) - ) - - # Functional: I, IV, bVII (the primary colour chords). - functional: typing.Set[subsequence.chords.Chord] = set() - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="major")) - functional.add(subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major")) - - return diatonic, functional +class Mixolydian(subsequence.chord_graphs.ChordGraph): + """Major-mode chord graph with a flat seventh degree. + + Scale: 1, 2, 3, 4, 5, 6, b7. + Chords: I (major), ii (minor), iii° (diminished), IV (major), + v (minor), vi (minor), bVII (major). + + The I ↔ bVII shuttle is the defining colour. The minor v avoids + dominant function, keeping progressions open and unresolved. + Common in EDM, synthwave, and rock-influenced electronic music. + """ + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build a Mixolydian mode graph.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + tonic = subsequence.chords.Chord(root_pc=key_pc, quality="major") + supertonic = subsequence.chords.Chord( + root_pc=(key_pc + 2) % 12, quality="minor" + ) + mediant = subsequence.chords.Chord( + root_pc=(key_pc + 4) % 12, quality="diminished" + ) + subdominant = subsequence.chords.Chord( + root_pc=(key_pc + 5) % 12, quality="major" + ) + minor_dominant = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="minor" + ) + submediant = subsequence.chords.Chord( + root_pc=(key_pc + 9) % 12, quality="minor" + ) + flat_seven = subsequence.chords.Chord( + root_pc=(key_pc + 10) % 12, quality="major" + ) + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- I ↔ bVII: the Mixolydian shuttle --- + graph.add_transition(tonic, flat_seven, WEIGHT_MIXOLYDIAN) + graph.add_transition(flat_seven, tonic, WEIGHT_MIXOLYDIAN) + + # --- Plagal motion --- + graph.add_transition(subdominant, tonic, WEIGHT_STRONG) + graph.add_transition(tonic, subdominant, WEIGHT_MEDIUM) + + # --- bVII ↔ IV: common EDM chain --- + graph.add_transition(flat_seven, subdominant, WEIGHT_MEDIUM) + graph.add_transition(subdominant, flat_seven, WEIGHT_COMMON) + + # --- Minor dominant --- + graph.add_transition(minor_dominant, tonic, WEIGHT_MEDIUM) + graph.add_transition(tonic, minor_dominant, WEIGHT_COMMON) + + # --- Supertonic --- + graph.add_transition(supertonic, minor_dominant, WEIGHT_COMMON) + graph.add_transition(supertonic, subdominant, WEIGHT_COMMON) + graph.add_transition(tonic, supertonic, WEIGHT_WEAK) + + # --- Submediant --- + graph.add_transition(submediant, flat_seven, WEIGHT_COMMON) + graph.add_transition(submediant, subdominant, WEIGHT_WEAK) + graph.add_transition(tonic, submediant, WEIGHT_WEAK) + graph.add_transition(minor_dominant, submediant, WEIGHT_WEAK) + + # --- Mediant (diminished - rare, colour) --- + graph.add_transition(mediant, subdominant, WEIGHT_WEAK) + graph.add_transition(mediant, tonic, WEIGHT_WEAK) + graph.add_transition(supertonic, mediant, WEIGHT_WEAK) + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return Mixolydian diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + diatonic: typing.Set[subsequence.chords.Chord] = set( + subsequence.chord_graphs.build_diatonic_chords( + subsequence.intervals.scale_pitch_classes(key_pc, "mixolydian"), + subsequence.intervals.MIXOLYDIAN_QUALITIES, + ) + ) + + # Functional: I, IV, bVII (the primary colour chords). + functional: typing.Set[subsequence.chords.Chord] = set() + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="major")) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="major") + ) + functional.add( + subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="major") + ) + + return diatonic, functional diff --git a/subsequence/chord_graphs/phrygian_minor.py b/subsequence/chord_graphs/phrygian_minor.py index 6e84593..981f511 100644 --- a/subsequence/chord_graphs/phrygian_minor.py +++ b/subsequence/chord_graphs/phrygian_minor.py @@ -10,67 +10,79 @@ WEIGHT_WEAK = subsequence.chord_graphs.WEIGHT_WEAK -class PhrygianMinor (subsequence.chord_graphs.ChordGraph): - - """Minor chord graph utilizing Phrygian and Plagal motion. - - Consists of four minor chords: i, bii, iv, v. - The Phrygian bII (major) is replaced here by a minor bii for a darker, - more modal sound often found in techno and minimal repetition. - The Phrygian cadence (bii -> i) is the primary resolution. - """ - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a minimal all-minor Phrygian graph.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # Four chords, all minor. - tonic = subsequence.chords.Chord(root_pc=key_pc, quality="minor") - flat_two = subsequence.chords.Chord(root_pc=(key_pc + 1) % 12, quality="minor") - subdominant = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="minor") - natural_dominant = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="minor") - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- Tonic departures --- - graph.add_transition(tonic, flat_two, WEIGHT_MEDIUM) - graph.add_transition(tonic, subdominant, WEIGHT_MEDIUM) - graph.add_transition(tonic, natural_dominant, WEIGHT_WEAK) - - # --- Phrygian cadence: bii -> i (strongest resolution) --- - graph.add_transition(flat_two, tonic, WEIGHT_STRONG) - graph.add_transition(flat_two, natural_dominant, WEIGHT_WEAK) - - # --- Plagal motion --- - graph.add_transition(subdominant, tonic, WEIGHT_STRONG) - graph.add_transition(subdominant, natural_dominant, WEIGHT_MEDIUM) - - # --- Natural dominant departures --- - graph.add_transition(natural_dominant, tonic, WEIGHT_MEDIUM) - graph.add_transition(natural_dominant, flat_two, WEIGHT_WEAK) - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return all-minor diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # All four chords are diatonic to this palette. - diatonic: typing.Set[subsequence.chords.Chord] = set() - - for interval in [0, 1, 5, 7]: - root_pc = (key_pc + interval) % 12 - diatonic.add(subsequence.chords.Chord(root_pc=root_pc, quality="minor")) - - # Functional set: tonic, bii (Phrygian), subdominant. - functional: typing.Set[subsequence.chords.Chord] = set() - - for interval in [0, 1, 5]: - root_pc = (key_pc + interval) % 12 - functional.add(subsequence.chords.Chord(root_pc=root_pc, quality="minor")) - - return diatonic, functional +class PhrygianMinor(subsequence.chord_graphs.ChordGraph): + """Minor chord graph utilizing Phrygian and Plagal motion. + + Consists of four minor chords: i, bii, iv, v. + The Phrygian bII (major) is replaced here by a minor bii for a darker, + more modal sound often found in techno and minimal repetition. + The Phrygian cadence (bii -> i) is the primary resolution. + """ + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build a minimal all-minor Phrygian graph.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # Four chords, all minor. + tonic = subsequence.chords.Chord(root_pc=key_pc, quality="minor") + flat_two = subsequence.chords.Chord(root_pc=(key_pc + 1) % 12, quality="minor") + subdominant = subsequence.chords.Chord( + root_pc=(key_pc + 5) % 12, quality="minor" + ) + natural_dominant = subsequence.chords.Chord( + root_pc=(key_pc + 7) % 12, quality="minor" + ) + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- Tonic departures --- + graph.add_transition(tonic, flat_two, WEIGHT_MEDIUM) + graph.add_transition(tonic, subdominant, WEIGHT_MEDIUM) + graph.add_transition(tonic, natural_dominant, WEIGHT_WEAK) + + # --- Phrygian cadence: bii -> i (strongest resolution) --- + graph.add_transition(flat_two, tonic, WEIGHT_STRONG) + graph.add_transition(flat_two, natural_dominant, WEIGHT_WEAK) + + # --- Plagal motion --- + graph.add_transition(subdominant, tonic, WEIGHT_STRONG) + graph.add_transition(subdominant, natural_dominant, WEIGHT_MEDIUM) + + # --- Natural dominant departures --- + graph.add_transition(natural_dominant, tonic, WEIGHT_MEDIUM) + graph.add_transition(natural_dominant, flat_two, WEIGHT_WEAK) + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return all-minor diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # All four chords are diatonic to this palette. + diatonic: typing.Set[subsequence.chords.Chord] = set() + + for interval in [0, 1, 5, 7]: + root_pc = (key_pc + interval) % 12 + diatonic.add(subsequence.chords.Chord(root_pc=root_pc, quality="minor")) + + # Functional set: tonic, bii (Phrygian), subdominant. + functional: typing.Set[subsequence.chords.Chord] = set() + + for interval in [0, 1, 5]: + root_pc = (key_pc + interval) % 12 + functional.add(subsequence.chords.Chord(root_pc=root_pc, quality="minor")) + + return diatonic, functional diff --git a/subsequence/chord_graphs/suspended.py b/subsequence/chord_graphs/suspended.py index bea3e9e..fbb70e3 100644 --- a/subsequence/chord_graphs/suspended.py +++ b/subsequence/chord_graphs/suspended.py @@ -13,97 +13,115 @@ WEIGHT_COLOUR = 5 -class Suspended (subsequence.chord_graphs.ChordGraph): - - """Open harmony using suspended chords - no major or minor thirds. - - All chords are sus2 or sus4, creating ambiguous, open textures. - Same-root sus2 ↔ sus4 movement (colour change without root movement) - is heavily weighted. Root movement favors fourths/fifths and whole - steps. A single minor tonic provides occasional grounding. - - Good for ambient, post-rock, drone, and minimalist electronic music. - """ - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a suspended-chord graph with open, ambiguous harmony.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # Core suspended chords. - tonic_sus2 = subsequence.chords.Chord(root_pc=key_pc, quality="sus2") - tonic_sus4 = subsequence.chords.Chord(root_pc=key_pc, quality="sus4") - sub_sus2 = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="sus2") - sub_sus4 = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="sus4") - dom_sus2 = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="sus2") - dom_sus4 = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="sus4") - subtonic_sus2 = subsequence.chords.Chord(root_pc=(key_pc + 10) % 12, quality="sus2") - - # Minor tonic as the one "resolved" chord. - tonic_minor = subsequence.chords.Chord(root_pc=key_pc, quality="minor") - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - # --- Same-root colour changes (sus2 ↔ sus4) --- - graph.add_transition(tonic_sus2, tonic_sus4, WEIGHT_COLOUR) - graph.add_transition(tonic_sus4, tonic_sus2, WEIGHT_COLOUR) - graph.add_transition(sub_sus2, sub_sus4, WEIGHT_COLOUR) - graph.add_transition(sub_sus4, sub_sus2, WEIGHT_COLOUR) - graph.add_transition(dom_sus2, dom_sus4, WEIGHT_COLOUR) - graph.add_transition(dom_sus4, dom_sus2, WEIGHT_COLOUR) - - # --- Root motion by fourths/fifths --- - graph.add_transition(tonic_sus2, sub_sus2, WEIGHT_MEDIUM) - graph.add_transition(tonic_sus4, sub_sus4, WEIGHT_MEDIUM) - graph.add_transition(sub_sus2, tonic_sus2, WEIGHT_MEDIUM) - graph.add_transition(sub_sus4, tonic_sus4, WEIGHT_MEDIUM) - - graph.add_transition(tonic_sus2, dom_sus2, WEIGHT_COMMON) - graph.add_transition(tonic_sus4, dom_sus4, WEIGHT_COMMON) - graph.add_transition(dom_sus2, tonic_sus2, WEIGHT_MEDIUM) - graph.add_transition(dom_sus4, tonic_sus4, WEIGHT_MEDIUM) - - # --- bVII whole-step motion --- - graph.add_transition(tonic_sus2, subtonic_sus2, WEIGHT_COMMON) - graph.add_transition(subtonic_sus2, tonic_sus2, WEIGHT_COMMON) - graph.add_transition(subtonic_sus2, sub_sus2, WEIGHT_WEAK) - graph.add_transition(sub_sus2, subtonic_sus2, WEIGHT_WEAK) - - # --- Minor tonic as resolution --- - graph.add_transition(tonic_sus4, tonic_minor, WEIGHT_WEAK) - graph.add_transition(dom_sus4, tonic_minor, WEIGHT_WEAK) - graph.add_transition(tonic_minor, tonic_sus2, WEIGHT_MEDIUM) - graph.add_transition(tonic_minor, tonic_sus4, WEIGHT_COMMON) - - # --- Cross-root colour movement --- - graph.add_transition(sub_sus2, dom_sus4, WEIGHT_WEAK) - graph.add_transition(dom_sus2, sub_sus4, WEIGHT_WEAK) - - return graph, tonic_sus2 - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return suspended diatonic and functional chord sets.""" - - key_pc = subsequence.chord_graphs.validate_key_name(key_name) - - # All chords in the graph form the diatonic set. - diatonic: typing.Set[subsequence.chords.Chord] = set() - - for interval in [0, 5, 7, 10]: - diatonic.add(subsequence.chords.Chord(root_pc=(key_pc + interval) % 12, quality="sus2")) - - for interval in [0, 5, 7]: - diatonic.add(subsequence.chords.Chord(root_pc=(key_pc + interval) % 12, quality="sus4")) - - diatonic.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) - - # Functional set: tonic sus2/sus4 and the minor resolution. - functional: typing.Set[subsequence.chords.Chord] = set() - - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="sus2")) - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="sus4")) - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) - - return diatonic, functional +class Suspended(subsequence.chord_graphs.ChordGraph): + """Open harmony using suspended chords - no major or minor thirds. + + All chords are sus2 or sus4, creating ambiguous, open textures. + Same-root sus2 ↔ sus4 movement (colour change without root movement) + is heavily weighted. Root movement favors fourths/fifths and whole + steps. A single minor tonic provides occasional grounding. + + Good for ambient, post-rock, drone, and minimalist electronic music. + """ + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build a suspended-chord graph with open, ambiguous harmony.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # Core suspended chords. + tonic_sus2 = subsequence.chords.Chord(root_pc=key_pc, quality="sus2") + tonic_sus4 = subsequence.chords.Chord(root_pc=key_pc, quality="sus4") + sub_sus2 = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="sus2") + sub_sus4 = subsequence.chords.Chord(root_pc=(key_pc + 5) % 12, quality="sus4") + dom_sus2 = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="sus2") + dom_sus4 = subsequence.chords.Chord(root_pc=(key_pc + 7) % 12, quality="sus4") + subtonic_sus2 = subsequence.chords.Chord( + root_pc=(key_pc + 10) % 12, quality="sus2" + ) + + # Minor tonic as the one "resolved" chord. + tonic_minor = subsequence.chords.Chord(root_pc=key_pc, quality="minor") + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + # --- Same-root colour changes (sus2 ↔ sus4) --- + graph.add_transition(tonic_sus2, tonic_sus4, WEIGHT_COLOUR) + graph.add_transition(tonic_sus4, tonic_sus2, WEIGHT_COLOUR) + graph.add_transition(sub_sus2, sub_sus4, WEIGHT_COLOUR) + graph.add_transition(sub_sus4, sub_sus2, WEIGHT_COLOUR) + graph.add_transition(dom_sus2, dom_sus4, WEIGHT_COLOUR) + graph.add_transition(dom_sus4, dom_sus2, WEIGHT_COLOUR) + + # --- Root motion by fourths/fifths --- + graph.add_transition(tonic_sus2, sub_sus2, WEIGHT_MEDIUM) + graph.add_transition(tonic_sus4, sub_sus4, WEIGHT_MEDIUM) + graph.add_transition(sub_sus2, tonic_sus2, WEIGHT_MEDIUM) + graph.add_transition(sub_sus4, tonic_sus4, WEIGHT_MEDIUM) + + graph.add_transition(tonic_sus2, dom_sus2, WEIGHT_COMMON) + graph.add_transition(tonic_sus4, dom_sus4, WEIGHT_COMMON) + graph.add_transition(dom_sus2, tonic_sus2, WEIGHT_MEDIUM) + graph.add_transition(dom_sus4, tonic_sus4, WEIGHT_MEDIUM) + + # --- bVII whole-step motion --- + graph.add_transition(tonic_sus2, subtonic_sus2, WEIGHT_COMMON) + graph.add_transition(subtonic_sus2, tonic_sus2, WEIGHT_COMMON) + graph.add_transition(subtonic_sus2, sub_sus2, WEIGHT_WEAK) + graph.add_transition(sub_sus2, subtonic_sus2, WEIGHT_WEAK) + + # --- Minor tonic as resolution --- + graph.add_transition(tonic_sus4, tonic_minor, WEIGHT_WEAK) + graph.add_transition(dom_sus4, tonic_minor, WEIGHT_WEAK) + graph.add_transition(tonic_minor, tonic_sus2, WEIGHT_MEDIUM) + graph.add_transition(tonic_minor, tonic_sus4, WEIGHT_COMMON) + + # --- Cross-root colour movement --- + graph.add_transition(sub_sus2, dom_sus4, WEIGHT_WEAK) + graph.add_transition(dom_sus2, sub_sus4, WEIGHT_WEAK) + + return graph, tonic_sus2 + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return suspended diatonic and functional chord sets.""" + + key_pc = subsequence.chord_graphs.validate_key_name(key_name) + + # All chords in the graph form the diatonic set. + diatonic: typing.Set[subsequence.chords.Chord] = set() + + for interval in [0, 5, 7, 10]: + diatonic.add( + subsequence.chords.Chord( + root_pc=(key_pc + interval) % 12, quality="sus2" + ) + ) + + for interval in [0, 5, 7]: + diatonic.add( + subsequence.chords.Chord( + root_pc=(key_pc + interval) % 12, quality="sus4" + ) + ) + + diatonic.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) + + # Functional set: tonic sus2/sus4 and the minor resolution. + functional: typing.Set[subsequence.chords.Chord] = set() + + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="sus2")) + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="sus4")) + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="minor")) + + return diatonic, functional diff --git a/subsequence/chord_graphs/turnaround_global.py b/subsequence/chord_graphs/turnaround_global.py index 765e6df..b570a6d 100644 --- a/subsequence/chord_graphs/turnaround_global.py +++ b/subsequence/chord_graphs/turnaround_global.py @@ -12,146 +12,164 @@ WEIGHT_DECEPTIVE = subsequence.chord_graphs.WEIGHT_DECEPTIVE -def _build_major_key_chords (key_pc: int) -> typing.Dict[str, subsequence.chords.Chord]: - - """Return common functional chords for a major key root.""" - - chords = subsequence.chord_graphs.build_diatonic_chords( - subsequence.intervals.scale_pitch_classes(key_pc, "ionian"), - subsequence.intervals.IONIAN_QUALITIES - ) - - return { - "I": chords[0], - "ii": chords[1], - "iii": chords[2], - "IV": chords[3], - "V": chords[4], - "vi": chords[5], - "vii": chords[6], - } - - -def _add_turnaround_edges ( - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], - chords: typing.Dict[str, subsequence.chords.Chord], - include_dominant_7th: bool +def _build_major_key_chords(key_pc: int) -> typing.Dict[str, subsequence.chords.Chord]: + """Return common functional chords for a major key root.""" + + chords = subsequence.chord_graphs.build_diatonic_chords( + subsequence.intervals.scale_pitch_classes(key_pc, "ionian"), + subsequence.intervals.IONIAN_QUALITIES, + ) + + return { + "I": chords[0], + "ii": chords[1], + "iii": chords[2], + "IV": chords[3], + "V": chords[4], + "vi": chords[5], + "vii": chords[6], + } + + +def _add_turnaround_edges( + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + chords: typing.Dict[str, subsequence.chords.Chord], + include_dominant_7th: bool, ) -> None: - - """Add ii-V-I style edges for a single major key.""" - - tonic = chords["I"] - supertonic = chords["ii"] - submediant = chords["vi"] - subdominant = chords["IV"] - dominant = chords["V"] - dominant_7th = subsequence.chords.Chord(root_pc=dominant.root_pc, quality="dominant_7th") - - # Decision path: allow the dominant seventh to drive stronger resolutions. - dominant_target = dominant_7th if include_dominant_7th else dominant - - graph.add_transition(tonic, submediant, WEIGHT_COMMON) - graph.add_transition(submediant, supertonic, WEIGHT_COMMON) - graph.add_transition(supertonic, dominant_target, WEIGHT_STRONG) - graph.add_transition(dominant_target, tonic, WEIGHT_STRONG) - - graph.add_transition(tonic, subdominant, WEIGHT_MEDIUM) - graph.add_transition(tonic, dominant, WEIGHT_MEDIUM) - - if include_dominant_7th: - # Decision path: deceptive resolution only applies to dominant sevenths. - graph.add_transition(dominant_7th, submediant, WEIGHT_DECEPTIVE) - - -def _add_minor_turnaround ( - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], - key_pc: int, - minor_turnaround_weight: float, - include_dominant_7th: bool + """Add ii-V-I style edges for a single major key.""" + + tonic = chords["I"] + supertonic = chords["ii"] + submediant = chords["vi"] + subdominant = chords["IV"] + dominant = chords["V"] + dominant_7th = subsequence.chords.Chord( + root_pc=dominant.root_pc, quality="dominant_7th" + ) + + # Decision path: allow the dominant seventh to drive stronger resolutions. + dominant_target = dominant_7th if include_dominant_7th else dominant + + graph.add_transition(tonic, submediant, WEIGHT_COMMON) + graph.add_transition(submediant, supertonic, WEIGHT_COMMON) + graph.add_transition(supertonic, dominant_target, WEIGHT_STRONG) + graph.add_transition(dominant_target, tonic, WEIGHT_STRONG) + + graph.add_transition(tonic, subdominant, WEIGHT_MEDIUM) + graph.add_transition(tonic, dominant, WEIGHT_MEDIUM) + + if include_dominant_7th: + # Decision path: deceptive resolution only applies to dominant sevenths. + graph.add_transition(dominant_7th, submediant, WEIGHT_DECEPTIVE) + + +def _add_minor_turnaround( + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + key_pc: int, + minor_turnaround_weight: float, + include_dominant_7th: bool, ) -> None: - - """Add a minor ii-V-I turnaround using a weight multiplier.""" - - if minor_turnaround_weight <= 0: - # Decision path: weight of zero disables minor turnarounds entirely. - return - - scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "aeolian") - supertonic_pc = scale_pcs[1] - dominant_pc = scale_pcs[4] - tonic_pc = scale_pcs[0] - - supertonic = subsequence.chords.Chord(root_pc=supertonic_pc, quality="half_diminished_7th") - dominant_7th = subsequence.chords.Chord(root_pc=dominant_pc, quality="dominant_7th") - tonic_minor = subsequence.chords.Chord(root_pc=tonic_pc, quality="minor") - tonic_major = subsequence.chords.Chord(root_pc=tonic_pc, quality="major") - - minor_weight_strong = max(1, int(round(WEIGHT_STRONG * minor_turnaround_weight))) - minor_weight_entry = max(1, int(round(WEIGHT_MEDIUM * minor_turnaround_weight))) - - # Entry edges — without these the iio7 chords are unreachable and the - # minor turnaround can never start. The tonic minor aliases existing - # diatonic nodes (e.g. C minor is the ii of Bb major), and the parallel - # major tonic is each key's own I, so both hooks connect the minor - # turnaround to the core ii-V-I graph. - graph.add_transition(tonic_minor, supertonic, minor_weight_strong) - graph.add_transition(tonic_major, supertonic, minor_weight_entry) - - if include_dominant_7th: - graph.add_transition(supertonic, dominant_7th, minor_weight_strong) - graph.add_transition(dominant_7th, tonic_minor, minor_weight_strong) - - else: - # Decision path: if dominant sevenths are disabled, resolve via the triad. - dominant = subsequence.chords.Chord(root_pc=dominant_pc, quality="major") - graph.add_transition(supertonic, dominant, minor_weight_strong) - graph.add_transition(dominant, tonic_minor, minor_weight_strong) - - -class TurnaroundModulation (subsequence.chord_graphs.ChordGraph): - - """Global ii-V-I turnaround graph enabling modulation between all keys.""" - - def __init__ (self, include_dominant_7th: bool = True, minor_turnaround_weight: float = 0.0) -> None: - - """Configure dominant sevenths and minor turnaround strength.""" - - if minor_turnaround_weight < 0 or minor_turnaround_weight > 1: - raise ValueError("Minor turnaround weight must be between 0 and 1") - - self.include_dominant_7th = include_dominant_7th - self.minor_turnaround_weight = minor_turnaround_weight - - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build the global turnaround graph for all 12 keys.""" - - tonic_pc = subsequence.chord_graphs.validate_key_name(key_name) - - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() - - for key_pc in range(12): - chords = _build_major_key_chords(key_pc) - _add_turnaround_edges(graph, chords, self.include_dominant_7th) - _add_minor_turnaround(graph, key_pc, self.minor_turnaround_weight, self.include_dominant_7th) - tonic = subsequence.chords.Chord(root_pc=tonic_pc, quality="major") - - return graph, tonic - - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: - - """Return major-key diatonic and functional chord sets.""" - - return subsequence.chord_graphs._major_key_gravity_sets(key_name) - - -def build_graph (key_name: str, include_dominant_7th: bool = True, minor_turnaround_weight: float = 0.0) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: - - """Build a global turnaround graph and return it with the chosen key tonic.""" - - graph_obj = TurnaroundModulation( - include_dominant_7th = include_dominant_7th, - minor_turnaround_weight = minor_turnaround_weight - ) - - return graph_obj.build(key_name) + """Add a minor ii-V-I turnaround using a weight multiplier.""" + + if minor_turnaround_weight <= 0: + # Decision path: weight of zero disables minor turnarounds entirely. + return + + scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "aeolian") + supertonic_pc = scale_pcs[1] + dominant_pc = scale_pcs[4] + tonic_pc = scale_pcs[0] + + supertonic = subsequence.chords.Chord( + root_pc=supertonic_pc, quality="half_diminished_7th" + ) + dominant_7th = subsequence.chords.Chord(root_pc=dominant_pc, quality="dominant_7th") + tonic_minor = subsequence.chords.Chord(root_pc=tonic_pc, quality="minor") + tonic_major = subsequence.chords.Chord(root_pc=tonic_pc, quality="major") + + minor_weight_strong = max(1, int(round(WEIGHT_STRONG * minor_turnaround_weight))) + minor_weight_entry = max(1, int(round(WEIGHT_MEDIUM * minor_turnaround_weight))) + + # Entry edges — without these the iio7 chords are unreachable and the + # minor turnaround can never start. The tonic minor aliases existing + # diatonic nodes (e.g. C minor is the ii of Bb major), and the parallel + # major tonic is each key's own I, so both hooks connect the minor + # turnaround to the core ii-V-I graph. + graph.add_transition(tonic_minor, supertonic, minor_weight_strong) + graph.add_transition(tonic_major, supertonic, minor_weight_entry) + + if include_dominant_7th: + graph.add_transition(supertonic, dominant_7th, minor_weight_strong) + graph.add_transition(dominant_7th, tonic_minor, minor_weight_strong) + + else: + # Decision path: if dominant sevenths are disabled, resolve via the triad. + dominant = subsequence.chords.Chord(root_pc=dominant_pc, quality="major") + graph.add_transition(supertonic, dominant, minor_weight_strong) + graph.add_transition(dominant, tonic_minor, minor_weight_strong) + + +class TurnaroundModulation(subsequence.chord_graphs.ChordGraph): + """Global ii-V-I turnaround graph enabling modulation between all keys.""" + + def __init__( + self, include_dominant_7th: bool = True, minor_turnaround_weight: float = 0.0 + ) -> None: + """Configure dominant sevenths and minor turnaround strength.""" + + if minor_turnaround_weight < 0 or minor_turnaround_weight > 1: + raise ValueError("Minor turnaround weight must be between 0 and 1") + + self.include_dominant_7th = include_dominant_7th + self.minor_turnaround_weight = minor_turnaround_weight + + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build the global turnaround graph for all 12 keys.""" + + tonic_pc = subsequence.chord_graphs.validate_key_name(key_name) + + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) + + for key_pc in range(12): + chords = _build_major_key_chords(key_pc) + _add_turnaround_edges(graph, chords, self.include_dominant_7th) + _add_minor_turnaround( + graph, key_pc, self.minor_turnaround_weight, self.include_dominant_7th + ) + tonic = subsequence.chords.Chord(root_pc=tonic_pc, quality="major") + + return graph, tonic + + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return major-key diatonic and functional chord sets.""" + + return subsequence.chord_graphs._major_key_gravity_sets(key_name) + + +def build_graph( + key_name: str, + include_dominant_7th: bool = True, + minor_turnaround_weight: float = 0.0, +) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, +]: + """Build a global turnaround graph and return it with the chosen key tonic.""" + + graph_obj = TurnaroundModulation( + include_dominant_7th=include_dominant_7th, + minor_turnaround_weight=minor_turnaround_weight, + ) + + return graph_obj.build(key_name) diff --git a/subsequence/chord_graphs/whole_tone.py b/subsequence/chord_graphs/whole_tone.py index 7bddb29..ba58561 100644 --- a/subsequence/chord_graphs/whole_tone.py +++ b/subsequence/chord_graphs/whole_tone.py @@ -10,68 +10,78 @@ WEIGHT_LEAP = 2 -class WholeTone (subsequence.chord_graphs.ChordGraph): +class WholeTone(subsequence.chord_graphs.ChordGraph): + """Symmetrical whole-tone chord graph. - """Symmetrical whole-tone chord graph. + Scale: 0, 2, 4, 6, 8, 10 (six equally-spaced pitches). + All chords are augmented triads. Every position is equivalent - + there is no functional hierarchy, only proximity. - Scale: 0, 2, 4, 6, 8, 10 (six equally-spaced pitches). - All chords are augmented triads. Every position is equivalent - - there is no functional hierarchy, only proximity. + Step-wise motion (whole step) is weighted highest, thirds next, + larger leaps lowest. The result is a drifting, dreamlike quality + with no sense of resolution. Useful for IDM, ambient, and + experimental electronic music. + """ - Step-wise motion (whole step) is weighted highest, thirds next, - larger leaps lowest. The result is a drifting, dreamlike quality - with no sense of resolution. Useful for IDM, ambient, and - experimental electronic music. - """ + def build( + self, key_name: str + ) -> typing.Tuple[ + subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], + subsequence.chords.Chord, + ]: + """Build a fully-connected whole-tone graph.""" - def build (self, key_name: str) -> typing.Tuple[subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord], subsequence.chords.Chord]: + key_pc = subsequence.chord_graphs.validate_key_name(key_name) - """Build a fully-connected whole-tone graph.""" + # Six augmented triads, each a whole step apart. + roots = [(key_pc + i) % 12 for i in range(0, 12, 2)] + chords = [ + subsequence.chords.Chord(root_pc=r, quality="augmented") for r in roots + ] - key_pc = subsequence.chord_graphs.validate_key_name(key_name) + graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = ( + subsequence.weighted_graph.WeightedGraph() + ) - # Six augmented triads, each a whole step apart. - roots = [(key_pc + i) % 12 for i in range(0, 12, 2)] - chords = [subsequence.chords.Chord(root_pc=r, quality="augmented") for r in roots] + # Fully connected: every chord links to every other chord. + for i, source in enumerate(chords): + for j, target in enumerate(chords): + if i == j: + continue - graph: subsequence.weighted_graph.WeightedGraph[subsequence.chords.Chord] = subsequence.weighted_graph.WeightedGraph() + # Distance in whole-tone steps (1-3, since the scale is symmetric). + step_distance = min(abs(i - j), 6 - abs(i - j)) - # Fully connected: every chord links to every other chord. - for i, source in enumerate(chords): - for j, target in enumerate(chords): - if i == j: - continue + if step_distance == 1: + weight = WEIGHT_STEP + elif step_distance == 2: + weight = WEIGHT_THIRD + else: + weight = WEIGHT_LEAP - # Distance in whole-tone steps (1-3, since the scale is symmetric). - step_distance = min(abs(i - j), 6 - abs(i - j)) + graph.add_transition(source, target, weight) - if step_distance == 1: - weight = WEIGHT_STEP - elif step_distance == 2: - weight = WEIGHT_THIRD - else: - weight = WEIGHT_LEAP + tonic = chords[0] - graph.add_transition(source, target, weight) + return graph, tonic - tonic = chords[0] + def gravity_sets( + self, key_name: str + ) -> typing.Tuple[ + typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord] + ]: + """Return whole-tone diatonic and functional chord sets.""" - return graph, tonic + key_pc = subsequence.chord_graphs.validate_key_name(key_name) - def gravity_sets (self, key_name: str) -> typing.Tuple[typing.Set[subsequence.chords.Chord], typing.Set[subsequence.chords.Chord]]: + roots = [(key_pc + i) % 12 for i in range(0, 12, 2)] + diatonic: typing.Set[subsequence.chords.Chord] = set() - """Return whole-tone diatonic and functional chord sets.""" + for r in roots: + diatonic.add(subsequence.chords.Chord(root_pc=r, quality="augmented")) - key_pc = subsequence.chord_graphs.validate_key_name(key_name) + # Minimal functional pull - only the tonic augmented chord. + functional: typing.Set[subsequence.chords.Chord] = set() + functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="augmented")) - roots = [(key_pc + i) % 12 for i in range(0, 12, 2)] - diatonic: typing.Set[subsequence.chords.Chord] = set() - - for r in roots: - diatonic.add(subsequence.chords.Chord(root_pc=r, quality="augmented")) - - # Minimal functional pull - only the tonic augmented chord. - functional: typing.Set[subsequence.chords.Chord] = set() - functional.add(subsequence.chords.Chord(root_pc=key_pc, quality="augmented")) - - return diatonic, functional + return diatonic, functional diff --git a/subsequence/chords.py b/subsequence/chords.py index 51f28f6..fe7de5a 100644 --- a/subsequence/chords.py +++ b/subsequence/chords.py @@ -27,271 +27,262 @@ NOTE_NAME_TO_PC: typing.Dict[str, int] = { - "C": 0, - "C#": 1, - "Db": 1, - "D": 2, - "D#": 3, - "Eb": 3, - "E": 4, - "F": 5, - "F#": 6, - "Gb": 6, - "G": 7, - "G#": 8, - "Ab": 8, - "A": 9, - "A#": 10, - "Bb": 10, - "B": 11, + "C": 0, + "C#": 1, + "Db": 1, + "D": 2, + "D#": 3, + "Eb": 3, + "E": 4, + "F": 5, + "F#": 6, + "Gb": 6, + "G": 7, + "G#": 8, + "Ab": 8, + "A": 9, + "A#": 10, + "Bb": 10, + "B": 11, } PC_TO_NOTE_NAME: typing.List[str] = [ - "C", - "C#", - "D", - "D#", - "E", - "F", - "F#", - "G", - "G#", - "A", - "A#", - "B", + "C", + "C#", + "D", + "D#", + "E", + "F", + "F#", + "G", + "G#", + "A", + "A#", + "B", ] -def key_name_to_pc (key_name: str) -> int: +def key_name_to_pc(key_name: str) -> int: + """Validate a key name and return its pitch class (0–11). - """Validate a key name and return its pitch class (0–11). + Parameters: + key_name: Note name (e.g. ``"C"``, ``"F#"``, ``"Bb"``). - Parameters: - key_name: Note name (e.g. ``"C"``, ``"F#"``, ``"Bb"``). + Returns: + Pitch class integer (0–11). - Returns: - Pitch class integer (0–11). + Raises: + ValueError: If the key name is not recognised. - Raises: - ValueError: If the key name is not recognised. + Example: + ```python + key_name_to_pc("C") # → 0 + key_name_to_pc("F#") # → 6 + key_name_to_pc("Bb") # → 10 + ``` + """ - Example: - ```python - key_name_to_pc("C") # → 0 - key_name_to_pc("F#") # → 6 - key_name_to_pc("Bb") # → 10 - ``` - """ + if key_name not in NOTE_NAME_TO_PC: + raise ValueError( + f"Unknown key name: {key_name!r}. Expected e.g. 'C', 'F#', 'Bb'." + ) - if key_name not in NOTE_NAME_TO_PC: - raise ValueError( - f"Unknown key name: {key_name!r}. Expected e.g. 'C', 'F#', 'Bb'." - ) - - return NOTE_NAME_TO_PC[key_name] + return NOTE_NAME_TO_PC[key_name] CHORD_INTERVALS: typing.Dict[str, typing.List[int]] = { - "major": [0, 4, 7], - "minor": [0, 3, 7], - "diminished": [0, 3, 6], - "augmented": [0, 4, 8], - "dominant_7th": [0, 4, 7, 10], - "major_7th": [0, 4, 7, 11], - "minor_7th": [0, 3, 7, 10], - "half_diminished_7th": [0, 3, 6, 10], - "diminished_7th": [0, 3, 6, 9], - "sus2": [0, 2, 7], - "sus4": [0, 5, 7], + "major": [0, 4, 7], + "minor": [0, 3, 7], + "diminished": [0, 3, 6], + "augmented": [0, 4, 8], + "dominant_7th": [0, 4, 7, 10], + "major_7th": [0, 4, 7, 11], + "minor_7th": [0, 3, 7, 10], + "half_diminished_7th": [0, 3, 6, 10], + "diminished_7th": [0, 3, 6, 9], + "sus2": [0, 2, 7], + "sus4": [0, 5, 7], } CHORD_SUFFIX: typing.Dict[str, str] = { - "major": "", - "minor": "m", - "diminished": "dim", - "augmented": "+", - "dominant_7th": "7", - "major_7th": "maj7", - "minor_7th": "m7", - "half_diminished_7th": "m7b5", - "diminished_7th": "dim7", - "sus2": "sus2", - "sus4": "sus4", + "major": "", + "minor": "m", + "diminished": "dim", + "augmented": "+", + "dominant_7th": "7", + "major_7th": "maj7", + "minor_7th": "m7", + "half_diminished_7th": "m7b5", + "diminished_7th": "dim7", + "sus2": "sus2", + "sus4": "sus4", } @dataclasses.dataclass(frozen=True) class Chord: + """ + Represents a chord as a root pitch class and quality. + """ - """ - Represents a chord as a root pitch class and quality. - """ - - root_pc: int - quality: str - - - def intervals (self) -> typing.List[int]: - - """ - Return the chord intervals for this chord quality. - """ - - if self.quality not in CHORD_INTERVALS: - raise ValueError(f"Unknown chord quality: {self.quality}") - - return CHORD_INTERVALS[self.quality] - + root_pc: int + quality: str + def intervals(self) -> typing.List[int]: + """ + Return the chord intervals for this chord quality. + """ + + if self.quality not in CHORD_INTERVALS: + raise ValueError(f"Unknown chord quality: {self.quality}") - def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: + return CHORD_INTERVALS[self.quality] + + def tones( + self, root: int, inversion: int = 0, count: typing.Optional[int] = None + ) -> typing.List[int]: + """Return MIDI note numbers for chord tones starting from a root. - """Return MIDI note numbers for chord tones starting from a root. + Finds the MIDI note corresponding to the chord's root pitch class that is + closest to the provided ``root`` argument. - Finds the MIDI note corresponding to the chord's root pitch class that is - closest to the provided ``root`` argument. + Parameters: + root: MIDI note number (e.g., 60 = middle C) to center the chord around. + inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). + Wraps around for values >= number of notes. + count: Number of notes to return. When set, the chord intervals cycle + into higher octaves until ``count`` notes are produced. When ``None`` + (default), returns the natural chord tones. - Parameters: - root: MIDI note number (e.g., 60 = middle C) to center the chord around. - inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). - Wraps around for values >= number of notes. - count: Number of notes to return. When set, the chord intervals cycle - into higher octaves until ``count`` notes are produced. When ``None`` - (default), returns the natural chord tones. + Returns: + List of MIDI note numbers for chord tones - Returns: - List of MIDI note numbers for chord tones + Example: + ```python + chord = Chord(root_pc=0, quality="major") # C major + chord.tones(root=60) # [60, 64, 67] - root position around C4 + chord.tones(root=62) # [60, 64, 67] - still finds C4 as closest root + chord.tones(root=70) # [72, 76, 79] - finds C5 as closest root + ``` + """ - Example: - ```python - chord = Chord(root_pc=0, quality="major") # C major - chord.tones(root=60) # [60, 64, 67] - root position around C4 - chord.tones(root=62) # [60, 64, 67] - still finds C4 as closest root - chord.tones(root=70) # [72, 76, 79] - finds C5 as closest root - ``` - """ + # Find the MIDI note for self.root_pc that is closest to the requested root. + # This handles octaves automatically. + offset = (self.root_pc - root) % 12 + if offset > 6: + offset -= 12 - # Find the MIDI note for self.root_pc that is closest to the requested root. - # This handles octaves automatically. - offset = (self.root_pc - root) % 12 - if offset > 6: - offset -= 12 + effective_root = root + offset - effective_root = root + offset + intervals = self.intervals() - intervals = self.intervals() + if inversion != 0: + intervals = subsequence.voicings.invert_chord(intervals, inversion) - if inversion != 0: - intervals = subsequence.voicings.invert_chord(intervals, inversion) + if count is not None: + n = len(intervals) + return [ + effective_root + intervals[i % n] + 12 * (i // n) for i in range(count) + ] - if count is not None: - n = len(intervals) - return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)] + return [effective_root + interval for interval in intervals] - return [effective_root + interval for interval in intervals] + def root_note(self, root_midi: int) -> int: + """ + Return the MIDI note number for the chord root nearest to *root_midi*. + This is equivalent to ``self.tones(root_midi)[0]`` but makes intent + explicit when you only need the single root pitch. - def root_note (self, root_midi: int) -> int: + Parameters: + root_midi: Reference MIDI note number used to find the closest octave + of this chord's root pitch class. - """ - Return the MIDI note number for the chord root nearest to *root_midi*. + Returns: + MIDI note number of the chord root. - This is equivalent to ``self.tones(root_midi)[0]`` but makes intent - explicit when you only need the single root pitch. + Example: + ```python + chord = Chord(root_pc=4, quality="major") # E major + chord.root_note(60) # → 64 (E4, nearest to C4) + chord.root_note(69) # → 64 (E4, nearest to A4) + ``` + """ - Parameters: - root_midi: Reference MIDI note number used to find the closest octave - of this chord's root pitch class. + return self.tones(root_midi)[0] - Returns: - MIDI note number of the chord root. + def bass_note(self, root_midi: int, octave_offset: int = -1) -> int: + """ + Return the chord root shifted by a number of octaves. - Example: - ```python - chord = Chord(root_pc=4, quality="major") # E major - chord.root_note(60) # → 64 (E4, nearest to C4) - chord.root_note(69) # → 64 (E4, nearest to A4) - ``` - """ + Commonly used to produce a bass register note one or two octaves + below the chord voicing. - return self.tones(root_midi)[0] + Parameters: + root_midi: Reference MIDI note number (passed to :meth:`root_note`). + octave_offset: Octaves to shift; negative moves down (default ``-1``). + Returns: + MIDI note number of the chord root in the target register. - def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: + Example: + ```python + chord = Chord(root_pc=4, quality="major") # E major + chord.bass_note(64) # → 52 (E3, one octave down from E4) + chord.bass_note(64, -2) # → 40 (E2, two octaves down) + ``` + """ - """ - Return the chord root shifted by a number of octaves. + return self.root_note(root_midi) + (12 * octave_offset) - Commonly used to produce a bass register note one or two octaves - below the chord voicing. + def name(self) -> str: + """ + Return a human-friendly chord name. - Parameters: - root_midi: Reference MIDI note number (passed to :meth:`root_note`). - octave_offset: Octaves to shift; negative moves down (default ``-1``). + A registered quality without a suffix prints as ``root(quality)`` + (e.g. ``"C(quartal)"``) rather than masquerading as a plain major. + """ - Returns: - MIDI note number of the chord root in the target register. + root_name = PC_TO_NOTE_NAME[self.root_pc % 12] - Example: - ```python - chord = Chord(root_pc=4, quality="major") # E major - chord.bass_note(64) # → 52 (E3, one octave down from E4) - chord.bass_note(64, -2) # → 40 (E2, two octaves down) - ``` - """ + if self.quality not in CHORD_SUFFIX: + return f"{root_name}({self.quality})" - return self.root_note(root_midi) + (12 * octave_offset) - - - def name (self) -> str: - - """ - Return a human-friendly chord name. - - A registered quality without a suffix prints as ``root(quality)`` - (e.g. ``"C(quartal)"``) rather than masquerading as a plain major. - """ - - root_name = PC_TO_NOTE_NAME[self.root_pc % 12] - - if self.quality not in CHORD_SUFFIX: - return f"{root_name}({self.quality})" - - return f"{root_name}{CHORD_SUFFIX[self.quality]}" + return f"{root_name}{CHORD_SUFFIX[self.quality]}" # Quality suffixes accepted by parse_chord(), including common alternates. The # canonical suffixes (the values of CHORD_SUFFIX) all round-trip, so a chord's # own name() always re-parses to the same chord. _SUFFIX_TO_QUALITY: typing.Dict[str, str] = { - "": "major", - "maj": "major", - "M": "major", - "m": "minor", - "min": "minor", - "-": "minor", - "dim": "diminished", - "o": "diminished", - "°": "diminished", - "aug": "augmented", - "+": "augmented", - "7": "dominant_7th", - "dom7": "dominant_7th", - "maj7": "major_7th", - "M7": "major_7th", - "m7": "minor_7th", - "min7": "minor_7th", - "-7": "minor_7th", - "m7b5": "half_diminished_7th", - "ø": "half_diminished_7th", - "ø7": "half_diminished_7th", - "halfdim": "half_diminished_7th", - "dim7": "diminished_7th", - "o7": "diminished_7th", - "°7": "diminished_7th", - "sus2": "sus2", - "sus4": "sus4", - "sus": "sus4", + "": "major", + "maj": "major", + "M": "major", + "m": "minor", + "min": "minor", + "-": "minor", + "dim": "diminished", + "o": "diminished", + "°": "diminished", + "aug": "augmented", + "+": "augmented", + "7": "dominant_7th", + "dom7": "dominant_7th", + "maj7": "major_7th", + "M7": "major_7th", + "m7": "minor_7th", + "min7": "minor_7th", + "-7": "minor_7th", + "m7b5": "half_diminished_7th", + "ø": "half_diminished_7th", + "ø7": "half_diminished_7th", + "halfdim": "half_diminished_7th", + "dim7": "diminished_7th", + "o7": "diminished_7th", + "°7": "diminished_7th", + "sus2": "sus2", + "sus4": "sus4", + "sus": "sus4", } # Snapshots of the shipped tables, taken before any register_chord_quality() @@ -300,120 +291,128 @@ def name (self) -> str: _BUILTIN_SUFFIXES: typing.FrozenSet[str] = frozenset(_SUFFIX_TO_QUALITY) -def register_chord_quality ( - name: str, - intervals: typing.List[int], - suffix: typing.Optional[str] = None, +def register_chord_quality( + name: str, + intervals: typing.List[int], + suffix: typing.Optional[str] = None, ) -> None: - - """Register a custom chord quality for use everywhere chords are used. - - The counterpart to :func:`subsequence.intervals.register_scale` — it opens - the quality table so quartal stacks, clusters, and extended chords become - first-class symbolic chords: they work in progressions, graphs, voice - leading, and ``describe()`` output. - - Built-in qualities (e.g. ``"minor"``) cannot be overwritten. Custom names - may be re-registered freely — live reload re-runs registration on every - save, so this must not raise. - - Parameters: - name: Quality name (used as ``Chord(root_pc, quality=name)``). - intervals: Semitone offsets from the root (e.g. ``[0, 5, 10]`` for a - quartal stack, ``[0, 3, 7, 10, 14]`` for a minor 9th). Must start - with 0, ascend strictly, and stay within 0–24 (extensions reach - past the octave). - suffix: Optional chord-name suffix. When given, ``parse_chord()`` - accepts ``"A" + suffix`` and ``Chord.name()`` prints it — so - ``register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9")`` - makes ``"Am9"`` parse from then on. Must not collide with a - built-in suffix. - - Example: - ```python - import subsequence - - subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4") - subsequence.parse_chord("Dq4") # → Chord(root_pc=2, quality="quartal") - ``` - """ - - if name in _BUILTIN_QUALITY_NAMES: - raise ValueError( - f"Cannot overwrite built-in chord quality '{name}'. " - "Choose a different name for your custom quality." - ) - - if not intervals: - raise ValueError("intervals must not be empty") - if not all(isinstance(i, int) and not isinstance(i, bool) for i in intervals): - raise ValueError("intervals must be whole numbers (semitone offsets)") - if intervals[0] != 0: - raise ValueError("intervals must start with 0 (the root)") - if any(b <= a for a, b in zip(intervals, intervals[1:])): - raise ValueError("intervals must be strictly ascending") - if any(i < 0 or i > 24 for i in intervals): - raise ValueError("intervals must contain values between 0 and 24") - - if suffix is not None: - if suffix in _BUILTIN_SUFFIXES: - raise ValueError( - f"Suffix {suffix!r} is a built-in chord suffix and cannot be reused. " - "Choose a different suffix for your custom quality." - ) - if not suffix or suffix[0] in "ABCDEFG#b0123456789": - raise ValueError( - f"Suffix {suffix!r} would be ambiguous in a chord name — " - "it must not be empty or start with a note letter, accidental, or digit" - ) - - # Re-registration: drop any suffix this quality registered previously, so - # renaming a suffix on live reload does not leave a stale alias behind. - for old_suffix in [s for s, q in _SUFFIX_TO_QUALITY.items() if q == name and s not in _BUILTIN_SUFFIXES]: - del _SUFFIX_TO_QUALITY[old_suffix] - - CHORD_INTERVALS[name] = list(intervals) - - if suffix is not None: - CHORD_SUFFIX[name] = suffix - _SUFFIX_TO_QUALITY[suffix] = name - else: - CHORD_SUFFIX.pop(name, None) - - -def parse_chord (name: str) -> Chord: - - """Parse a chord name like ``"Cm7"`` or ``"Dbmaj7"`` into a :class:`Chord`. - - The name is a root note (``A``–``G`` with an optional ``#`` or ``b``) followed - by a quality suffix: ``""`` major, ``m`` minor, ``dim`` diminished, - ``+``/``aug`` augmented, ``7`` dominant 7th, ``maj7`` major 7th, ``m7`` minor - 7th, ``m7b5``/``ø`` half-diminished 7th, ``sus2``, ``sus4``. A few common - alternates (``min``, ``-``, ``M7``, …) are accepted too. - - Raises ``ValueError`` for anything it can't read, so a typo surfaces at the - call site rather than as a silently wrong chord. - - Example: - ```python - parse_chord("Cm7") # → Chord(root_pc=0, quality="minor_7th") - parse_chord("Dbmaj7") # → Chord(root_pc=1, quality="major_7th") - parse_chord("F#") # → Chord(root_pc=6, quality="major") - ``` - """ - - stripped = name.strip() - if not stripped or stripped[0] not in "ABCDEFG": - raise ValueError(f"Cannot parse chord name {name!r} — expected a root like 'C', 'F#', 'Bb' then a quality, e.g. 'Cm7'") - - split = 2 if (len(stripped) > 1 and stripped[1] in "#b") else 1 - root_name = stripped[:split] - suffix = stripped[split:] - - if root_name not in NOTE_NAME_TO_PC: - raise ValueError(f"Cannot parse chord name {name!r} — unknown root {root_name!r}") - if suffix not in _SUFFIX_TO_QUALITY: - known = ", ".join(repr(key) for key in sorted(_SUFFIX_TO_QUALITY) if key) - raise ValueError(f"Cannot parse chord name {name!r} — unknown quality {suffix!r}. Known suffixes: {known}") - - return Chord(root_pc=NOTE_NAME_TO_PC[root_name], quality=_SUFFIX_TO_QUALITY[suffix]) + """Register a custom chord quality for use everywhere chords are used. + + The counterpart to :func:`subsequence.intervals.register_scale` — it opens + the quality table so quartal stacks, clusters, and extended chords become + first-class symbolic chords: they work in progressions, graphs, voice + leading, and ``describe()`` output. + + Built-in qualities (e.g. ``"minor"``) cannot be overwritten. Custom names + may be re-registered freely — live reload re-runs registration on every + save, so this must not raise. + + Parameters: + name: Quality name (used as ``Chord(root_pc, quality=name)``). + intervals: Semitone offsets from the root (e.g. ``[0, 5, 10]`` for a + quartal stack, ``[0, 3, 7, 10, 14]`` for a minor 9th). Must start + with 0, ascend strictly, and stay within 0–24 (extensions reach + past the octave). + suffix: Optional chord-name suffix. When given, ``parse_chord()`` + accepts ``"A" + suffix`` and ``Chord.name()`` prints it — so + ``register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9")`` + makes ``"Am9"`` parse from then on. Must not collide with a + built-in suffix. + + Example: + ```python + import subsequence + + subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4") + subsequence.parse_chord("Dq4") # → Chord(root_pc=2, quality="quartal") + ``` + """ + + if name in _BUILTIN_QUALITY_NAMES: + raise ValueError( + f"Cannot overwrite built-in chord quality '{name}'. " + "Choose a different name for your custom quality." + ) + + if not intervals: + raise ValueError("intervals must not be empty") + if not all(isinstance(i, int) and not isinstance(i, bool) for i in intervals): + raise ValueError("intervals must be whole numbers (semitone offsets)") + if intervals[0] != 0: + raise ValueError("intervals must start with 0 (the root)") + if any(b <= a for a, b in zip(intervals, intervals[1:])): + raise ValueError("intervals must be strictly ascending") + if any(i < 0 or i > 24 for i in intervals): + raise ValueError("intervals must contain values between 0 and 24") + + if suffix is not None: + if suffix in _BUILTIN_SUFFIXES: + raise ValueError( + f"Suffix {suffix!r} is a built-in chord suffix and cannot be reused. " + "Choose a different suffix for your custom quality." + ) + if not suffix or suffix[0] in "ABCDEFG#b0123456789": + raise ValueError( + f"Suffix {suffix!r} would be ambiguous in a chord name — " + "it must not be empty or start with a note letter, accidental, or digit" + ) + + # Re-registration: drop any suffix this quality registered previously, so + # renaming a suffix on live reload does not leave a stale alias behind. + for old_suffix in [ + s + for s, q in _SUFFIX_TO_QUALITY.items() + if q == name and s not in _BUILTIN_SUFFIXES + ]: + del _SUFFIX_TO_QUALITY[old_suffix] + + CHORD_INTERVALS[name] = list(intervals) + + if suffix is not None: + CHORD_SUFFIX[name] = suffix + _SUFFIX_TO_QUALITY[suffix] = name + else: + CHORD_SUFFIX.pop(name, None) + + +def parse_chord(name: str) -> Chord: + """Parse a chord name like ``"Cm7"`` or ``"Dbmaj7"`` into a :class:`Chord`. + + The name is a root note (``A``–``G`` with an optional ``#`` or ``b``) followed + by a quality suffix: ``""`` major, ``m`` minor, ``dim`` diminished, + ``+``/``aug`` augmented, ``7`` dominant 7th, ``maj7`` major 7th, ``m7`` minor + 7th, ``m7b5``/``ø`` half-diminished 7th, ``sus2``, ``sus4``. A few common + alternates (``min``, ``-``, ``M7``, …) are accepted too. + + Raises ``ValueError`` for anything it can't read, so a typo surfaces at the + call site rather than as a silently wrong chord. + + Example: + ```python + parse_chord("Cm7") # → Chord(root_pc=0, quality="minor_7th") + parse_chord("Dbmaj7") # → Chord(root_pc=1, quality="major_7th") + parse_chord("F#") # → Chord(root_pc=6, quality="major") + ``` + """ + + stripped = name.strip() + if not stripped or stripped[0] not in "ABCDEFG": + raise ValueError( + f"Cannot parse chord name {name!r} — expected a root like 'C', 'F#', 'Bb' then a quality, e.g. 'Cm7'" + ) + + split = 2 if (len(stripped) > 1 and stripped[1] in "#b") else 1 + root_name = stripped[:split] + suffix = stripped[split:] + + if root_name not in NOTE_NAME_TO_PC: + raise ValueError( + f"Cannot parse chord name {name!r} — unknown root {root_name!r}" + ) + if suffix not in _SUFFIX_TO_QUALITY: + known = ", ".join(repr(key) for key in sorted(_SUFFIX_TO_QUALITY) if key) + raise ValueError( + f"Cannot parse chord name {name!r} — unknown quality {suffix!r}. Known suffixes: {known}" + ) + + return Chord(root_pc=NOTE_NAME_TO_PC[root_name], quality=_SUFFIX_TO_QUALITY[suffix]) diff --git a/subsequence/composition.py b/subsequence/composition.py index bfb9cab..27a968c 100644 --- a/subsequence/composition.py +++ b/subsequence/composition.py @@ -60,114 +60,109 @@ # Hotkey support — dataclasses and label derivation # --------------------------------------------------------------------------- + @dataclasses.dataclass class HotkeyBinding: + """A registered keyboard shortcut and its associated action. - """A registered keyboard shortcut and its associated action. - - Attributes: - key: The single character that triggers this binding. - action: Zero-argument callable executed when the key fires. - quantize: ``0`` = execute immediately; ``N`` = execute on the - next global bar divisible by *N*. - label: Human-readable description shown by the ``?`` help key. - """ + Attributes: + key: The single character that triggers this binding. + action: Zero-argument callable executed when the key fires. + quantize: ``0`` = execute immediately; ``N`` = execute on the + next global bar divisible by *N*. + label: Human-readable description shown by the ``?`` help key. + """ - key: str - action: typing.Callable[[], None] - quantize: int - label: str + key: str + action: typing.Callable[[], None] + quantize: int + label: str @dataclasses.dataclass class _PendingHotkeyAction: + """An action that has been triggered but is waiting for its quantize boundary.""" - """An action that has been triggered but is waiting for its quantize boundary.""" - - binding: HotkeyBinding + binding: HotkeyBinding _HOTKEY_RESERVED = "?" """Key reserved for listing all active hotkeys.""" -def _derive_label (action: typing.Callable[[], None]) -> str: - - """Auto-derive a display label for *action*. - - Tried in order: +def _derive_label(action: typing.Callable[[], None]) -> str: + """Auto-derive a display label for *action*. - 1. **Named function** — returns ``fn.__name__``. - 2. **Lambda in a ``.py`` file** — uses :func:`inspect.getsource` to extract - the lambda body from the source line (works for compositions defined in - files; falls back gracefully in REPLs and ``exec()`` contexts). - 3. **Fallback** — returns ``""``. + Tried in order: - Args: - action: The callable registered as a hotkey action. + 1. **Named function** — returns ``fn.__name__``. + 2. **Lambda in a ``.py`` file** — uses :func:`inspect.getsource` to extract + the lambda body from the source line (works for compositions defined in + files; falls back gracefully in REPLs and ``exec()`` contexts). + 3. **Fallback** — returns ``""``. - Returns: - A short, readable string suitable for the ``?`` help listing. - """ + Args: + action: The callable registered as a hotkey action. - name: typing.Optional[str] = getattr(action, "__name__", None) - if name and name != "": - return name + Returns: + A short, readable string suitable for the ``?`` help listing. + """ - # Lambda — try to extract the body from the source line. - try: - source = inspect.getsource(action).strip() - match = re.search(r"lambda\b[^:]*:\s*(.+)", source) - if match: - body = match.group(1).strip() - # Strip trailing kwargs that belong to the outer hotkey() call. - body = re.sub(r"[,\s]*(quantize|label)\s*=.*", "", body) - body = body.rstrip(" ,)") - if body: - return body - except (OSError, TypeError): - pass + name: typing.Optional[str] = getattr(action, "__name__", None) + if name and name != "": + return name - return "" + # Lambda — try to extract the body from the source line. + try: + source = inspect.getsource(action).strip() + match = re.search(r"lambda\b[^:]*:\s*(.+)", source) + if match: + body = match.group(1).strip() + # Strip trailing kwargs that belong to the outer hotkey() call. + body = re.sub(r"[,\s]*(quantize|label)\s*=.*", "", body) + body = body.rstrip(" ,)") + if body: + return body + except (OSError, TypeError): + pass + return "" -def _fn_has_parameter (fn: typing.Callable, name: str) -> bool: - """Check whether a callable accepts a parameter with the given name.""" +def _fn_has_parameter(fn: typing.Callable, name: str) -> bool: + """Check whether a callable accepts a parameter with the given name.""" - return name in inspect.signature(fn).parameters + return name in inspect.signature(fn).parameters @dataclasses.dataclass class ScheduleContext: + """ + Context object passed to ``composition.schedule()`` callbacks + whose signature declares a first parameter (conventionally named ``p``). - """ - Context object passed to ``composition.schedule()`` callbacks - whose signature declares a first parameter (conventionally named ``p``). + Attributes: + cycle: How many times this callback has been called so far (0-indexed). + 0 on the first call, including the blocking ``wait_for_initial`` run. + """ - Attributes: - cycle: How many times this callback has been called so far (0-indexed). - 0 on the first call, including the blocking ``wait_for_initial`` run. - """ - - cycle: int + cycle: int @dataclasses.dataclass class _AdditionalOutput: + """A MIDI output device registered via ``composition.midi_output()``. - """A MIDI output device registered via ``composition.midi_output()``. - - Attributes: - device: The exact MIDI output port name. - alias: Optional friendly name for device-id lookups. - latency_ms: Physical output latency in milliseconds for delay - compensation (0.0 = no compensation). - """ + Attributes: + device: The exact MIDI output port name. + alias: Optional friendly name for device-id lookups. + latency_ms: Physical output latency in milliseconds for delay + compensation (0.0 = no compensation). + """ - device: str - alias: typing.Optional[str] = None - latency_ms: float = 0.0 + device: str + alias: typing.Optional[str] = None + latency_ms: float = 0.0 # The one progression type (subsequence/progressions.py) — re-exported here @@ -177,5923 +172,6326 @@ class _AdditionalOutput: class _InjectedChord: + """ + Wraps a Chord with key context so tones() transposes correctly. + """ + + def __init__( + self, + chord: typing.Any, + voice_leading_state: typing.Optional[ + subsequence.voicings.VoiceLeadingState + ] = None, + next_chord: typing.Optional[typing.Any] = None, + beats_remaining: typing.Optional[float] = None, + ) -> None: + """ + Store the chord, optional voice leading state, and the harmony + window's anticipation data (the chord after this one and the beats + until it arrives), when known. + """ + + self._chord = chord + self._voice_leading_state = voice_leading_state + self._next_chord = next_chord + self._beats_remaining = beats_remaining + + @property + def next(self) -> typing.Optional["_InjectedChord"]: + """The chord after the current one — planned and revocable. + + Sugar over the harmony window (``p.harmony.next_chord``), so + two-parameter builders get anticipation without learning a new + object. ``None`` when the window has no committed next chord. + Voice leading is not applied (the voicing belongs to the chord + that is actually sounding). + """ + + if self._next_chord is None: + return None + + return _InjectedChord(self._next_chord) + + @property + def beats_remaining(self) -> typing.Optional[float]: + """Beats until the next chord boundary (from this cycle's start), when known.""" + + return self._beats_remaining + + def root_midi(self, base: int) -> int: + """ + Return the MIDI note for this chord's root that is closest to ``base``. + """ + + # Delegate to the chord's own root_note() rather than reimplementing + # the pitch-class offset arithmetic. + return self._chord.root_note(base) # type: ignore[no-any-return] + + def tones( + self, root: int, inversion: int = 0, count: typing.Optional[int] = None + ) -> typing.List[int]: + """Return MIDI note numbers transposed to the correct chord root. + + When voice leading is active, the best inversion is chosen + automatically and the ``inversion`` parameter is ignored. + + When ``count`` is set, the chord intervals cycle into higher + octaves until ``count`` notes are produced. + """ + + midi_root = self.root_midi(root) + intervals = self._chord.intervals() + + if self._voice_leading_state is not None: + base = self._voice_leading_state.next(intervals, midi_root) + if count is not None: + n = len(base) + base_intervals = [p - base[0] for p in base] + return [ + base[0] + base_intervals[i % n] + 12 * (i // n) + for i in range(count) + ] + return base + + if inversion != 0: + intervals = subsequence.voicings.invert_chord(intervals, inversion) + + if count is not None: + n = len(intervals) + return [midi_root + intervals[i % n] + 12 * (i // n) for i in range(count)] + + return [midi_root + interval for interval in intervals] + + def root_note(self, root_midi: int) -> int: + """Return the MIDI note number for the chord root nearest to *root_midi*.""" + + # Delegate to root_midi(), NOT tones(): under voice leading, tones() + # returns the smoothest INVERSION (whose bass is often not the root) + # and advances the voicing state — a read must do neither. + return self.root_midi(root_midi) + + def bass_note(self, root_midi: int, octave_offset: int = -1) -> int: + """Return the chord root shifted by a number of octaves.""" + + return self.root_midi(root_midi) + (12 * octave_offset) + + def intervals(self) -> typing.List[int]: + """ + Forward to the underlying chord's intervals. + """ - """ - Wraps a Chord with key context so tones() transposes correctly. - """ - - def __init__ ( - self, - chord: typing.Any, - voice_leading_state: typing.Optional[subsequence.voicings.VoiceLeadingState] = None, - next_chord: typing.Optional[typing.Any] = None, - beats_remaining: typing.Optional[float] = None, - ) -> None: - - """ - Store the chord, optional voice leading state, and the harmony - window's anticipation data (the chord after this one and the beats - until it arrives), when known. - """ - - self._chord = chord - self._voice_leading_state = voice_leading_state - self._next_chord = next_chord - self._beats_remaining = beats_remaining - - @property - def next (self) -> typing.Optional["_InjectedChord"]: - - """The chord after the current one — planned and revocable. - - Sugar over the harmony window (``p.harmony.next_chord``), so - two-parameter builders get anticipation without learning a new - object. ``None`` when the window has no committed next chord. - Voice leading is not applied (the voicing belongs to the chord - that is actually sounding). - """ - - if self._next_chord is None: - return None - - return _InjectedChord(self._next_chord) - - @property - def beats_remaining (self) -> typing.Optional[float]: - - """Beats until the next chord boundary (from this cycle's start), when known.""" - - return self._beats_remaining - - def root_midi (self, base: int) -> int: - - """ - Return the MIDI note for this chord's root that is closest to ``base``. - """ - - # Delegate to the chord's own root_note() rather than reimplementing - # the pitch-class offset arithmetic. - return self._chord.root_note(base) # type: ignore[no-any-return] - - def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: - - """Return MIDI note numbers transposed to the correct chord root. - - When voice leading is active, the best inversion is chosen - automatically and the ``inversion`` parameter is ignored. - - When ``count`` is set, the chord intervals cycle into higher - octaves until ``count`` notes are produced. - """ - - midi_root = self.root_midi(root) - intervals = self._chord.intervals() - - if self._voice_leading_state is not None: - base = self._voice_leading_state.next(intervals, midi_root) - if count is not None: - n = len(base) - base_intervals = [p - base[0] for p in base] - return [base[0] + base_intervals[i % n] + 12 * (i // n) for i in range(count)] - return base - - if inversion != 0: - intervals = subsequence.voicings.invert_chord(intervals, inversion) - - if count is not None: - n = len(intervals) - return [midi_root + intervals[i % n] + 12 * (i // n) for i in range(count)] - - return [midi_root + interval for interval in intervals] - - def root_note (self, root_midi: int) -> int: - - """Return the MIDI note number for the chord root nearest to *root_midi*.""" - - # Delegate to root_midi(), NOT tones(): under voice leading, tones() - # returns the smoothest INVERSION (whose bass is often not the root) - # and advances the voicing state — a read must do neither. - return self.root_midi(root_midi) - - def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: - - """Return the chord root shifted by a number of octaves.""" - - return self.root_midi(root_midi) + (12 * octave_offset) - - def intervals (self) -> typing.List[int]: + return self._chord.intervals() # type: ignore[no-any-return] - """ - Forward to the underlying chord's intervals. - """ + def name(self) -> str: + """ + Forward to the underlying chord's name. + """ - return self._chord.intervals() # type: ignore[no-any-return] - - def name (self) -> str: - - """ - Forward to the underlying chord's name. - """ - - return self._chord.name() # type: ignore[no-any-return] + return self._chord.name() # type: ignore[no-any-return] class _HarmonyHorizon: + """The published harmony window — realised chord spans on the absolute beat axis. - """The published harmony window — realised chord spans on the absolute beat axis. - - The harmonic clock commits one span per chord boundary (decorated chords - where the source span is spiced) and, where the future is data (a bound - or section progression), installs a *future* lookup so ``chord_at`` can - answer arbitrarily far ahead. In live graph mode the window is - ``[current, next]`` — one pre-committed step — and queries beyond it - clamp to the last known chord with a one-time warning. - - All beats are absolute (from playback start). Read through - :class:`HarmonyView` inside patterns, or :meth:`Composition.current_chord` - for the chord sounding at the playhead. - """ - - def __init__ (self) -> None: - - """Start with an empty window.""" - - self._spans: typing.List[typing.Tuple[float, float, typing.Any]] = [] - self._future: typing.Optional[typing.Callable[[float], typing.Optional[typing.Tuple[float, float, typing.Any]]]] = None - self._planned: typing.Optional[typing.Tuple[float, float, typing.Any]] = None - self._warned_beyond = False - - @property - def is_empty (self) -> bool: - - """True when nothing has been committed yet (no harmony configured).""" + The harmonic clock commits one span per chord boundary (decorated chords + where the source span is spiced) and, where the future is data (a bound + or section progression), installs a *future* lookup so ``chord_at`` can + answer arbitrarily far ahead. In live graph mode the window is + ``[current, next]`` — one pre-committed step — and queries beyond it + clamp to the last known chord with a one-time warning. + + All beats are absolute (from playback start). Read through + :class:`HarmonyView` inside patterns, or :meth:`Composition.current_chord` + for the chord sounding at the playhead. + """ - return not self._spans + def __init__(self) -> None: + """Start with an empty window.""" - def reset (self) -> None: + self._spans: typing.List[typing.Tuple[float, float, typing.Any]] = [] + self._future: typing.Optional[ + typing.Callable[ + [float], typing.Optional[typing.Tuple[float, float, typing.Any]] + ] + ] = None + self._planned: typing.Optional[typing.Tuple[float, float, typing.Any]] = None + self._warned_beyond = False - """Clear everything (a fresh playback).""" + @property + def is_empty(self) -> bool: + """True when nothing has been committed yet (no harmony configured).""" - self._spans = [] - self._future = None - self._planned = None - self._warned_beyond = False + return not self._spans - def commit (self, start: float, end: float, chord: typing.Any) -> None: + def reset(self) -> None: + """Clear everything (a fresh playback).""" - """Commit a realised span. A commit at an earlier start truncates the tail.""" + self._spans = [] + self._future = None + self._planned = None + self._warned_beyond = False - while self._spans and self._spans[-1][0] >= start - 1e-9: - self._spans.pop() + def commit(self, start: float, end: float, chord: typing.Any) -> None: + """Commit a realised span. A commit at an earlier start truncates the tail.""" - if self._spans and self._spans[-1][1] > start: - previous_start, _, previous_chord = self._spans[-1] - self._spans[-1] = (previous_start, start, previous_chord) + while self._spans and self._spans[-1][0] >= start - 1e-9: + self._spans.pop() - self._spans.append((start, end, chord)) + if self._spans and self._spans[-1][1] > start: + previous_start, _, previous_chord = self._spans[-1] + self._spans[-1] = (previous_start, start, previous_chord) - if self._planned is not None and self._planned[0] < end: - self._planned = None + self._spans.append((start, end, chord)) - # Keep a bounded history so long sessions don't grow without limit. - while len(self._spans) > 64: - self._spans.pop(0) + if self._planned is not None and self._planned[0] < end: + self._planned = None - def set_planned (self, start: float, end: float, chord: typing.Any) -> None: + # Keep a bounded history so long sessions don't grow without limit. + while len(self._spans) > 64: + self._spans.pop(0) - """Publish the live engine's pre-committed next step.""" + def set_planned(self, start: float, end: float, chord: typing.Any) -> None: + """Publish the live engine's pre-committed next step.""" - self._planned = (start, end, chord) + self._planned = (start, end, chord) - def set_future (self, fn: typing.Optional[typing.Callable[[float], typing.Optional[typing.Tuple[float, float, typing.Any]]]]) -> None: + def set_future( + self, + fn: typing.Optional[ + typing.Callable[ + [float], typing.Optional[typing.Tuple[float, float, typing.Any]] + ] + ], + ) -> None: + """Install (or clear) the data-source lookup for beats beyond the committed spans.""" - """Install (or clear) the data-source lookup for beats beyond the committed spans.""" + self._future = fn - self._future = fn + def invalidate_future(self) -> None: + """Drop everything not yet sounding — the next clock fire recomputes it. - def invalidate_future (self) -> None: + Called on every supported intervention: a ``harmony()`` re-call, + ``form_jump``/``form_next``, a re-bind, a new pin. ``next_chord`` + is planned and revocable; this is the revocation. + """ - """Drop everything not yet sounding — the next clock fire recomputes it. + self._future = None + self._planned = None - Called on every supported intervention: a ``harmony()`` re-call, - ``form_jump``/``form_next``, a re-bind, a new pin. ``next_chord`` - is planned and revocable; this is the revocation. - """ + def span_at( + self, beat: float + ) -> typing.Optional[typing.Tuple[float, float, typing.Any]]: + """The realised ``(start, end, chord)`` covering *beat*, or None if unknown.""" - self._future = None - self._planned = None + for start, end, chord in reversed(self._spans): + if start - 1e-9 <= beat < end - 1e-9: + return (start, end, chord) - def span_at (self, beat: float) -> typing.Optional[typing.Tuple[float, float, typing.Any]]: + if self._spans and beat >= self._spans[-1][1] - 1e-9: + if self._future is not None: + found = self._future(beat) + if found is not None: + return found - """The realised ``(start, end, chord)`` covering *beat*, or None if unknown.""" + if ( + self._planned is not None + and self._planned[0] - 1e-9 <= beat < self._planned[1] - 1e-9 + ): + return self._planned - for start, end, chord in reversed(self._spans): - if start - 1e-9 <= beat < end - 1e-9: - return (start, end, chord) + return None - if self._spans and beat >= self._spans[-1][1] - 1e-9: + def chord_at(self, beat: float) -> typing.Optional[typing.Any]: + """The chord sounding at *beat* — clamping to the last known chord beyond the window.""" - if self._future is not None: - found = self._future(beat) - if found is not None: - return found + span = self.span_at(beat) - if self._planned is not None and self._planned[0] - 1e-9 <= beat < self._planned[1] - 1e-9: - return self._planned + if span is not None: + return span[2] - return None + if not self._spans: + return None - def chord_at (self, beat: float) -> typing.Optional[typing.Any]: + if beat < self._spans[0][0]: + return self._spans[0][2] - """The chord sounding at *beat* — clamping to the last known chord beyond the window.""" + if not self._warned_beyond: + self._warned_beyond = True + logger.warning( + "chord_at(%.2f) is beyond the harmony window — clamping to the last known chord. " + "In live graph mode only [current, next] is committed; bind a progression for a data future.", + beat, + ) - span = self.span_at(beat) + if self._planned is not None and beat >= self._planned[1] - 1e-9: + return self._planned[2] - if span is not None: - return span[2] + return self._spans[-1][2] - if not self._spans: - return None + def boundary_after(self, beat: float) -> typing.Optional[float]: + """The absolute beat of the next chord boundary after *beat*, when known.""" - if beat < self._spans[0][0]: - return self._spans[0][2] + span = self.span_at(beat) - if not self._warned_beyond: - self._warned_beyond = True - logger.warning( - "chord_at(%.2f) is beyond the harmony window — clamping to the last known chord. " - "In live graph mode only [current, next] is committed; bind a progression for a data future.", - beat, - ) + return None if span is None else span[1] - if self._planned is not None and beat >= self._planned[1] - 1e-9: - return self._planned[2] + def next_chord_after(self, beat: float) -> typing.Optional[typing.Any]: + """The chord that follows the one sounding at *beat* — None when unknown (no clamping).""" - return self._spans[-1][2] + boundary = self.boundary_after(beat) - def boundary_after (self, beat: float) -> typing.Optional[float]: + if boundary is None: + return None - """The absolute beat of the next chord boundary after *beat*, when known.""" + following = self.span_at(boundary) - span = self.span_at(beat) + return None if following is None else following[2] - return None if span is None else span[1] + def latest_chord(self) -> typing.Optional[typing.Any]: + """The most recently committed chord (compatibility accessor).""" - def next_chord_after (self, beat: float) -> typing.Optional[typing.Any]: - - """The chord that follows the one sounding at *beat* — None when unknown (no clamping).""" - - boundary = self.boundary_after(beat) - - if boundary is None: - return None - - following = self.span_at(boundary) - - return None if following is None else following[2] - - def latest_chord (self) -> typing.Optional[typing.Any]: - - """The most recently committed chord (compatibility accessor).""" - - return self._spans[-1][2] if self._spans else None + return self._spans[-1][2] if self._spans else None class HarmonyView: + """Read-only harmony context for one pattern cycle (``p.harmony``). - """Read-only harmony context for one pattern cycle (``p.harmony``). - - Anchored at the cycle's start beat, so all beat arguments are - cycle-relative — ``chord_at(0)`` is the chord at the cycle's first beat - (what the two-parameter ``chord`` convention injects), ``chord_at(3.5)`` - the chord sounding under beat 3.5 of this cycle. + Anchored at the cycle's start beat, so all beat arguments are + cycle-relative — ``chord_at(0)`` is the chord at the cycle's first beat + (what the two-parameter ``chord`` convention injects), ``chord_at(3.5)`` + the chord sounding under beat 3.5 of this cycle. - Under bound/frozen progressions the future is data and any beat - answers; in live graph mode the window is the current chord plus one - pre-committed step, and ``next_chord`` is *planned and revocable*. - """ + Under bound/frozen progressions the future is data and any beat + answers; in live graph mode the window is the current chord plus one + pre-committed step, and ``next_chord`` is *planned and revocable*. + """ - def __init__ (self, horizon: _HarmonyHorizon, origin_beat: float) -> None: + def __init__(self, horizon: _HarmonyHorizon, origin_beat: float) -> None: + """Anchor the view at a cycle-start beat.""" - """Anchor the view at a cycle-start beat.""" + self._horizon = horizon + self._origin = origin_beat - self._horizon = horizon - self._origin = origin_beat + @property + def chord(self) -> typing.Optional[typing.Any]: + """The chord at this cycle's start (the cycle-start snapshot).""" - @property - def chord (self) -> typing.Optional[typing.Any]: + return self._horizon.chord_at(self._origin) - """The chord at this cycle's start (the cycle-start snapshot).""" + def chord_at(self, beat: float) -> typing.Optional[typing.Any]: + """The chord sounding at *beat* of THIS cycle (0-based beats).""" - return self._horizon.chord_at(self._origin) + return self._horizon.chord_at(self._origin + beat) - def chord_at (self, beat: float) -> typing.Optional[typing.Any]: + @property + def next_chord(self) -> typing.Optional[typing.Any]: + """The chord after the current one — for anticipation and approach tones.""" - """The chord sounding at *beat* of THIS cycle (0-based beats).""" + return self._horizon.next_chord_after(self._origin) - return self._horizon.chord_at(self._origin + beat) + def next_chord_at(self, beat: float) -> typing.Optional[typing.Any]: + """The chord following the one sounding at *beat* of THIS cycle, when known.""" - @property - def next_chord (self) -> typing.Optional[typing.Any]: + return self._horizon.next_chord_after(self._origin + beat) - """The chord after the current one — for anticipation and approach tones.""" + @property + def until_change(self) -> typing.Optional[float]: + """Beats from the cycle start until the next chord boundary, when known.""" - return self._horizon.next_chord_after(self._origin) + boundary = self._horizon.boundary_after(self._origin) - def next_chord_at (self, beat: float) -> typing.Optional[typing.Any]: + return None if boundary is None else boundary - self._origin - """The chord following the one sounding at *beat* of THIS cycle, when known.""" - return self._horizon.next_chord_after(self._origin + beat) +def _span_chord(span: subsequence.progressions.ChordSpan) -> typing.Any: + """The chord a span presents to patterns — decorated where spiced, bare otherwise.""" - @property - def until_change (self) -> typing.Optional[float]: + if span.is_decorated: + return subsequence.progressions.DecoratedChord(span) - """Beats from the cycle start until the next chord boundary, when known.""" + return span.chord - boundary = self._horizon.boundary_after(self._origin) - return None if boundary is None else boundary - self._origin +def _bare_chord(chord_like: typing.Any) -> typing.Any: + """The engine-currency chord under a possibly-decorated chord.""" + if isinstance(chord_like, subsequence.progressions.DecoratedChord): + return chord_like.base -def _span_chord (span: subsequence.progressions.ChordSpan) -> typing.Any: + return chord_like - """The chord a span presents to patterns — decorated where spiced, bare otherwise.""" - if span.is_decorated: - return subsequence.progressions.DecoratedChord(span) - - return span.chord - - -def _bare_chord (chord_like: typing.Any) -> typing.Any: - - """The engine-currency chord under a possibly-decorated chord.""" - - if isinstance(chord_like, subsequence.progressions.DecoratedChord): - return chord_like.base - - return chord_like - - -async def schedule_harmonic_clock ( - sequencer: subsequence.sequencer.Sequencer, - get_harmonic_state: typing.Callable[[], typing.Optional[subsequence.harmonic_state.HarmonicState]], - horizon: typing.Optional[_HarmonyHorizon] = None, - bar_beats: float = 4.0, - cycle_beats: int = 4, - get_cycle_beats: typing.Optional[typing.Callable[[], int]] = None, - get_bound_progression: typing.Optional[typing.Callable[[], typing.Optional["Progression"]]] = None, - get_section_progression: typing.Optional[ - typing.Callable[[], typing.Optional[typing.Tuple[str, int, int, typing.Optional["Progression"]]]] - ] = None, - get_pinned: typing.Optional[typing.Callable[[int], typing.Optional[typing.Any]]] = None, - cadence_requests: typing.Optional[typing.Dict[int, str]] = None, - resolve_cadence: typing.Optional[typing.Callable[[str], typing.List[subsequence.chords.Chord]]] = None, - get_section_cadence: typing.Optional[typing.Callable[[str], typing.Optional[str]]] = None, - reschedule_lookahead: float = 1, +async def schedule_harmonic_clock( + sequencer: subsequence.sequencer.Sequencer, + get_harmonic_state: typing.Callable[ + [], typing.Optional[subsequence.harmonic_state.HarmonicState] + ], + horizon: typing.Optional[_HarmonyHorizon] = None, + bar_beats: float = 4.0, + cycle_beats: int = 4, + get_cycle_beats: typing.Optional[typing.Callable[[], int]] = None, + get_bound_progression: typing.Optional[ + typing.Callable[[], typing.Optional["Progression"]] + ] = None, + get_section_progression: typing.Optional[ + typing.Callable[ + [], + typing.Optional[ + typing.Tuple[str, int, int, typing.Optional["Progression"]] + ], + ] + ] = None, + get_pinned: typing.Optional[ + typing.Callable[[int], typing.Optional[typing.Any]] + ] = None, + cadence_requests: typing.Optional[typing.Dict[int, str]] = None, + resolve_cadence: typing.Optional[ + typing.Callable[[str], typing.List[subsequence.chords.Chord]] + ] = None, + get_section_cadence: typing.Optional[ + typing.Callable[[str], typing.Optional[str]] + ] = None, + reschedule_lookahead: float = 1, ) -> None: - - """Schedule the harmonic clock — a span walker over the bound harmony sources. - - Generalises the old fixed-cycle clock: chords last as long as their - spans say, the clock fires at ``min(next span boundary, next bar - boundary)`` (so section bookkeeping stays bar-aligned under variable - harmonic rhythm), and every realised span is published to *horizon* - (the harmony window patterns read through ``p.harmony``). - - Priority chain per chord boundary: **section progression > - composition-bound progression > live ``step()``**. A bound progression - loops on exhaustion when no live engine is configured (or when it - contains a :class:`~subsequence.progressions.PitchSet`); with a live - engine, exhaustion falls through to live stepping — the frozen-replay - bridge. In live mode the engine pre-commits one step so the window - always holds ``[current, next]``. - - ``get_harmonic_state``, ``get_bound_progression``, and ``get_pinned`` - are evaluated on every tick so mid-playback calls to ``harmony()``, - re-binds, and new pins take effect immediately. ``get_section_progression`` - returns ``(name, index, bars, Progression|None)`` for the current section - (``index`` increments on every entry, so verse→verse re-entry resets - correctly) or ``None`` when no form is active. - - ``cadence_requests`` is the request-hook seam: a mutable ``{bar: name}`` - dict (shared with ``Composition.request_cadence``) the live walk steers - toward — at the first boundary with a pending request, the remaining - changes up to its bar are planned as a constrained walk pinned to the - cadence formula (resolved by ``resolve_cadence``) and then committed - one boundary at a time. ``get_section_cadence`` turns a section entry - into a request arriving at that section's final bar (live sections - only). Requests whose bar passes unserved expire with a warning. - - The clock fires ``reschedule_lookahead`` beats before each boundary — - raised by the caller to the maximum pattern lookahead, so the window - always covers a pattern's next cycle before it rebuilds. - - ``horizon`` may be omitted for direct use without a :class:`Composition` - (a private window is created — ``p.harmony`` inside a Composition needs - the composition's own horizon). ``get_cycle_beats``, when given, is - re-read at every boundary so a mid-playback ``harmony(cycle_beats=…)`` - re-call takes effect like the other getter-based parameters; the plain - ``cycle_beats`` value is the fixed fallback. - """ - - if horizon is None: - horizon = _HarmonyHorizon() - - def _cycle_beats_now () -> float: - return float(get_cycle_beats() if get_cycle_beats is not None else cycle_beats) - - pulses_per_beat = sequencer.pulses_per_beat - - state: typing.Dict[str, typing.Any] = { - "next_change": 0.0, # absolute beat of the next chord boundary - "last_section_index": None, - "section_anchor": 0.0, # beat the current section entered - "section_exhausted": False, - "bound_anchor": 0.0, # beat the bound progression was first walked from - "bound_seen": None, # identity of the bound progression last walked - "bound_exhausted": False, - "planned": None, # the live engine's pre-committed next chord - "cadence_queue": [], # planned approach chords (None = step live at that boundary) - } - - def _plan_cadence_request (beat: float, hs: subsequence.harmonic_state.HarmonicState) -> None: - - """Compile the nearest pending cadence request into the approach queue. - - A live freeze-ahead: a constrained walk from the engine's current - chord to the request's bar, pinned to the cadence formula at the - tail, drawn through the engine's real weights on the play stream. - The engine's state is snapshot-restored — chords commit one by one - as their boundaries actually sound. An unwalkable formula falls - back to fiat (live steps up to the approach, the formula committed - at its bars), loudly. - """ - - if not cadence_requests or resolve_cadence is None: - return - - cb = _cycle_beats_now() - target_bar = min(cadence_requests) - target_beat = (target_bar - 1) * bar_beats - - if target_beat < beat - 1e-9: - return # stale; the expiry pass warns and drops it - - name = cadence_requests.pop(target_bar) - - try: - formula = resolve_cadence(name) - except (ValueError, TypeError) as error: - logger.warning(f"cadence request {name!r} at bar {target_bar} cannot resolve: {error}") - return - - remaining = (target_beat - beat) / cb - steps = int(remaining + 1e-9) + 1 # chord changes from here to the arrival, inclusive - - if abs(remaining - round(remaining)) > 1e-9: - logger.warning( - f"cadence request {name!r}: bar {target_bar} does not land on a chord " - f"boundary ({cb:g}-beat cycles) — the arrival sounds at the boundary before it" - ) - - tail = list(formula[-steps:]) - - if steps < len(formula): - logger.warning( - f"cadence request {name!r} at bar {target_bar}: only {steps} chord change(s) " - f"before the arrival — approaching with the formula's tail alone" - ) - - length = steps + 1 # walk position 1 is the chord sounding now - pins = {length - len(tail) + 1 + index: chord for index, chord in enumerate(tail)} - - saved_history = list(hs.history) - saved_current = hs.current_chord - - def _commit (chosen: subsequence.chords.Chord) -> None: - hs.current_chord = chosen - - try: - walked = subsequence.sequence_utils.constrained_walk( - hs.graph, - hs.current_chord, - length, - rng = hs.rng, - pins = pins, - weight_modifier = hs._transition_weight, - before_choice = hs._record_transition_source, - after_choice = _commit, - ) - except ValueError as error: - logger.warning( - f"cadence request {name!r} at bar {target_bar} is not walkable from " - f"{saved_current.name()} ({error}) — the arrival lands by fiat" - ) - state["cadence_queue"] = [None] * (steps - len(tail)) + tail - return - finally: - hs.history = saved_history - hs.current_chord = saved_current - - state["cadence_queue"] = list(walked[1:]) - - def _data_future ( - progression: "Progression", - anchor: float, - loops: bool, - ) -> typing.Callable[[float], typing.Optional[typing.Tuple[float, float, typing.Any]]]: - - """A horizon future fn computing spans arithmetically from a data source.""" - - def future (beat: float) -> typing.Optional[typing.Tuple[float, float, typing.Any]]: - - offset = beat - anchor - - if offset < -1e-9: - return None - - if not loops and offset >= progression.length - 1e-9: - return None - - span, span_start, span_end = progression.span_at(offset) - cycle_base = anchor + (offset // progression.length) * progression.length - start = cycle_base + span_start - end = cycle_base + span_end - - chord = _span_chord(span) - - if get_pinned is not None: - pinned = get_pinned(int(start // bar_beats) + 1) - if pinned is not None: - chord = pinned - - return (start, end, chord) - - return future - - def advance (beat: float) -> typing.Optional[float]: - - """Prepare the boundary at *beat*; return beats to the next fire (or None to stop).""" - - hs = get_harmonic_state() - initial = beat == 0.0 and horizon.is_empty - - # --- Section bookkeeping (every fire is bar-aligned or a span boundary, - # and the form clock fired first at this pulse, so the info is current). - section_progression: typing.Optional["Progression"] = None - - if get_section_progression is not None: - info = get_section_progression() - if info is not None: - _section_name, section_index, section_bars, section_progression = info - - if section_index != state["last_section_index"]: - state["last_section_index"] = section_index - state["section_anchor"] = beat - state["section_exhausted"] = False - state["next_change"] = beat # a section entry forces a chord decision - horizon.invalidate_future() - state["planned"] = None - - # Restore the NIR context that was current when this - # progression was frozen, so every replay starts alike. - if section_progression is not None and section_progression.trailing_history and hs is not None: - hs.history = list(section_progression.trailing_history) - - # A registered section cadence becomes a bar request: the - # arrival lands on this section's final bar. Live sections - # only — bound chords are data and cannot be steered. - if ( - get_section_cadence is not None - and cadence_requests is not None - and section_progression is None - and section_bars > 0 - ): - section_cadence_name = get_section_cadence(_section_name) - if section_cadence_name is not None: - entry_bar = int(beat // bar_beats) + 1 - cadence_requests.setdefault(entry_bar + section_bars - 1, section_cadence_name) - - # Cadence requests expire when their bar passes unserved — harmony was - # data-bound the whole approach, or the request arrived too late. - if cadence_requests: - for expired_bar in [b for b in cadence_requests if (b - 1) * bar_beats < beat - 1e-9]: - expired_name = cadence_requests.pop(expired_bar) - logger.warning( - f"cadence request {expired_name!r} at bar {expired_bar} expired unserved — " - "the bar passed while harmony was data-bound, or the request arrived too late" - ) - - bound_progression = get_bound_progression() if get_bound_progression is not None else None - - if bound_progression is not None and state["bound_seen"] is not bound_progression: - # First sighting (or a re-bind): anchor the walk here and forget exhaustion. - state["bound_seen"] = bound_progression - state["bound_anchor"] = beat - state["bound_exhausted"] = False - horizon.invalidate_future() - - chord_boundary = beat >= state["next_change"] - 1e-9 - - if not chord_boundary and get_pinned is not None: - - # Fiat inside a longer span: a pinned bar forces its chord at the - # bar line, overriding the sounding span until the next change. - pinned_now = get_pinned(int(beat // bar_beats) + 1) - - if pinned_now is not None and horizon.chord_at(beat) is not pinned_now: - - bare_pin = _bare_chord(pinned_now) - - if hs is not None and isinstance(bare_pin, subsequence.chords.Chord): - hs.commit_chord(bare_pin) - - horizon.commit(beat, state["next_change"], pinned_now) - - if chord_boundary: - - chord_like: typing.Optional[typing.Any] = None - span_beats: typing.Optional[float] = None - from_live = False - - # Priority 1: the current section's progression. - if section_progression is not None and not state["section_exhausted"]: - - offset = beat - state["section_anchor"] - loops = hs is None or section_progression.loops_on_exhaustion - - if offset >= section_progression.length - 1e-9 and not loops: - state["section_exhausted"] = True # fall through to live stepping - else: - span, span_start, span_end = section_progression.span_at(offset) - chord_like = _span_chord(span) - span_beats = span_end - (offset % section_progression.length) - horizon.set_future(_data_future(section_progression, state["section_anchor"], loops)) - - # Priority 2: the composition-bound progression. - if chord_like is None and bound_progression is not None and not state["bound_exhausted"]: - - offset = beat - state["bound_anchor"] - loops = hs is None or bound_progression.loops_on_exhaustion - - if offset >= bound_progression.length - 1e-9 and not loops: - state["bound_exhausted"] = True # the frozen-replay bridge: live from here - else: - span, span_start, span_end = bound_progression.span_at(offset) - chord_like = _span_chord(span) - span_beats = span_end - (offset % bound_progression.length) - horizon.set_future(_data_future(bound_progression, state["bound_anchor"], loops)) - - # Priority 3: live graph stepping. - if chord_like is None: - - if hs is None: - return None # nothing left to drive the clock - - if initial: - chord_like = hs.current_chord # the tonic sounds first; no step at beat 0 - else: - if not state["cadence_queue"]: - _plan_cadence_request(beat, hs) - - queued: typing.Optional[typing.Any] = None - - if state["cadence_queue"]: - queued = state["cadence_queue"].pop(0) - - if queued is not None: - # A planned approach supersedes the pre-committed step. - state["planned"] = None - chord_like = queued - else: - if state["planned"] is None: - state["planned"] = hs.plan_next() - chord_like = state["planned"] - state["planned"] = None - - span_beats = _cycle_beats_now() - from_live = True - horizon.set_future(None) - - # Pins are fiat — they override whatever the source produced. - if get_pinned is not None: - pinned = get_pinned(int(beat // bar_beats) + 1) - if pinned is not None: - chord_like = pinned - - assert span_beats is not None # every branch above either set it or returned - - # Sync the engine so freeze()/NIR/live fall-through stay coherent. - bare = _bare_chord(chord_like) - - if hs is not None and isinstance(bare, subsequence.chords.Chord): - if initial: - hs.current_chord = bare - elif from_live or bare is not hs.current_chord: - hs.commit_chord(bare) - - horizon.commit(beat, beat + span_beats, chord_like) - state["next_change"] = beat + span_beats - - # Live mode pre-commits one step so the window holds [current, next]. - # A planned cadence approach already knows its next chord — publish - # it without drawing (a fiat gap, queue head None, plans normally). - if from_live and hs is not None: - if state["cadence_queue"] and state["cadence_queue"][0] is not None: - horizon.set_planned(state["next_change"], state["next_change"] + _cycle_beats_now(), state["cadence_queue"][0]) - else: - state["planned"] = hs.plan_next() - horizon.set_planned(state["next_change"], state["next_change"] + _cycle_beats_now(), state["planned"]) - - # Fire again at the earlier of the next chord change and the next bar - # line — bar fires keep section bookkeeping aligned under long spans. - next_bar = (beat // bar_beats) * bar_beats + bar_beats - if next_bar <= beat + 1e-9: - next_bar = beat + bar_beats - - next_fire = min(float(state["next_change"]), next_bar) - - return max(next_fire - beat, 1.0 / pulses_per_beat) - - def advance_pulse (boundary_pulse: int) -> typing.Optional[float]: - - """The sequencer-facing callback: pulses in, beats out.""" - - return advance(boundary_pulse / pulses_per_beat) - - # Populate the window for beat 0 synchronously, BEFORE patterns first - # build, then schedule the walker from the first boundary it reported. - first_interval = advance(0.0) - - if first_interval is None: - return - - await sequencer.schedule_callback_sequence( - callback = advance_pulse, - start_pulse = int(first_interval * pulses_per_beat), - reschedule_lookahead = reschedule_lookahead, - ) - - -def _make_safe_callback (fn: typing.Callable, accepts_context: bool = False, start_cycle: int = 0) -> typing.Callable[[int], None]: - - """Wrap a user function as a fire-and-forget callback that never blocks the clock. - - If *accepts_context* is True, ``fn`` is called with a :class:`ScheduleContext` - whose ``cycle`` field increments on every invocation. - """ - - is_async = inspect.iscoroutinefunction(fn) - cycle_count: typing.List[int] = [start_cycle] # mutable cell so the closure can mutate it - - async def _execute (cycle: int) -> None: - - """Run the user function with error handling and optional threading.""" - - ctx = ScheduleContext(cycle=cycle) - - try: - - if is_async: - await (fn(ctx) if accepts_context else fn()) - - else: - loop = asyncio.get_running_loop() - call = (lambda: fn(ctx)) if accepts_context else fn - await loop.run_in_executor(None, call) - - except Exception as exc: - logger.warning(f"Scheduled task {getattr(fn, '__name__', repr(fn))!r} failed: {exc}") - - def wrapper (pulse: int) -> None: - - """Spawn the task in the background without blocking the sequencer.""" - - # Capture the cycle number synchronously before any async yield so that - # even if multiple pulses fire before the event loop runs, each task - # receives the correct cycle value it was triggered at. - current_cycle = cycle_count[0] - cycle_count[0] += 1 - asyncio.create_task(_execute(current_cycle)) - - return wrapper - - -async def schedule_task ( - sequencer: subsequence.sequencer.Sequencer, - fn: typing.Callable, - cycle_beats: int, - reschedule_lookahead: int = 1, - defer: bool = False + """Schedule the harmonic clock — a span walker over the bound harmony sources. + + Generalises the old fixed-cycle clock: chords last as long as their + spans say, the clock fires at ``min(next span boundary, next bar + boundary)`` (so section bookkeeping stays bar-aligned under variable + harmonic rhythm), and every realised span is published to *horizon* + (the harmony window patterns read through ``p.harmony``). + + Priority chain per chord boundary: **section progression > + composition-bound progression > live ``step()``**. A bound progression + loops on exhaustion when no live engine is configured (or when it + contains a :class:`~subsequence.progressions.PitchSet`); with a live + engine, exhaustion falls through to live stepping — the frozen-replay + bridge. In live mode the engine pre-commits one step so the window + always holds ``[current, next]``. + + ``get_harmonic_state``, ``get_bound_progression``, and ``get_pinned`` + are evaluated on every tick so mid-playback calls to ``harmony()``, + re-binds, and new pins take effect immediately. ``get_section_progression`` + returns ``(name, index, bars, Progression|None)`` for the current section + (``index`` increments on every entry, so verse→verse re-entry resets + correctly) or ``None`` when no form is active. + + ``cadence_requests`` is the request-hook seam: a mutable ``{bar: name}`` + dict (shared with ``Composition.request_cadence``) the live walk steers + toward — at the first boundary with a pending request, the remaining + changes up to its bar are planned as a constrained walk pinned to the + cadence formula (resolved by ``resolve_cadence``) and then committed + one boundary at a time. ``get_section_cadence`` turns a section entry + into a request arriving at that section's final bar (live sections + only). Requests whose bar passes unserved expire with a warning. + + The clock fires ``reschedule_lookahead`` beats before each boundary — + raised by the caller to the maximum pattern lookahead, so the window + always covers a pattern's next cycle before it rebuilds. + + ``horizon`` may be omitted for direct use without a :class:`Composition` + (a private window is created — ``p.harmony`` inside a Composition needs + the composition's own horizon). ``get_cycle_beats``, when given, is + re-read at every boundary so a mid-playback ``harmony(cycle_beats=…)`` + re-call takes effect like the other getter-based parameters; the plain + ``cycle_beats`` value is the fixed fallback. + """ + + if horizon is None: + horizon = _HarmonyHorizon() + + def _cycle_beats_now() -> float: + return float(get_cycle_beats() if get_cycle_beats is not None else cycle_beats) + + pulses_per_beat = sequencer.pulses_per_beat + + state: typing.Dict[str, typing.Any] = { + "next_change": 0.0, # absolute beat of the next chord boundary + "last_section_index": None, + "section_anchor": 0.0, # beat the current section entered + "section_exhausted": False, + "bound_anchor": 0.0, # beat the bound progression was first walked from + "bound_seen": None, # identity of the bound progression last walked + "bound_exhausted": False, + "planned": None, # the live engine's pre-committed next chord + "cadence_queue": [], # planned approach chords (None = step live at that boundary) + } + + def _plan_cadence_request( + beat: float, hs: subsequence.harmonic_state.HarmonicState + ) -> None: + """Compile the nearest pending cadence request into the approach queue. + + A live freeze-ahead: a constrained walk from the engine's current + chord to the request's bar, pinned to the cadence formula at the + tail, drawn through the engine's real weights on the play stream. + The engine's state is snapshot-restored — chords commit one by one + as their boundaries actually sound. An unwalkable formula falls + back to fiat (live steps up to the approach, the formula committed + at its bars), loudly. + """ + + if not cadence_requests or resolve_cadence is None: + return + + cb = _cycle_beats_now() + target_bar = min(cadence_requests) + target_beat = (target_bar - 1) * bar_beats + + if target_beat < beat - 1e-9: + return # stale; the expiry pass warns and drops it + + name = cadence_requests.pop(target_bar) + + try: + formula = resolve_cadence(name) + except (ValueError, TypeError) as error: + logger.warning( + f"cadence request {name!r} at bar {target_bar} cannot resolve: {error}" + ) + return + + remaining = (target_beat - beat) / cb + steps = ( + int(remaining + 1e-9) + 1 + ) # chord changes from here to the arrival, inclusive + + if abs(remaining - round(remaining)) > 1e-9: + logger.warning( + f"cadence request {name!r}: bar {target_bar} does not land on a chord " + f"boundary ({cb:g}-beat cycles) — the arrival sounds at the boundary before it" + ) + + tail = list(formula[-steps:]) + + if steps < len(formula): + logger.warning( + f"cadence request {name!r} at bar {target_bar}: only {steps} chord change(s) " + f"before the arrival — approaching with the formula's tail alone" + ) + + length = steps + 1 # walk position 1 is the chord sounding now + pins = { + length - len(tail) + 1 + index: chord for index, chord in enumerate(tail) + } + + saved_history = list(hs.history) + saved_current = hs.current_chord + + def _commit(chosen: subsequence.chords.Chord) -> None: + hs.current_chord = chosen + + try: + walked = subsequence.sequence_utils.constrained_walk( + hs.graph, + hs.current_chord, + length, + rng=hs.rng, + pins=pins, + weight_modifier=hs._transition_weight, + before_choice=hs._record_transition_source, + after_choice=_commit, + ) + except ValueError as error: + logger.warning( + f"cadence request {name!r} at bar {target_bar} is not walkable from " + f"{saved_current.name()} ({error}) — the arrival lands by fiat" + ) + state["cadence_queue"] = [None] * (steps - len(tail)) + tail + return + finally: + hs.history = saved_history + hs.current_chord = saved_current + + state["cadence_queue"] = list(walked[1:]) + + def _data_future( + progression: "Progression", + anchor: float, + loops: bool, + ) -> typing.Callable[ + [float], typing.Optional[typing.Tuple[float, float, typing.Any]] + ]: + """A horizon future fn computing spans arithmetically from a data source.""" + + def future( + beat: float, + ) -> typing.Optional[typing.Tuple[float, float, typing.Any]]: + offset = beat - anchor + + if offset < -1e-9: + return None + + if not loops and offset >= progression.length - 1e-9: + return None + + span, span_start, span_end = progression.span_at(offset) + cycle_base = anchor + (offset // progression.length) * progression.length + start = cycle_base + span_start + end = cycle_base + span_end + + chord = _span_chord(span) + + if get_pinned is not None: + pinned = get_pinned(int(start // bar_beats) + 1) + if pinned is not None: + chord = pinned + + return (start, end, chord) + + return future + + def advance(beat: float) -> typing.Optional[float]: + """Prepare the boundary at *beat*; return beats to the next fire (or None to stop).""" + + hs = get_harmonic_state() + initial = beat == 0.0 and horizon.is_empty + + # --- Section bookkeeping (every fire is bar-aligned or a span boundary, + # and the form clock fired first at this pulse, so the info is current). + section_progression: typing.Optional["Progression"] = None + + if get_section_progression is not None: + info = get_section_progression() + if info is not None: + _section_name, section_index, section_bars, section_progression = info + + if section_index != state["last_section_index"]: + state["last_section_index"] = section_index + state["section_anchor"] = beat + state["section_exhausted"] = False + state["next_change"] = ( + beat # a section entry forces a chord decision + ) + horizon.invalidate_future() + state["planned"] = None + + # Restore the NIR context that was current when this + # progression was frozen, so every replay starts alike. + if ( + section_progression is not None + and section_progression.trailing_history + and hs is not None + ): + hs.history = list(section_progression.trailing_history) + + # A registered section cadence becomes a bar request: the + # arrival lands on this section's final bar. Live sections + # only — bound chords are data and cannot be steered. + if ( + get_section_cadence is not None + and cadence_requests is not None + and section_progression is None + and section_bars > 0 + ): + section_cadence_name = get_section_cadence(_section_name) + if section_cadence_name is not None: + entry_bar = int(beat // bar_beats) + 1 + cadence_requests.setdefault( + entry_bar + section_bars - 1, section_cadence_name + ) + + # Cadence requests expire when their bar passes unserved — harmony was + # data-bound the whole approach, or the request arrived too late. + if cadence_requests: + for expired_bar in [ + b for b in cadence_requests if (b - 1) * bar_beats < beat - 1e-9 + ]: + expired_name = cadence_requests.pop(expired_bar) + logger.warning( + f"cadence request {expired_name!r} at bar {expired_bar} expired unserved — " + "the bar passed while harmony was data-bound, or the request arrived too late" + ) + + bound_progression = ( + get_bound_progression() if get_bound_progression is not None else None + ) + + if ( + bound_progression is not None + and state["bound_seen"] is not bound_progression + ): + # First sighting (or a re-bind): anchor the walk here and forget exhaustion. + state["bound_seen"] = bound_progression + state["bound_anchor"] = beat + state["bound_exhausted"] = False + horizon.invalidate_future() + + chord_boundary = beat >= state["next_change"] - 1e-9 + + if not chord_boundary and get_pinned is not None: + # Fiat inside a longer span: a pinned bar forces its chord at the + # bar line, overriding the sounding span until the next change. + pinned_now = get_pinned(int(beat // bar_beats) + 1) + + if pinned_now is not None and horizon.chord_at(beat) is not pinned_now: + bare_pin = _bare_chord(pinned_now) + + if hs is not None and isinstance(bare_pin, subsequence.chords.Chord): + hs.commit_chord(bare_pin) + + horizon.commit(beat, state["next_change"], pinned_now) + + if chord_boundary: + chord_like: typing.Optional[typing.Any] = None + span_beats: typing.Optional[float] = None + from_live = False + + # Priority 1: the current section's progression. + if section_progression is not None and not state["section_exhausted"]: + offset = beat - state["section_anchor"] + loops = hs is None or section_progression.loops_on_exhaustion + + if offset >= section_progression.length - 1e-9 and not loops: + state["section_exhausted"] = True # fall through to live stepping + else: + span, span_start, span_end = section_progression.span_at(offset) + chord_like = _span_chord(span) + span_beats = span_end - (offset % section_progression.length) + horizon.set_future( + _data_future( + section_progression, state["section_anchor"], loops + ) + ) + + # Priority 2: the composition-bound progression. + if ( + chord_like is None + and bound_progression is not None + and not state["bound_exhausted"] + ): + offset = beat - state["bound_anchor"] + loops = hs is None or bound_progression.loops_on_exhaustion + + if offset >= bound_progression.length - 1e-9 and not loops: + state["bound_exhausted"] = ( + True # the frozen-replay bridge: live from here + ) + else: + span, span_start, span_end = bound_progression.span_at(offset) + chord_like = _span_chord(span) + span_beats = span_end - (offset % bound_progression.length) + horizon.set_future( + _data_future(bound_progression, state["bound_anchor"], loops) + ) + + # Priority 3: live graph stepping. + if chord_like is None: + if hs is None: + return None # nothing left to drive the clock + + if initial: + chord_like = ( + hs.current_chord + ) # the tonic sounds first; no step at beat 0 + else: + if not state["cadence_queue"]: + _plan_cadence_request(beat, hs) + + queued: typing.Optional[typing.Any] = None + + if state["cadence_queue"]: + queued = state["cadence_queue"].pop(0) + + if queued is not None: + # A planned approach supersedes the pre-committed step. + state["planned"] = None + chord_like = queued + else: + if state["planned"] is None: + state["planned"] = hs.plan_next() + chord_like = state["planned"] + state["planned"] = None + + span_beats = _cycle_beats_now() + from_live = True + horizon.set_future(None) + + # Pins are fiat — they override whatever the source produced. + if get_pinned is not None: + pinned = get_pinned(int(beat // bar_beats) + 1) + if pinned is not None: + chord_like = pinned + + assert ( + span_beats is not None + ) # every branch above either set it or returned + + # Sync the engine so freeze()/NIR/live fall-through stay coherent. + bare = _bare_chord(chord_like) + + if hs is not None and isinstance(bare, subsequence.chords.Chord): + if initial: + hs.current_chord = bare + elif from_live or bare is not hs.current_chord: + hs.commit_chord(bare) + + horizon.commit(beat, beat + span_beats, chord_like) + state["next_change"] = beat + span_beats + + # Live mode pre-commits one step so the window holds [current, next]. + # A planned cadence approach already knows its next chord — publish + # it without drawing (a fiat gap, queue head None, plans normally). + if from_live and hs is not None: + if state["cadence_queue"] and state["cadence_queue"][0] is not None: + horizon.set_planned( + state["next_change"], + state["next_change"] + _cycle_beats_now(), + state["cadence_queue"][0], + ) + else: + state["planned"] = hs.plan_next() + horizon.set_planned( + state["next_change"], + state["next_change"] + _cycle_beats_now(), + state["planned"], + ) + + # Fire again at the earlier of the next chord change and the next bar + # line — bar fires keep section bookkeeping aligned under long spans. + next_bar = (beat // bar_beats) * bar_beats + bar_beats + if next_bar <= beat + 1e-9: + next_bar = beat + bar_beats + + next_fire = min(float(state["next_change"]), next_bar) + + return max(next_fire - beat, 1.0 / pulses_per_beat) + + def advance_pulse(boundary_pulse: int) -> typing.Optional[float]: + """The sequencer-facing callback: pulses in, beats out.""" + + return advance(boundary_pulse / pulses_per_beat) + + # Populate the window for beat 0 synchronously, BEFORE patterns first + # build, then schedule the walker from the first boundary it reported. + first_interval = advance(0.0) + + if first_interval is None: + return + + await sequencer.schedule_callback_sequence( + callback=advance_pulse, + start_pulse=int(first_interval * pulses_per_beat), + reschedule_lookahead=reschedule_lookahead, + ) + + +def _make_safe_callback( + fn: typing.Callable, accepts_context: bool = False, start_cycle: int = 0 +) -> typing.Callable[[int], None]: + """Wrap a user function as a fire-and-forget callback that never blocks the clock. + + If *accepts_context* is True, ``fn`` is called with a :class:`ScheduleContext` + whose ``cycle`` field increments on every invocation. + """ + + is_async = inspect.iscoroutinefunction(fn) + cycle_count: typing.List[int] = [ + start_cycle + ] # mutable cell so the closure can mutate it + + async def _execute(cycle: int) -> None: + """Run the user function with error handling and optional threading.""" + + ctx = ScheduleContext(cycle=cycle) + + try: + if is_async: + await (fn(ctx) if accepts_context else fn()) + + else: + loop = asyncio.get_running_loop() + call = (lambda: fn(ctx)) if accepts_context else fn + await loop.run_in_executor(None, call) + + except Exception as exc: + logger.warning( + f"Scheduled task {getattr(fn, '__name__', repr(fn))!r} failed: {exc}" + ) + + def wrapper(pulse: int) -> None: + """Spawn the task in the background without blocking the sequencer.""" + + # Capture the cycle number synchronously before any async yield so that + # even if multiple pulses fire before the event loop runs, each task + # receives the correct cycle value it was triggered at. + current_cycle = cycle_count[0] + cycle_count[0] += 1 + asyncio.create_task(_execute(current_cycle)) + + return wrapper + + +async def schedule_task( + sequencer: subsequence.sequencer.Sequencer, + fn: typing.Callable, + cycle_beats: int, + reschedule_lookahead: int = 1, + defer: bool = False, ) -> None: - - """Schedule a non-blocking repeating task on the sequencer's beat clock. - - If ``fn`` declares a first parameter named ``p``, it is called with a - :class:`ScheduleContext` on every invocation (same behaviour as - ``composition.schedule()``). - - When *defer* is True the backshift fire at pulse 0 is skipped; the first - call happens one full *cycle_beats* later. Direct API users who need the - equivalent of ``initial=True`` can simply ``await fn()`` themselves before - calling this function. - """ - - accepts_ctx = _fn_has_parameter(fn, "p") - wrapped = _make_safe_callback(fn, accepts_context=accepts_ctx) - start_pulse = int(cycle_beats * sequencer.pulses_per_beat) if defer else 0 - - await sequencer.schedule_callback_repeating( - callback = wrapped, - interval_beats = cycle_beats, - start_pulse = start_pulse, - reschedule_lookahead = reschedule_lookahead - ) - - -async def schedule_form ( - sequencer: subsequence.sequencer.Sequencer, - form_state: subsequence.form_state.FormState, - reschedule_lookahead: float = 1, - on_bar: typing.Optional[typing.Callable[[int, bool], None]] = None, - get_form_state: typing.Optional[typing.Callable[[], typing.Optional[subsequence.form_state.FormState]]] = None, + """Schedule a non-blocking repeating task on the sequencer's beat clock. + + If ``fn`` declares a first parameter named ``p``, it is called with a + :class:`ScheduleContext` on every invocation (same behaviour as + ``composition.schedule()``). + + When *defer* is True the backshift fire at pulse 0 is skipped; the first + call happens one full *cycle_beats* later. Direct API users who need the + equivalent of ``initial=True`` can simply ``await fn()`` themselves before + calling this function. + """ + + accepts_ctx = _fn_has_parameter(fn, "p") + wrapped = _make_safe_callback(fn, accepts_context=accepts_ctx) + start_pulse = int(cycle_beats * sequencer.pulses_per_beat) if defer else 0 + + await sequencer.schedule_callback_repeating( + callback=wrapped, + interval_beats=cycle_beats, + start_pulse=start_pulse, + reschedule_lookahead=reschedule_lookahead, + ) + + +async def schedule_form( + sequencer: subsequence.sequencer.Sequencer, + form_state: subsequence.form_state.FormState, + reschedule_lookahead: float = 1, + on_bar: typing.Optional[typing.Callable[[int, bool], None]] = None, + get_form_state: typing.Optional[ + typing.Callable[[], typing.Optional[subsequence.form_state.FormState]] + ] = None, ) -> None: - - """Schedule the form state to advance each bar. - - Emits a ``"section"`` event on the sequencer's emitter at play start - and on every section change (one lookahead-beat early, like every form - decision), carrying the new :class:`~subsequence.form_state.SectionInfo` - (``None`` when the form finishes). ``on_bar`` is the boundary hook — - called once per bar with ``(boundary_pulse, section_changed)`` after the - form advances; the transition machinery rides it. - - ``get_form_state``, when given, is re-read every bar — so a mid-playback - ``form()`` re-bind advances the NEW form state from the next bar instead - of silently driving the abandoned object forever. ``form_state`` is the - fixed fallback for direct use. - """ - - lookahead_pulses = int(reschedule_lookahead * sequencer.pulses_per_beat) - - def _current_form_state () -> typing.Optional[subsequence.form_state.FormState]: - return get_form_state() if get_form_state is not None else form_state - - # Log and announce the initial section. - initial_form = _current_form_state() - initial_section = initial_form.get_section_info() if initial_form is not None else None - if initial_section: - logger.info(f"Form: {initial_section.name}") - sequencer.events.emit_sync("section", initial_section) - - if on_bar is not None: - on_bar(0, True) # the first bar is a boundary too (a 1-bar opener can end) - - def advance_form (pulse: int) -> None: - - """Advance the form by one bar, logging and announcing section changes.""" - - fs = _current_form_state() - - if fs is None: - if on_bar is not None: - on_bar(pulse + lookahead_pulses, False) - return - - section_changed = fs.advance() - - if section_changed: - section = fs.get_section_info() - if section: - logger.info(f"Form: {section.name}") - else: - logger.info("Form: finished") - sequencer.events.emit_sync("section", section) - - if on_bar is not None: - # Fixed callbacks fire lookahead-early; the bar line itself is - # lookahead pulses ahead of the fire pulse. - on_bar(pulse + lookahead_pulses, section_changed) - - # Form advances once per bar based on the global time signature. - _BEATS_PER_BAR: int = sequencer.time_signature[0] - first_bar_pulse = int(_BEATS_PER_BAR * sequencer.pulses_per_beat) - - await sequencer.schedule_callback_repeating( - callback = advance_form, - interval_beats = _BEATS_PER_BAR, - start_pulse = first_bar_pulse, - reschedule_lookahead = reschedule_lookahead - ) - - -async def schedule_patterns ( - sequencer: subsequence.sequencer.Sequencer, - patterns: typing.Iterable[subsequence.pattern.Pattern], - start_pulse: int = 0 + """Schedule the form state to advance each bar. + + Emits a ``"section"`` event on the sequencer's emitter at play start + and on every section change (one lookahead-beat early, like every form + decision), carrying the new :class:`~subsequence.form_state.SectionInfo` + (``None`` when the form finishes). ``on_bar`` is the boundary hook — + called once per bar with ``(boundary_pulse, section_changed)`` after the + form advances; the transition machinery rides it. + + ``get_form_state``, when given, is re-read every bar — so a mid-playback + ``form()`` re-bind advances the NEW form state from the next bar instead + of silently driving the abandoned object forever. ``form_state`` is the + fixed fallback for direct use. + """ + + lookahead_pulses = int(reschedule_lookahead * sequencer.pulses_per_beat) + + def _current_form_state() -> typing.Optional[subsequence.form_state.FormState]: + return get_form_state() if get_form_state is not None else form_state + + # Log and announce the initial section. + initial_form = _current_form_state() + initial_section = ( + initial_form.get_section_info() if initial_form is not None else None + ) + if initial_section: + logger.info(f"Form: {initial_section.name}") + sequencer.events.emit_sync("section", initial_section) + + if on_bar is not None: + on_bar(0, True) # the first bar is a boundary too (a 1-bar opener can end) + + def advance_form(pulse: int) -> None: + """Advance the form by one bar, logging and announcing section changes.""" + + fs = _current_form_state() + + if fs is None: + if on_bar is not None: + on_bar(pulse + lookahead_pulses, False) + return + + section_changed = fs.advance() + + if section_changed: + section = fs.get_section_info() + if section: + logger.info(f"Form: {section.name}") + else: + logger.info("Form: finished") + sequencer.events.emit_sync("section", section) + + if on_bar is not None: + # Fixed callbacks fire lookahead-early; the bar line itself is + # lookahead pulses ahead of the fire pulse. + on_bar(pulse + lookahead_pulses, section_changed) + + # Form advances once per bar based on the global time signature. + _BEATS_PER_BAR: int = sequencer.time_signature[0] + first_bar_pulse = int(_BEATS_PER_BAR * sequencer.pulses_per_beat) + + await sequencer.schedule_callback_repeating( + callback=advance_form, + interval_beats=_BEATS_PER_BAR, + start_pulse=first_bar_pulse, + reschedule_lookahead=reschedule_lookahead, + ) + + +async def schedule_patterns( + sequencer: subsequence.sequencer.Sequencer, + patterns: typing.Iterable[subsequence.pattern.Pattern], + start_pulse: int = 0, ) -> None: + """ + Schedule a collection of repeating patterns from a shared start pulse. + """ - """ - Schedule a collection of repeating patterns from a shared start pulse. - """ - - for pattern in patterns: - await sequencer.schedule_pattern_repeating(pattern, start_pulse=start_pulse) - + for pattern in patterns: + await sequencer.schedule_pattern_repeating(pattern, start_pulse=start_pulse) -async def run_until_stopped (sequencer: subsequence.sequencer.Sequencer) -> None: - """ - Run the sequencer until a stop signal is received. - """ +async def run_until_stopped(sequencer: subsequence.sequencer.Sequencer) -> None: + """ + Run the sequencer until a stop signal is received. + """ - logger.info("Playing sequence. Press Ctrl+C to stop.") + logger.info("Playing sequence. Press Ctrl+C to stop.") - await sequencer.start() + await sequencer.start() - stop_event = asyncio.Event() - loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() - def _request_stop () -> None: + def _request_stop() -> None: + """ + Signal handler to request a clean shutdown. + """ - """ - Signal handler to request a clean shutdown. - """ + stop_event.set() - stop_event.set() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, _request_stop) + except NotImplementedError: + # Windows: add_signal_handler is Unix-only. + # Fall back to signal.signal() for SIGINT (Ctrl+C); skip SIGTERM. + if sig == signal.SIGINT: + signal.signal(sig, lambda s, f: _request_stop()) - for sig in (signal.SIGINT, signal.SIGTERM): - try: - loop.add_signal_handler(sig, _request_stop) - except NotImplementedError: - # Windows: add_signal_handler is Unix-only. - # Fall back to signal.signal() for SIGINT (Ctrl+C); skip SIGTERM. - if sig == signal.SIGINT: - signal.signal(sig, lambda s, f: _request_stop()) + assert sequencer.task is not None, "Sequencer task should exist after start()" + await asyncio.wait( + [asyncio.create_task(stop_event.wait()), sequencer.task], + return_when=asyncio.FIRST_COMPLETED, + ) - assert sequencer.task is not None, "Sequencer task should exist after start()" - await asyncio.wait( - [asyncio.create_task(stop_event.wait()), sequencer.task], - return_when = asyncio.FIRST_COMPLETED - ) - - await sequencer.stop() + await sequencer.stop() @dataclasses.dataclass class _Transition: - - """One declarative boundary rule registered by ``Composition.transition()``. - - Attributes: - before: The incoming section name the rule fires before, or ``"*"`` - for any *different* section. - fill: A Motif (anything with ``.events``/``.length``) to play in the - final bar. - channel: Resolved 0-indexed channel for the fill. - beat: Beat offset of the fill within the final bar. - mute: Pattern names to mute over the boundary approach. - beats: Mute window in beats (rounded UP to whole bars — muting is - bar-granular). - drum_note_map: Explicit drum map for the fill (otherwise borrowed - from a registered pattern on the same channel). - device: Output device (index, name, or None) for the fill — kept raw and - resolved when the fill fires, since device names are not known until - play() opens the ports. - """ - - before: str - fill: typing.Optional[typing.Any] = None - channel: typing.Optional[int] = None - beat: float = 0.0 - mute: typing.Optional[typing.List[str]] = None - beats: typing.Optional[float] = None - drum_note_map: typing.Optional[typing.Dict[str, int]] = None - device: subsequence.midi_utils.DeviceId = None + """One declarative boundary rule registered by ``Composition.transition()``. + + Attributes: + before: The incoming section name the rule fires before, or ``"*"`` + for any *different* section. + fill: A Motif (anything with ``.events``/``.length``) to play in the + final bar. + channel: Resolved 0-indexed channel for the fill. + beat: Beat offset of the fill within the final bar. + mute: Pattern names to mute over the boundary approach. + beats: Mute window in beats (rounded UP to whole bars — muting is + bar-granular). + drum_note_map: Explicit drum map for the fill (otherwise borrowed + from a registered pattern on the same channel). + device: Output device (index, name, or None) for the fill — kept raw and + resolved when the fill fires, since device names are not known until + play() opens the ports. + """ + + before: str + fill: typing.Optional[typing.Any] = None + channel: typing.Optional[int] = None + beat: float = 0.0 + mute: typing.Optional[typing.List[str]] = None + beats: typing.Optional[float] = None + drum_note_map: typing.Optional[typing.Dict[str, int]] = None + device: subsequence.midi_utils.DeviceId = None class _PendingPattern: - - """ - Holds decorator arguments and builder function until play() is called. - """ - - def __init__ ( - self, - builder_fn: typing.Callable, - channel: int, - length: float, - default_grid: int, - drum_note_map: typing.Optional[typing.Dict[str, int]], - cc_name_map: typing.Optional[typing.Dict[str, int]] = None, - nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, - reschedule_lookahead: float = 1, - voice_leading: bool = False, - device: int = 0, - raw_device: subsequence.midi_utils.DeviceId = None, - mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, - min_energy: typing.Optional[float] = None, - ) -> None: - - """ - Store pattern registration details for deferred scheduling. - - *raw_device* holds the original ``DeviceId`` passed to ``pattern()`` - (``None``, ``int``, or ``str``). When it is a string, ``device`` - starts at 0 as a placeholder and ``_resolve_pending_devices()`` in - ``_run()`` replaces it with the correct integer index once all output - devices have been opened. When it is ``None`` or an ``int``, ``device`` - is already final and ``raw_device`` is not consulted again. - - *mirrors* is the list of additional ``(device_idx, channel_0_indexed)`` - destinations resolved at decoration time. Empty list = no mirroring. - """ - - self.builder_fn = builder_fn - self.channel = channel - self.length = length - self.default_grid = default_grid - self.drum_note_map = drum_note_map - self.cc_name_map = cc_name_map - self.nrpn_name_map = nrpn_name_map - self.reschedule_lookahead = reschedule_lookahead - self.voice_leading = voice_leading - self.device = device - self.raw_device: subsequence.midi_utils.DeviceId = raw_device - self.mirrors: typing.List[subsequence.pattern.MirrorSpec] = list(mirrors) if mirrors else [] - self.min_energy = min_energy + """ + Holds decorator arguments and builder function until play() is called. + """ + + def __init__( + self, + builder_fn: typing.Callable, + channel: int, + length: float, + default_grid: int, + drum_note_map: typing.Optional[typing.Dict[str, int]], + cc_name_map: typing.Optional[typing.Dict[str, int]] = None, + nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, + reschedule_lookahead: float = 1, + voice_leading: bool = False, + device: int = 0, + raw_device: subsequence.midi_utils.DeviceId = None, + mirrors: typing.Optional[ + typing.Iterable[subsequence.pattern.MirrorSpec] + ] = None, + min_energy: typing.Optional[float] = None, + ) -> None: + """ + Store pattern registration details for deferred scheduling. + + *raw_device* holds the original ``DeviceId`` passed to ``pattern()`` + (``None``, ``int``, or ``str``). When it is a string, ``device`` + starts at 0 as a placeholder and ``_resolve_pending_devices()`` in + ``_run()`` replaces it with the correct integer index once all output + devices have been opened. When it is ``None`` or an ``int``, ``device`` + is already final and ``raw_device`` is not consulted again. + + *mirrors* is the list of additional ``(device_idx, channel_0_indexed)`` + destinations resolved at decoration time. Empty list = no mirroring. + """ + + self.builder_fn = builder_fn + self.channel = channel + self.length = length + self.default_grid = default_grid + self.drum_note_map = drum_note_map + self.cc_name_map = cc_name_map + self.nrpn_name_map = nrpn_name_map + self.reschedule_lookahead = reschedule_lookahead + self.voice_leading = voice_leading + self.device = device + self.raw_device: subsequence.midi_utils.DeviceId = raw_device + self.mirrors: typing.List[subsequence.pattern.MirrorSpec] = ( + list(mirrors) if mirrors else [] + ) + self.min_energy = min_energy class _PendingScheduled: + """Holds a user function and cycle interval for deferred scheduling.""" - """Holds a user function and cycle interval for deferred scheduling.""" - - def __init__ (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int, wait_for_initial: bool = False, defer: bool = False) -> None: - - """Store the function and scheduling parameters.""" - - self.fn = fn - self.cycle_beats = cycle_beats - self.reschedule_lookahead = reschedule_lookahead - self.wait_for_initial = wait_for_initial - self.defer = defer + def __init__( + self, + fn: typing.Callable, + cycle_beats: int, + reschedule_lookahead: int, + wait_for_initial: bool = False, + defer: bool = False, + ) -> None: + """Store the function and scheduling parameters.""" + self.fn = fn + self.cycle_beats = cycle_beats + self.reschedule_lookahead = reschedule_lookahead + self.wait_for_initial = wait_for_initial + self.defer = defer -def _live_blocked (name: str) -> typing.Callable: - """Return a function that raises ``RuntimeError`` when called. +def _live_blocked(name: str) -> typing.Callable: + """Return a function that raises ``RuntimeError`` when called. - Substituted for built-ins that would block the async event loop - (``help``, ``input``, ``breakpoint``, ``exit``, ``quit``). Used by - ``Composition._build_live_namespace`` to populate the safe builtins - dict for both the file watcher and the TCP eval server. - """ + Substituted for built-ins that would block the async event loop + (``help``, ``input``, ``breakpoint``, ``exit``, ``quit``). Used by + ``Composition._build_live_namespace`` to populate the safe builtins + dict for both the file watcher and the TCP eval server. + """ - def _raise (*args: typing.Any, **kwargs: typing.Any) -> None: - raise RuntimeError(f"{name}() is not available in live mode - it would block the sequencer.") + def _raise(*args: typing.Any, **kwargs: typing.Any) -> None: + raise RuntimeError( + f"{name}() is not available in live mode - it would block the sequencer." + ) - _raise.__name__ = name - _raise.__qualname__ = name + _raise.__name__ = name + _raise.__qualname__ = name - return _raise + return _raise class Composition: + """ + The top-level controller for a musical piece. + + The ``Composition`` object manages the global clock (Sequencer), the harmonic + progression (HarmonicState), the song structure (subsequence.form_state.FormState), and all MIDI patterns. + It serves as the main entry point for defining your music. + + Typical workflow: + + 1. Initialize ``Composition`` with BPM and Key. + 2. Define harmony and form (optional). + 3. Register patterns using the ``@composition.pattern`` decorator. + 4. Call ``composition.play()`` to start the music. + """ + + def __init__( + self, + output_device: typing.Optional[str] = None, + bpm: float = 120, + time_signature: typing.Tuple[int, int] = (4, 4), + key: typing.Optional[str] = None, + scale: typing.Optional[str] = None, + seed: typing.Optional[int] = None, + record: bool = False, + record_filename: typing.Optional[str] = None, + zero_indexed_channels: bool = False, + latency_ms: float = 0.0, + ) -> None: + """ + Initialize a new composition. + + Parameters: + output_device: The exact name of the MIDI output port to use, + as reported by ``mido.get_output_names()``. Matching is + strict — the string must equal an entry in that list + verbatim. On Linux/ALSA, names include the client and + port IDs (e.g. + ``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``); the + trailing ``:client:port`` digits are assigned in + connection order and can change between reboots or when + a virtual port is recreated. To look up the current + names:: + + import mido + for n in mido.get_output_names(): print(n) + + If ``None``, Subsequence auto-discovers — uses the only + available device, or prompts to choose if several exist. + bpm: Initial tempo in beats per minute (default 120). + time_signature: The metre as ``(beats, unit)``, default ``(4, 4)``. + Sets the bar length everywhere bars matter: ``bars=`` pattern + lengths, ``p.bar``/``p.signal()``, form advancement and + transitions, and pinned-chord bar numbers. + key: The root key of the piece (e.g., "C", "F#", "Bb"). + Required if you plan to use ``harmony()``. + scale: The scale/mode of the piece (e.g. "minor", "dorian", + or any registered scale name). Used to resolve scale + degrees in motifs; defaults to major (ionian) when unset. + seed: An optional integer for deterministic randomness. When set, + every random decision (chord choices, drum probability, etc.) + will be identical on every run. + record: When True, record all MIDI events to a file. + record_filename: Optional filename for the recording (defaults to timestamp). + zero_indexed_channels: When False (default), MIDI channels use + 1-based numbering (1-16) matching instrument labelling. + Channel 10 is drums, the way musicians and hardware panels + show it. When True, channels use 0-based numbering (0-15) + matching the raw MIDI protocol. + latency_ms: Physical output latency of the primary device in + milliseconds, for delay compensation (default 0.0, must be + non-negative). Set this when the primary output sounds late + (e.g. a software sampler) so Subsequence delays faster + devices to line everything up. See ``midi_output()`` for + additional devices. + + Example: + ```python + comp = subsequence.Composition(bpm=128, key="Eb", seed=123) + ``` + """ + + if latency_ms < 0: + raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") + + self.output_device = output_device + self.bpm = bpm + self.time_signature = time_signature + self.key = key + self.scale = scale + self._seed: typing.Optional[int] = seed + self._zero_indexed_channels: bool = zero_indexed_channels + self._output_latency_ms: float = latency_ms + + # Determinism plumbing: named-stream derivation state. Build-time + # consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so + # adding one call never shifts another's stream; play-time pattern + # streams are name-keyed in _build_pattern_from_pending. + self._freeze_count: int = 0 + self._harmony_count: int = 0 + self._form_count: int = 0 + self._reroll_nonces: typing.Dict[str, int] = {} + self._locked_names: typing.Set[str] = set() + + self._sequencer = subsequence.sequencer.Sequencer( + output_device_name=output_device, + initial_bpm=bpm, + time_signature=time_signature, + record=record, + record_filename=record_filename, + ) + + self._harmonic_state: typing.Optional[ + subsequence.harmonic_state.HarmonicState + ] = None + self._harmony_cycle_beats: typing.Optional[int] = None + self._harmony_style: typing.Optional[str] = None + # The style (name or ChordGraph) from the most recent style-configuring + # harmony() call — reused by parameter-only re-calls. + self._last_harmony_style: typing.Optional[ + typing.Union[str, subsequence.chord_graphs.ChordGraph] + ] = None + self._harmony_reschedule_lookahead: float = 1 + self._section_progressions: typing.Dict[str, Progression] = {} + self._bound_progression: typing.Optional[Progression] = None + self._pinned_chords: typing.Dict[int, typing.Any] = {} + self._cadence_requests: typing.Dict[int, str] = {} + self._section_cadences: typing.Dict[str, str] = {} + self._harmony_horizon = _HarmonyHorizon() + # True once the span-walking clock is registered for this playback — + # lets a first mid-playback harmony() call start it exactly once. + self._harmonic_clock_started: bool = False + self._section_motifs: typing.Dict[ + typing.Tuple[str, typing.Optional[str]], typing.Any + ] = {} + self._energy_map: typing.Dict[ + str, typing.Union[float, typing.Tuple[float, float]] + ] = {} + self._form_has_payload: bool = False + self._form_key: typing.Optional[str] = None + self._form_scale: typing.Optional[str] = None + # Cache of section progressions resolved against an effective key/scale + # (key-relative section harmony re-keys per occurrence; resolution is a + # pure function of (content, key, scale), so this is just memoisation). + self._resolved_section_cache: typing.Dict[ + typing.Tuple[str, typing.Optional[str], typing.Optional[str]], Progression + ] = {} + self._transitions: typing.List[_Transition] = [] + self._transition_muted: typing.Set[str] = set() + self._pending_patterns: typing.List[_PendingPattern] = [] + # Names of patterns declared by the most recent live-reload exec (added by + # pattern()/layer() as they run); the deletion diff in _apply_source_async + # compares this against the same source's PREVIOUS exec. + self._declared_names: typing.Set[str] = set() + # Per-source declaration history: source label/path → the names it + # declared last time it was exec'd. The deletion diff unregisters only + # names a source used to declare and no longer does — never patterns + # registered by the wrapper script or by another watched source. + self._source_declared: typing.Dict[str, typing.Set[str]] = {} + self._pending_scheduled: typing.List[_PendingScheduled] = [] + self._form_state: typing.Optional[subsequence.form_state.FormState] = None + self._builder_bar: int = 0 + self._display: typing.Optional[subsequence.display.Display] = None + self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None + self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = ( + None + ) + self._is_live: bool = False + self._running_patterns: typing.Dict[str, typing.Any] = {} + self._input_device: typing.Optional[str] = None + self._input_device_alias: typing.Optional[str] = None + self._clock_follow: bool = False + self._clock_output: bool = False + self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] + self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] + # Held-note input config from note_input() (None = not declared). + self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None + # Additional output devices registered with midi_output() after construction. + self._additional_outputs: typing.List[_AdditionalOutput] = [] + # Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool) + self._additional_inputs: typing.List[ + typing.Tuple[str, typing.Optional[str], bool] + ] = [] + # Maps alias/name → output device index (populated in _run after all devices are opened). + self._output_device_names: typing.Dict[str, int] = {} + # Maps alias/name → input device index (populated in _run after all input devices are opened). + self._input_device_names: typing.Dict[str, int] = {} + self.data: typing.Dict[str, typing.Any] = {} + self._osc_server: typing.Optional[subsequence.osc.OscServer] = None + self.conductor = subsequence.conductor.Conductor() + self._web_ui_enabled: bool = False + self._web_ui_http_host: str = "127.0.0.1" + self._web_ui_ws_host: str = "127.0.0.1" + self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None + self._link_quantum: typing.Optional[float] = None + + # Hotkey state — populated by hotkeys() and hotkey(). + self._hotkeys_enabled: bool = False + self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {} + self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = [] + self._keystroke_listener: typing.Optional[ + subsequence.keystroke.KeystrokeListener + ] = None + + # Tuning state — populated by tuning(). + self._tuning: typing.Optional[typing.Any] = None # subsequence.tuning.Tuning + self._tuning_bend_range: float = 2.0 + self._tuning_channels: typing.Optional[typing.List[int]] = None + self._tuning_reference_note: int = 60 + self._tuning_exclude_drums: bool = True + + def _resolve_device_id(self, device: subsequence.midi_utils.DeviceId) -> int: + """Resolve an output device id (None/int/str) to an integer index. + + ``None`` → 0 (primary device). ``int`` → returned as-is. + ``str`` → looked up in ``_output_device_names``; logs a warning and + returns 0 if the name is unknown (called after all devices are opened + in ``_run()``). + """ + if device is None: + return 0 + if isinstance(device, int): + return device + idx = self._output_device_names.get(device) + if idx is None: + logger.warning( + f"Unknown output device name '{device}' — routing to device 0. " + f"Available names: {list(self._output_device_names.keys())}" + ) + return 0 + return idx + + def _resolve_input_device_id( + self, device: subsequence.midi_utils.DeviceId + ) -> typing.Optional[int]: + """Resolve an input device id (None/int/str) to an integer index. + + ``None`` → ``None`` (matches any input device — existing behaviour). + ``int`` → returned as-is. ``str`` → looked up in ``_input_device_names``; + logs a warning and returns ``-1`` if the name is unknown — an index no + real device carries, so the mapping matches NOTHING (returning None + here would silently fail OPEN and listen to every device). + Called after all input devices are opened in ``_run()``. + """ + if device is None: + return None + if isinstance(device, int): + return device + idx = self._input_device_names.get(device) + if idx is None: + logger.warning( + f"Unknown input device name '{device}' — mapping will be ignored. " + f"Available names: {list(self._input_device_names.keys())}" + ) + return -1 + return idx + + def _resolve_pending_devices(self) -> None: + """Resolve name-based device ids on pending patterns now that all output devices are open.""" + for pending in self._pending_patterns: + if isinstance(pending.raw_device, str): + pending.device = self._resolve_device_id(pending.raw_device) + + async def _activate_new_pending_patterns(self) -> None: + """Build and schedule any pending patterns whose names are not yet running. + + Used by ``LiveReloader._reload_async`` to bring NEW patterns added + in a live reload into rotation mid-flight. Existing patterns + hot-swap via the decorator (their ``_builder_fn`` is replaced in + place); only patterns whose names are not yet in ``_running_patterns`` + need this graduation step. + + Newly-scheduled patterns start at the current sequencer pulse — + they'll generate events from now onward, and the next reschedule + will fire at the same offset as their primary cycle. + """ + + # Resolve any deferred string-device names against the now-open + # device registry (no-op for int/None devices). + self._resolve_pending_devices() + + # Dedupe by name, last declaration wins — re-declaring a pattern in a + # reloaded source must not schedule two copies. + new_by_name: typing.Dict[str, _PendingPattern] = {} + + for pending in self._pending_patterns: + if pending.builder_fn.__name__ not in self._running_patterns: + new_by_name[pending.builder_fn.__name__] = pending + + new_pending = list(new_by_name.values()) + + if not new_pending: + return + + current_pulse = self._sequencer.pulse_count + + for pending in new_pending: + pattern = self._build_pattern_from_pending( + pending, start_pulse=current_pulse + ) + await self._sequencer.schedule_pattern_repeating( + pattern, start_pulse=current_pulse + ) + self._running_patterns[pending.builder_fn.__name__] = pattern + + logger.info( + f"Live-reload: scheduled new pattern '{pending.builder_fn.__name__}'" + ) + + # Prune graduated (and stale duplicate) declarations: leaving them in + # _pending_patterns resurrected deleted patterns on every later reload. + self._pending_patterns = [ + pending + for pending in self._pending_patterns + if pending.builder_fn.__name__ not in self._running_patterns + ] + + def _resolve_channel(self, channel: int) -> int: + """ + Convert a user-supplied MIDI channel to the 0-indexed value used internally. + + When ``zero_indexed_channels`` is False (default), the channel is + validated as 1-16 and decremented by one. When True (0-indexed), the + channel is validated as 0-15 and returned unchanged. + """ + + if self._zero_indexed_channels: + if not 0 <= channel <= 15: + raise ValueError( + f"MIDI channel must be 0-15 (zero_indexed_channels=True), got {channel}" + ) + return channel + else: + if not 1 <= channel <= 16: + raise ValueError(f"MIDI channel must be 1-16, got {channel}") + return channel - 1 + + def _resolve_mirrors( + self, + mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]], + primary: typing.Optional[typing.Tuple[int, int]] = None, + ) -> typing.List[subsequence.pattern.MirrorSpec]: + """ + Validate and normalise a list of mirror destinations. + + Each entry is a 2- or 3-element sequence — ``(device_idx, channel)`` or + ``(device_idx, channel, drum_note_map)`` — as a tuple, list, or any such + iterable. ``channel`` is expressed in the user's channel-numbering + convention (1-16 by default, 0-15 when ``zero_indexed_channels=True``); + this method converts it to canonical 0-indexed form and rejects + malformed entries. The optional ``drum_note_map`` is preserved verbatim + so the sequencer can re-resolve mirrored drum names per device. + + String device names are NOT supported here; users wanting a named + device should pass the integer index returned from ``midi_output()``. + + If ``primary=(device, channel)`` is supplied (canonical 0-indexed + form), a mirror entry whose ``(device, channel)`` matches it triggers a + ``logger.warning`` — this is almost always a user error (every event + would double-fire on the same destination). The optional map is ignored + for this comparison. Skipped when ``primary`` is ``None``, since the + runtime API call site supplies its own check. + """ + + if mirrors is None: + return [] + + resolved: typing.List[subsequence.pattern.MirrorSpec] = [] + + for entry in mirrors: + # Accept any 2- or 3-element iterable (tuple, list, etc.) — config + # files and JSON sources naturally produce lists. Validate shape at + # decoration time so bad inputs surface here instead of producing + # inscrutable failures inside the sequencer. + try: + items = list(entry) + except TypeError: + raise ValueError( + f"Mirror entry must be a (device, channel[, drum_note_map]) tuple — got {entry!r}" + ) + + if len(items) not in (2, 3): + raise ValueError( + f"Mirror entry must have 2 or 3 elements (device, channel[, drum_note_map]) — got {entry!r}" + ) + + device = items[0] + channel = items[1] + drum_map = items[2] if len(items) == 3 else None + + if not isinstance(device, int) or isinstance(device, bool): + raise ValueError( + f"Mirror device must be an integer index — got {type(device).__name__} ({device!r})" + ) + + if not isinstance(channel, int) or isinstance(channel, bool): + raise ValueError( + f"Mirror channel must be an integer — got {type(channel).__name__} ({channel!r})" + ) + + if drum_map is not None and not isinstance(drum_map, dict): + raise ValueError( + f"Mirror drum_note_map must be a dict or None — got {type(drum_map).__name__} ({drum_map!r})" + ) + + resolved_channel = self._resolve_channel(channel) + + if primary is not None and (device, resolved_channel) == primary: + logger.warning( + f"Mirror destination {(device, resolved_channel)} matches the pattern's primary destination " + f"— every event will double-fire on this (device, channel). This is almost " + f"certainly unintended." + ) + + resolved_entry: subsequence.pattern.MirrorSpec = ( + (device, resolved_channel) + if drum_map is None + else (device, resolved_channel, drum_map) + ) + resolved.append(resolved_entry) + + return resolved + + @property + def harmonic_state( + self, + ) -> typing.Optional[subsequence.harmonic_state.HarmonicState]: + """The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called.""" + return self._harmonic_state + + def current_chord(self) -> typing.Optional[typing.Any]: + """The chord sounding at the playhead, or ``None`` without harmony. + + Reads the harmony window at the current pulse, so it stays accurate + under variable harmonic rhythm and clock lookahead (the engine's + ``current_chord`` flips *lookahead* beats early — this does not). + Falls back to the engine's chord before playback starts. The chord + may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span + is spiced; it duck-types the ``Chord`` voicing protocol either way. + """ + + if not self._harmony_horizon.is_empty: + beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat + chord = self._harmony_horizon.chord_at(beat) + if chord is not None: + return chord + + if self._harmonic_state is not None: + return self._harmonic_state.get_current_chord() + + return None + + def _effective_key_scale( + self, + section_info: typing.Optional["subsequence.form_state.SectionInfo"], + ) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: + """Resolve the key and scale in force, by the key-source precedence. + + The layered chain, key and scale resolved **independently** so a + section can move the tonic, the mode, or both: + ``Section.key`` > form key (``form(key=)`` / ``Form(key=)``) > + ``Composition.key``, and likewise for scale. This is the one place + the tier order lives; every placement site routes through it so the + section key reaches every compositional element uniformly (the + three-intent model: only *key-relative* content reads this — absolute + content ignores it, chord-relative content tracks the chord). + """ + + key: typing.Optional[str] = None + scale: typing.Optional[str] = None + + if section_info is not None: + key = section_info.key + scale = section_info.scale + + if key is None: + key = self._form_key + if key is None: + key = self.key + + if scale is None: + scale = self._form_scale + if scale is None: + scale = self.scale + + return key, scale + + def _resolve_section_progression( + self, + info: "subsequence.form_state.SectionInfo", + ) -> typing.Optional[Progression]: + """Resolve a section's bound progression against its effective key/scale. + + Concrete progressions (names, ``PitchSet``, frozen captures) are + returned unchanged. Key-relative ones resolve against the section's + effective key+scale — memoised per ``(name, key, scale)`` so a stable + section reuses one realisation and span identity is stable across + ticks. If no key is resolvable at this moment the section is skipped + (returns ``None`` → falls through to the bound/live source) with a + warning; the authoritative check runs at :meth:`play`/:meth:`render`. + """ + + raw = self._section_progressions.get(info.name) + + if raw is None or raw.is_concrete: + return raw + + key, scale = self._effective_key_scale(info) + + if key is None: + logger.warning( + "section_chords(%r) is key-relative but no key resolves for this section — " + "skipping (the chords fall through to the live/bound source)", + info.name, + ) + return None + + cache_key = (info.name, key, scale) + cached = self._resolved_section_cache.get(cache_key) + + if cached is not None: + return cached + + try: + resolved = raw.resolve(key, scale or "ionian") + except ValueError as error: + # A degree out of range for the effective scale, or an unknown + # scale — never let it escape the clock callback (that would kill + # harmony for the rest of playback). Skip the section; the _run + # pre-flight catches the common case far earlier. + logger.warning( + "section_chords(%r) cannot resolve against %s %s (%s) — skipping; " + "the chords fall through to the live/bound source", + info.name, + key, + scale or "ionian", + error, + ) + return None + + self._resolved_section_cache[cache_key] = resolved + return resolved + + @property + def form_state(self) -> typing.Optional["subsequence.form_state.FormState"]: + """The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called.""" + return self._form_state + + @property + def sequencer(self) -> subsequence.sequencer.Sequencer: + """The underlying ``Sequencer`` instance.""" + return self._sequencer + + @property + def running_patterns(self) -> typing.Dict[str, typing.Any]: + """The currently active patterns, keyed by name.""" + return self._running_patterns + + @property + def builder_bar(self) -> int: + """Current bar index used by pattern builders.""" + return self._builder_bar + + def _require_harmonic_state(self) -> subsequence.harmonic_state.HarmonicState: + """Return the active HarmonicState, raising ValueError if none is configured.""" + if self._harmonic_state is None: + raise ValueError( + "harmony() must be called before this action — " + "no harmonic state has been configured." + ) + return self._harmonic_state + + def _coerce_progression(self, source: typing.Any, what: str) -> Progression: + """Coerce a Progression / element list / preset name and resolve it against the key. + + Binding freezes one realisation (the value type's identity), so + key-relative content resolves here, at bind time, against the + composition's key and scale. Used by the *global* bound progression + (``harmony(progression=)``) — which is not section-scoped, so it has + nothing to re-key against. + """ + + value = ( + source + if isinstance(source, Progression) + else subsequence.progressions.progression(source) + ) + + if not value.is_concrete: + if self.key is None: + raise ValueError( + f"{what} contains key-relative chords (degrees/romans) — " + "set key= on the Composition so they can resolve" + ) + value = value.resolve(self.key, self.scale or "ionian") + + return value + + def _coerce_section_progression(self, source: typing.Any) -> Progression: + """Coerce a section progression, keeping key-relative content UNRESOLVED. + + Section harmony re-keys per occurrence (the section/form/composition + key in force when the section plays), so a key-relative progression is + stored relative and resolved late, in the clock, against the section's + effective key+scale — unlike the global bound progression, which + freezes at bind. Concrete content (chord names, frozen captures, + ``PitchSet``) is already absolute and never moves. + """ + + return ( + source + if isinstance(source, Progression) + else subsequence.progressions.progression(source) + ) + + def harmony( + self, + style: typing.Optional[ + typing.Union[str, subsequence.chord_graphs.ChordGraph] + ] = None, + cycle_beats: int = 4, + dominant_7th: bool = True, + gravity: float = 1.0, + nir_strength: float = 0.5, + minor_turnaround_weight: float = 0.0, + root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, + reschedule_lookahead: float = 1, + progression: typing.Optional[typing.Any] = None, + ) -> None: + """ + Configure the harmonic logic and chord change intervals. + + Two sources, combinable: a **bound progression** (``progression=`` — a + :class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``, + or chord names) walked span by span on the global clock; and/or a + **graph style** stepping live chords. With only a progression bound, + it loops on exhaustion; with a style configured too, exhaustion falls + through to live stepping (the frozen-replay bridge). Calling with + neither argument keeps today's default live engine + (``style="functional_major"``). + + Parameters: + style: The harmonic style to use. Built-in: "functional_major" + (alias "diatonic_major"), "hooktheory_major" (alias + "pop_major"), "turnaround", "aeolian_minor", + "phrygian_minor", "lydian_major", "dorian_minor", + "chromatic_mediant", "suspended", "mixolydian", "whole_tone", + "diminished". See README for full descriptions. + cycle_beats: How many beats each live chord lasts (default 4). + Bound progressions carry their own harmonic rhythm in their + spans, so this applies to live stepping only. A re-call + during playback takes effect from the next chord boundary; + a FIRST harmony() call mid-playback starts the clock itself. + dominant_7th: Whether to include V7 chords (default True). + gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord. + nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement + expectations. + minor_turnaround_weight: For "turnaround" style, influences major vs minor feel. + root_diversity: Root-repetition damping (0.0 to 1.0). Each recent + chord sharing a candidate's root reduces the weight to 40% at + the default (0.4). Set to 1.0 to disable. + reschedule_lookahead: How many beats in advance to calculate the + next chord. + progression: A progression to bind to the global clock. Key- + relative content resolves now, against the composition key + and scale (binding freezes one realisation). + + Example: + ```python + # A moody minor progression that changes every 8 beats + comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4) + + # Manual harmony driving everything — loops forever + comp.harmony(progression=subsequence.progression([1, 6, 3, 7])) + ``` + """ + + if style is None and progression is None: + # A parameter-only re-call (gravity=, cycle_beats=, ...) keeps the + # configured style — defaulting unconditionally here would silently + # replace e.g. aeolian_minor with functional_major. + style = ( + self._last_harmony_style + if self._last_harmony_style is not None + else "functional_major" + ) + + if style is not None: + if self.key is None: + raise ValueError( + "Cannot configure harmony without a key - set key in the Composition constructor" + ) + + preserved_history: typing.List[subsequence.chords.Chord] = [] + preserved_current: typing.Optional[subsequence.chords.Chord] = None + + if self._harmonic_state is not None: + preserved_history = self._harmonic_state.history.copy() + preserved_current = self._harmonic_state.current_chord + + # Per-call salted build stream (harmony:1, harmony:2, ...): a re-call + # gets its own deterministic stream while history and current chord + # are preserved above, and adding a re-call never shifts any other + # consumer's stream. + self._harmony_count += 1 + + self._harmonic_state = subsequence.harmonic_state.HarmonicState( + key_name=self.key, + graph_style=style, + include_dominant_7th=dominant_7th, + key_gravity_blend=gravity, + nir_strength=nir_strength, + minor_turnaround_weight=minor_turnaround_weight, + root_diversity=root_diversity, + rng=self._stream(f"harmony:{self._harmony_count}"), + ) + + if preserved_history: + self._harmonic_state.history = preserved_history + if ( + preserved_current is not None + and self._harmonic_state.graph.get_transitions(preserved_current) + ): + self._harmonic_state.current_chord = preserved_current + + self._harmony_style = style if isinstance(style, str) else None + self._last_harmony_style = style + + if progression is not None: + self._bound_progression = self._coerce_progression( + progression, "harmony(progression=)" + ) + + self._harmony_cycle_beats = cycle_beats + self._harmony_reschedule_lookahead = reschedule_lookahead + + # A re-call invalidates whatever the horizon had planned. + self._harmony_horizon.invalidate_future() + + # A FIRST harmony() call mid-playback must start the clock itself — + # _run() only schedules clocks for sources it can see at play() time. + # (Re-calls need nothing here: the clock reads its sources through + # getters on every tick.) + loop = self._sequencer._event_loop + + if loop is not None and loop.is_running() and not self._harmonic_clock_started: + try: + on_loop = asyncio.get_running_loop() is loop + except RuntimeError: + on_loop = False + + if on_loop: + loop.create_task(self._start_harmonic_clock()) + else: + asyncio.run_coroutine_threadsafe(self._start_harmonic_clock(), loop) + + async def _start_harmonic_clock( + self, + bar_beats: typing.Optional[float] = None, + clock_lookahead: typing.Optional[float] = None, + ) -> None: + """Register the span-walking harmonic clock (idempotent per playback). + + Called from ``_run()`` when a harmony source exists at play time, and + from ``harmony()`` when the FIRST source arrives mid-playback. + ``bar_beats``/``clock_lookahead`` default to a fresh computation for + the mid-playback path; ``_run()`` passes the values it validated. + """ + + if self._harmonic_clock_started: + return + + self._harmonic_clock_started = True + + if bar_beats is None: + bar_beats = float(self.time_signature[0]) + + if clock_lookahead is None: + lookaheads = [ + pattern.reschedule_lookahead + for pattern in self._running_patterns.values() + ] + clock_lookahead = min( + bar_beats, + max( + 1.0, + float(self._harmony_reschedule_lookahead), + float(max(lookaheads, default=1)), + ), + ) + + def _get_section_progression() -> typing.Optional[ + typing.Tuple[str, int, int, typing.Optional[Progression]] + ]: + """Return (section_name, section_index, bars, Progression|None) for the current section, or None. + + The progression is resolved against the section's effective + key/scale here (key-relative section harmony re-keys per + occurrence); concrete content passes through unchanged. + """ + if self._form_state is None: + return None + info = self._form_state.get_section_info() + if info is None: + return None + prog = self._resolve_section_progression(info) + return (info.name, info.index, info.bars, prog) + + def _resolve_cadence_formula( + name: str, + ) -> typing.List[subsequence.chords.Chord]: + """Resolve a cadence formula against the composition key and scale, at plan time.""" + hs = self._harmonic_state + key_pc = ( + subsequence.chords.key_name_to_pc(self.key) + if self.key is not None + else (hs.key_root_pc if hs is not None else 0) + ) + spec = subsequence.cadences.cadence_formula(name) + return [ + subsequence.progressions.resolve_constraint( + element, key_pc, self._constraint_scale(), f"cadence {name!r}" + ) + for element in spec.formula + ] + + await schedule_harmonic_clock( + sequencer=self._sequencer, + get_harmonic_state=lambda: self._harmonic_state, + horizon=self._harmony_horizon, + bar_beats=bar_beats, + cycle_beats=self._harmony_cycle_beats or 4, + get_cycle_beats=lambda: self._harmony_cycle_beats or 4, + get_bound_progression=lambda: self._bound_progression, + get_section_progression=_get_section_progression, + get_pinned=self._resolve_pin, + cadence_requests=self._cadence_requests, + resolve_cadence=_resolve_cadence_formula, + get_section_cadence=self._section_cadences.get, + reschedule_lookahead=clock_lookahead, + ) + + def _constraint_scale(self) -> str: + """The scale that hybrid-constraint ints resolve against. + + The composition's own scale when set; otherwise inferred from the + harmony style (``aeolian_minor`` → minor, matching + :meth:`Progression.generate`'s documented inference), falling back + to ionian. Roman strings carry their quality and never need it. + """ + + if self.scale is not None: + return self.scale + + return subsequence.progressions._STYLE_SCALES.get( + self._harmony_style or "", "ionian" + ) + + def freeze( + self, + bars: int, + end: typing.Optional[typing.Any] = None, + pins: typing.Optional[typing.Dict[int, typing.Any]] = None, + avoid: typing.Optional[typing.Sequence[typing.Any]] = None, + cadence: typing.Optional[str] = None, + ) -> "Progression": + """Capture a chord progression from the live harmony engine. + + Runs the harmony engine forward by *bars* chord changes, records each + chord, and returns it as a :class:`Progression` that can be bound to a + form section with :meth:`section_chords`. + + The engine state **advances** — successive ``freeze()`` calls produce a + continuing compositional journey so section progressions feel like parts + of a whole rather than isolated islands. + + The hybrid constraints compile into the walk: ``end=`` fixes the last + bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=`` + excludes chords throughout. Specs follow the progression-element + grammar (ints where diatonic, roman/name strings where chromatic) and + resolve against the composition key and scale. A backward + feasibility pass guarantees satisfiability before any chord is drawn; + the forward walk keeps the engine's real history-dependent weighting. + Bar 1 is always the engine's current chord — the journey continues — + so ``pins={1: ...}`` may only name it redundantly. + + Parameters: + bars: Number of chords to capture (one per harmony cycle). + end: The chord at the final bar — ``end="V"`` is the cadential + major dominant in minor. + pins: ``{bar: chord}`` — 1-based fiat positions. + avoid: Chords excluded from the walk. + cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ + ``"fakeout"``, theory aliases accepted) — its formula pins + the final bars, so the walk approaches the close. + Conflicts with ``end=`` or pins on those bars. + + Returns: + A :class:`Progression` with the captured chords and trailing + history for NIR continuity. + + Raises: + ValueError: If :meth:`harmony` has not been called first, or the + constraints are contradictory or unsatisfiable. + + Example:: + + composition.harmony(style="functional_major", cycle_beats=4) + verse = composition.freeze(8, end="V") # the verse sets up the chorus + chorus = composition.freeze(4) # next 4 chords, continuing on + composition.section_chords("verse", verse) + composition.section_chords("chorus", chorus) + """ + + hs = self._require_harmonic_state() + + if bars < 1: + raise ValueError("bars must be at least 1") + + if cadence is not None: + pins = subsequence.progressions.cadence_pins(cadence, bars, pins, end) + end = None + + scale = self._constraint_scale() + key_pc = ( + subsequence.chords.key_name_to_pc(self.key) + if self.key is not None + else hs.key_root_pc + ) + + resolved_pins = { + position: subsequence.progressions.resolve_constraint( + spec, key_pc, scale, f"pins[{position}]" + ) + for position, spec in (pins or {}).items() + } + resolved_end = ( + subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") + if end is not None + else None + ) + resolved_avoid = [ + subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") + for spec in (avoid or []) + ] + + if 1 in resolved_pins and resolved_pins[1] != hs.current_chord: + raise ValueError( + f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord " + f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; " + "pin a later bar, or use pin_chord() for playback fiat" + ) + + # Per-call salted stream (freeze:1, freeze:2, ...): each call's draws + # are independent of every other consumer, so frozen progressions are + # reproducible WITHOUT play() and adding a call cannot shift a + # neighbour's output. Engine state still advances normally — chord + # continuity comes from current_chord/history, randomness from the + # salted stream (swap-and-restore keeps hs.rng for play untouched). + self._freeze_count += 1 + stream = self._stream(f"freeze:{self._freeze_count}") + saved_rng = hs.rng + + if stream is not None: + hs.rng = stream + + try: + # The kernel with the engine's own hooks is draw-for-draw the old + # step() loop when unconstrained — one walk path for both. + def _commit(chosen: subsequence.chords.Chord) -> None: + hs.current_chord = chosen + + collected = subsequence.sequence_utils.constrained_walk( + hs.graph, + hs.current_chord, + bars, + rng=hs.rng, + pins=resolved_pins, + end=resolved_end, + avoid=resolved_avoid, + weight_modifier=hs._transition_weight, + before_choice=hs._record_transition_source, + after_choice=_commit, + ) + + # Advance past the last captured chord so the next freeze() call or + # live playback does not duplicate it. + hs.step() + + finally: + hs.rng = saved_rng + + span_beats = float(self._harmony_cycle_beats or 4) + + return Progression( + spans=tuple( + subsequence.progressions.ChordSpan(chord=chord, beats=span_beats) + for chord in collected + ), + trailing_history=tuple(hs.history), + ) + + def section_chords(self, section_name: str, progression: typing.Any) -> None: + """Bind a :class:`Progression` to a named form section. + + Every time *section_name* plays, the harmonic clock walks the + progression's spans instead of calling the live engine. Sections + without a bound progression continue generating live chords. + + Accepts a :class:`Progression` value (from :meth:`freeze`, the + ``progression()`` factory, or hand-built) or anything the factory + accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord + names. + + **Key-relative content re-keys per occurrence.** A progression + written in degrees or romans is *key-relative* content: it resolves + late, each time the section plays, against that section's effective + key and scale (``Section.key`` > form key > composition key, with + mode following the same chain). So a ``Section(key="A")`` plays the + same numbered progression a tone higher — its chords and its degrees + share one tonic. *Absolute* content — chord names (``"Am"``), + :class:`~subsequence.progressions.PitchSet`, and frozen captures from + :meth:`freeze` — names exact chords and is never transposed by a key. + + On exhaustion mid-section the progression loops when no graph style + is configured (and always when it contains a ``PitchSet``); with a + live engine, exhaustion **falls through to live stepping in the + COMPOSITION key** — the live graph engine does not transpose for a + section (a stateful walk does not modulate mid-stream), so a + re-keyed section that runs out of written chords hands off to + composition-key harmony. Bind a full-length progression (or set + ``at_end``/loop intent) if you need the whole section in its key. + + Parameters: + section_name: Name of the section as defined in :meth:`form`. + progression: The progression to bind. + + Raises: + ValueError: If a graph-based form has been configured and + *section_name* is not one of its sections. List and generator + forms yield names lazily, so they cannot be validated here. + (A key-relative progression with no resolvable key for the + section is caught at :meth:`play`/:meth:`render`, once the + form's keys are known.) + + Example:: + + composition.section_chords("verse", verse_progression) + composition.section_chords("chorus", [1, 6, 3, 7]) + # "bridge" is not bound — it generates live chords + """ + + if ( + self._form_state is not None + and self._form_state._section_bars is not None + and section_name not in self._form_state._section_bars + ): + known = ", ".join(sorted(self._form_state._section_bars)) + raise ValueError( + f"Section '{section_name}' not found in form. Known sections: {known}" + ) + + self._section_progressions[section_name] = self._coerce_section_progression( + progression + ) + self._resolved_section_cache = {} + self._harmony_horizon.invalidate_future() + + def pin_chord(self, bar: int, chord: typing.Optional[typing.Any]) -> None: + """Force the chord sounding at a bar — fiat over live generation. + + Whatever the harmonic source (live walk, bound progression, section + progression) produces for *bar*, the pinned chord overrides it. + Pass ``None`` to remove a pin. + + Parameters: + bar: 1-based bar number (the musician count). + chord: A chord name, int degree, roman string, ``Chord``, + ``PitchSet``, or ``None`` to unpin. A **key-relative** spec + (int degree, roman) re-keys like section harmony: it resolves + late, against the effective key of the section sounding at + that bar (so ``pin_chord(8, "V")`` is the dominant of + wherever bar 8 lands). A **concrete** spec (name, ``Chord``, + ``PitchSet``) is absolute and never moves. + + Example:: + + composition.pin_chord(8, "E7") # the turnaround lands on E7 + composition.pin_chord(8, "V") # the dominant of bar 8's section + composition.pin_chord(8, None) # let it walk again + """ + + if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: + raise ValueError(f"bars are 1-based ints, got {bar!r}") + + if chord is None: + self._pinned_chords.pop(bar, None) + else: + # Store the parsed span — relative pins resolve late (per section) + # at the clock; concrete pins are absolute. + span = subsequence.progressions.parse_element( + chord, beats=float(self.time_signature[0]) + ) + + if not span.is_concrete: + # Raise early only when no key is resolvable for this bar — the + # bar's own section (sequence forms) may supply one even with no + # composition/form key. + probe_info = ( + self._form_state.section_info_at_bar(bar) + if self._form_state is not None + else None + ) + probe_key, _ = self._effective_key_scale(probe_info) + if probe_key is None: + raise ValueError( + "pin_chord with a key-relative spec (degree/roman) needs a key — set key= on " + "the Composition, a form key, or a Section.key for that bar (the pin re-keys " + "to the section's effective key)" + ) + + self._pinned_chords[bar] = span + + self._harmony_horizon.invalidate_future() + + def _resolve_pin(self, bar: int) -> typing.Optional[typing.Any]: + """Resolve a stored pin to a chord-like, re-keying relative pins per section. + + Concrete pins return their chord directly; key-relative pins resolve + against the effective key of the section sounding now (the clock + reads this as it reaches each bar). Returns ``None`` (no pin / a + relative pin with no resolvable key, warned) so the clock falls + through to its normal source. + """ + + span = self._pinned_chords.get(bar) + + if span is None: + return None + + if span.is_concrete: + return _span_chord(span) + + # Key the pin to the section that OWNS this bar (the clock's lookahead + # can project a pin into a later, differently-keyed section while the + # playhead is still earlier) — fall back to the playhead section where + # a per-bar section is not computable (graph/generator forms). + info: typing.Optional["subsequence.form_state.SectionInfo"] = None + if self._form_state is not None: + info = self._form_state.section_info_at_bar(bar) + if info is None: + info = self._form_state.get_section_info() + + key, scale = self._effective_key_scale(info) + + if key is None: + logger.warning( + "pin_chord(%d, ...) is key-relative but no key resolves for that bar — ignoring the pin", + bar, + ) + return None + + return _span_chord( + span.resolve(subsequence.chords.key_name_to_pc(key), scale or "ionian") + ) + + def request_cadence( + self, cadence: str = "strong", bar: typing.Optional[int] = None + ) -> None: + """Ask the live engine to approach a cadence arriving at a bar. + + The request hook: where :meth:`pin_chord` is fiat, this is a + *steered approach* — at the next chord boundary the clock plans the + remaining changes up to *bar* as a constrained walk through the + engine's real weights, pinned to the cadence formula at the tail + (``"strong"`` arrives V→I, ``"soft"`` IV→I, ``"open"`` IV→V, + ``"fakeout"`` V→vi; theory aliases accepted). The chords still + commit one boundary at a time, so the journey continues through the + close. + + One-shot: the request is consumed when planned. Live harmony only — + bound/section progressions are data and cannot be steered; a request + whose bar passes unserved expires with a warning. If the formula is + not walkable from where the harmony stands, the arrival lands by + fiat (loudly). Ask at least a pattern-lookahead ahead: patterns may + already have rendered against the previously planned chord. + + Parameters: + cadence: The cadence name. + bar: The 1-based bar the cadence's final chord arrives at + (required; in practice ≥ 2 — bar 1 cannot be approached). + + Example:: + + composition.request_cadence("open", bar=16) # hang on V at bar 16 + """ + + spec = subsequence.cadences.cadence_formula(cadence) + + if bar is None or not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: + raise ValueError( + f"request_cadence needs bar= — the 1-based bar the cadence arrives at (got {bar!r})" + ) + + self._cadence_requests[bar] = spec.name + self._harmony_horizon.invalidate_future() + + def section_cadence( + self, section_name: str, cadence: typing.Optional[str] = "strong" + ) -> None: + """Close every pass of a section with a cadence — the standing request. + + Each time *section_name* is entered, the clock registers a + :meth:`request_cadence` arriving at the section's final bar, so the + harmony approaches the close as the section ends. Live harmony + only: a section with bound chords (:meth:`section_chords`) is data + and ignores the registration — its closes are written, not steered. + Pass ``None`` to unregister. + + Example:: + + composition.form([("verse", 8), ("chorus", 8)]) + composition.section_cadence("verse", "open") # every verse hangs on V + composition.section_cadence("chorus", "strong") # every chorus lands home + """ + + if cadence is None: + self._section_cadences.pop(section_name, None) + return + + spec = subsequence.cadences.cadence_formula(cadence) + self._section_cadences[section_name] = spec.name + + def section_motifs( + self, section_name: str, value: typing.Any, part: typing.Optional[str] = None + ) -> None: + """Bind a Motif or Phrase to a named form section (per optional part). + + Patterns read the binding back with ``p.section_motif(part)`` (or use + the one-call :meth:`phrase_part`); a section with no binding for the + part is silent for that part — bind material or don't, no fallback + guessing. Re-binding is idempotent, so the call is safe in a live + file: re-executing on save is the desired rebind. + + Parameters: + section_name: Name of the section as defined in :meth:`form`. + value: A ``Motif`` or ``Phrase`` (anything exposing + ``.length``/``.slice`` places). + part: Optional part label, so one section can carry several + bindings (``"lead"``, ``"bass"``, ...). + + Raises: + ValueError: If a graph-based form has been configured and + *section_name* is not one of its sections. + + Example:: + + composition.section_motifs("verse", verse_line, part="lead") + composition.section_motifs("chorus", chorus_line, part="lead") + """ + + if not hasattr(value, "length") or not hasattr(value, "slice"): + raise TypeError( + f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}" + ) + + if ( + self._form_state is not None + and self._form_state._section_bars is not None + and section_name not in self._form_state._section_bars + ): + known = ", ".join(sorted(self._form_state._section_bars)) + raise ValueError( + f"Section '{section_name}' not found in form. Known sections: {known}" + ) + + self._section_motifs[(section_name, part)] = value - """ - The top-level controller for a musical piece. - - The ``Composition`` object manages the global clock (Sequencer), the harmonic - progression (HarmonicState), the song structure (subsequence.form_state.FormState), and all MIDI patterns. - It serves as the main entry point for defining your music. - - Typical workflow: - - 1. Initialize ``Composition`` with BPM and Key. - 2. Define harmony and form (optional). - 3. Register patterns using the ``@composition.pattern`` decorator. - 4. Call ``composition.play()`` to start the music. - """ - - def __init__ ( - self, - output_device: typing.Optional[str] = None, - bpm: float = 120, - time_signature: typing.Tuple[int, int] = (4, 4), - key: typing.Optional[str] = None, - scale: typing.Optional[str] = None, - seed: typing.Optional[int] = None, - record: bool = False, - record_filename: typing.Optional[str] = None, - zero_indexed_channels: bool = False, - latency_ms: float = 0.0 - ) -> None: - - """ - Initialize a new composition. - - Parameters: - output_device: The exact name of the MIDI output port to use, - as reported by ``mido.get_output_names()``. Matching is - strict — the string must equal an entry in that list - verbatim. On Linux/ALSA, names include the client and - port IDs (e.g. - ``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``); the - trailing ``:client:port`` digits are assigned in - connection order and can change between reboots or when - a virtual port is recreated. To look up the current - names:: - - import mido - for n in mido.get_output_names(): print(n) - - If ``None``, Subsequence auto-discovers — uses the only - available device, or prompts to choose if several exist. - bpm: Initial tempo in beats per minute (default 120). - time_signature: The metre as ``(beats, unit)``, default ``(4, 4)``. - Sets the bar length everywhere bars matter: ``bars=`` pattern - lengths, ``p.bar``/``p.signal()``, form advancement and - transitions, and pinned-chord bar numbers. - key: The root key of the piece (e.g., "C", "F#", "Bb"). - Required if you plan to use ``harmony()``. - scale: The scale/mode of the piece (e.g. "minor", "dorian", - or any registered scale name). Used to resolve scale - degrees in motifs; defaults to major (ionian) when unset. - seed: An optional integer for deterministic randomness. When set, - every random decision (chord choices, drum probability, etc.) - will be identical on every run. - record: When True, record all MIDI events to a file. - record_filename: Optional filename for the recording (defaults to timestamp). - zero_indexed_channels: When False (default), MIDI channels use - 1-based numbering (1-16) matching instrument labelling. - Channel 10 is drums, the way musicians and hardware panels - show it. When True, channels use 0-based numbering (0-15) - matching the raw MIDI protocol. - latency_ms: Physical output latency of the primary device in - milliseconds, for delay compensation (default 0.0, must be - non-negative). Set this when the primary output sounds late - (e.g. a software sampler) so Subsequence delays faster - devices to line everything up. See ``midi_output()`` for - additional devices. - - Example: - ```python - comp = subsequence.Composition(bpm=128, key="Eb", seed=123) - ``` - """ - - if latency_ms < 0: - raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") - - self.output_device = output_device - self.bpm = bpm - self.time_signature = time_signature - self.key = key - self.scale = scale - self._seed: typing.Optional[int] = seed - self._zero_indexed_channels: bool = zero_indexed_channels - self._output_latency_ms: float = latency_ms - - # Determinism plumbing: named-stream derivation state. Build-time - # consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so - # adding one call never shifts another's stream; play-time pattern - # streams are name-keyed in _build_pattern_from_pending. - self._freeze_count: int = 0 - self._harmony_count: int = 0 - self._form_count: int = 0 - self._reroll_nonces: typing.Dict[str, int] = {} - self._locked_names: typing.Set[str] = set() - - self._sequencer = subsequence.sequencer.Sequencer( - output_device_name = output_device, - initial_bpm = bpm, - time_signature = time_signature, - record = record, - record_filename = record_filename - ) - - self._harmonic_state: typing.Optional[subsequence.harmonic_state.HarmonicState] = None - self._harmony_cycle_beats: typing.Optional[int] = None - self._harmony_style: typing.Optional[str] = None - # The style (name or ChordGraph) from the most recent style-configuring - # harmony() call — reused by parameter-only re-calls. - self._last_harmony_style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None - self._harmony_reschedule_lookahead: float = 1 - self._section_progressions: typing.Dict[str, Progression] = {} - self._bound_progression: typing.Optional[Progression] = None - self._pinned_chords: typing.Dict[int, typing.Any] = {} - self._cadence_requests: typing.Dict[int, str] = {} - self._section_cadences: typing.Dict[str, str] = {} - self._harmony_horizon = _HarmonyHorizon() - # True once the span-walking clock is registered for this playback — - # lets a first mid-playback harmony() call start it exactly once. - self._harmonic_clock_started: bool = False - self._section_motifs: typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] = {} - self._energy_map: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} - self._form_has_payload: bool = False - self._form_key: typing.Optional[str] = None - self._form_scale: typing.Optional[str] = None - # Cache of section progressions resolved against an effective key/scale - # (key-relative section harmony re-keys per occurrence; resolution is a - # pure function of (content, key, scale), so this is just memoisation). - self._resolved_section_cache: typing.Dict[typing.Tuple[str, typing.Optional[str], typing.Optional[str]], Progression] = {} - self._transitions: typing.List[_Transition] = [] - self._transition_muted: typing.Set[str] = set() - self._pending_patterns: typing.List[_PendingPattern] = [] - # Names of patterns declared by the most recent live-reload exec (added by - # pattern()/layer() as they run); the deletion diff in _apply_source_async - # compares this against the same source's PREVIOUS exec. - self._declared_names: typing.Set[str] = set() - # Per-source declaration history: source label/path → the names it - # declared last time it was exec'd. The deletion diff unregisters only - # names a source used to declare and no longer does — never patterns - # registered by the wrapper script or by another watched source. - self._source_declared: typing.Dict[str, typing.Set[str]] = {} - self._pending_scheduled: typing.List[_PendingScheduled] = [] - self._form_state: typing.Optional[subsequence.form_state.FormState] = None - self._builder_bar: int = 0 - self._display: typing.Optional[subsequence.display.Display] = None - self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None - self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = None - self._is_live: bool = False - self._running_patterns: typing.Dict[str, typing.Any] = {} - self._input_device: typing.Optional[str] = None - self._input_device_alias: typing.Optional[str] = None - self._clock_follow: bool = False - self._clock_output: bool = False - self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] - self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] - # Held-note input config from note_input() (None = not declared). - self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None - # Additional output devices registered with midi_output() after construction. - self._additional_outputs: typing.List[_AdditionalOutput] = [] - # Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool) - self._additional_inputs: typing.List[typing.Tuple[str, typing.Optional[str], bool]] = [] - # Maps alias/name → output device index (populated in _run after all devices are opened). - self._output_device_names: typing.Dict[str, int] = {} - # Maps alias/name → input device index (populated in _run after all input devices are opened). - self._input_device_names: typing.Dict[str, int] = {} - self.data: typing.Dict[str, typing.Any] = {} - self._osc_server: typing.Optional[subsequence.osc.OscServer] = None - self.conductor = subsequence.conductor.Conductor() - self._web_ui_enabled: bool = False - self._web_ui_http_host: str = "127.0.0.1" - self._web_ui_ws_host: str = "127.0.0.1" - self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None - self._link_quantum: typing.Optional[float] = None - - # Hotkey state — populated by hotkeys() and hotkey(). - self._hotkeys_enabled: bool = False - self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {} - self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = [] - self._keystroke_listener: typing.Optional[subsequence.keystroke.KeystrokeListener] = None - - # Tuning state — populated by tuning(). - self._tuning: typing.Optional[typing.Any] = None # subsequence.tuning.Tuning - self._tuning_bend_range: float = 2.0 - self._tuning_channels: typing.Optional[typing.List[int]] = None - self._tuning_reference_note: int = 60 - self._tuning_exclude_drums: bool = True - - def _resolve_device_id (self, device: subsequence.midi_utils.DeviceId) -> int: - """Resolve an output device id (None/int/str) to an integer index. - - ``None`` → 0 (primary device). ``int`` → returned as-is. - ``str`` → looked up in ``_output_device_names``; logs a warning and - returns 0 if the name is unknown (called after all devices are opened - in ``_run()``). - """ - if device is None: - return 0 - if isinstance(device, int): - return device - idx = self._output_device_names.get(device) - if idx is None: - logger.warning( - f"Unknown output device name '{device}' — routing to device 0. " - f"Available names: {list(self._output_device_names.keys())}" - ) - return 0 - return idx - - def _resolve_input_device_id (self, device: subsequence.midi_utils.DeviceId) -> typing.Optional[int]: - """Resolve an input device id (None/int/str) to an integer index. - - ``None`` → ``None`` (matches any input device — existing behaviour). - ``int`` → returned as-is. ``str`` → looked up in ``_input_device_names``; - logs a warning and returns ``-1`` if the name is unknown — an index no - real device carries, so the mapping matches NOTHING (returning None - here would silently fail OPEN and listen to every device). - Called after all input devices are opened in ``_run()``. - """ - if device is None: - return None - if isinstance(device, int): - return device - idx = self._input_device_names.get(device) - if idx is None: - logger.warning( - f"Unknown input device name '{device}' — mapping will be ignored. " - f"Available names: {list(self._input_device_names.keys())}" - ) - return -1 - return idx - - def _resolve_pending_devices (self) -> None: - """Resolve name-based device ids on pending patterns now that all output devices are open.""" - for pending in self._pending_patterns: - if isinstance(pending.raw_device, str): - pending.device = self._resolve_device_id(pending.raw_device) - - async def _activate_new_pending_patterns (self) -> None: - - """Build and schedule any pending patterns whose names are not yet running. - - Used by ``LiveReloader._reload_async`` to bring NEW patterns added - in a live reload into rotation mid-flight. Existing patterns - hot-swap via the decorator (their ``_builder_fn`` is replaced in - place); only patterns whose names are not yet in ``_running_patterns`` - need this graduation step. - - Newly-scheduled patterns start at the current sequencer pulse — - they'll generate events from now onward, and the next reschedule - will fire at the same offset as their primary cycle. - """ - - # Resolve any deferred string-device names against the now-open - # device registry (no-op for int/None devices). - self._resolve_pending_devices() - - # Dedupe by name, last declaration wins — re-declaring a pattern in a - # reloaded source must not schedule two copies. - new_by_name: typing.Dict[str, _PendingPattern] = {} - - for pending in self._pending_patterns: - if pending.builder_fn.__name__ not in self._running_patterns: - new_by_name[pending.builder_fn.__name__] = pending - - new_pending = list(new_by_name.values()) - - if not new_pending: - return - - current_pulse = self._sequencer.pulse_count - - for pending in new_pending: - - pattern = self._build_pattern_from_pending(pending, start_pulse = current_pulse) - await self._sequencer.schedule_pattern_repeating(pattern, start_pulse = current_pulse) - self._running_patterns[pending.builder_fn.__name__] = pattern - - logger.info(f"Live-reload: scheduled new pattern '{pending.builder_fn.__name__}'") - - # Prune graduated (and stale duplicate) declarations: leaving them in - # _pending_patterns resurrected deleted patterns on every later reload. - self._pending_patterns = [ - pending for pending in self._pending_patterns - if pending.builder_fn.__name__ not in self._running_patterns - ] - - def _resolve_channel (self, channel: int) -> int: - - """ - Convert a user-supplied MIDI channel to the 0-indexed value used internally. - - When ``zero_indexed_channels`` is False (default), the channel is - validated as 1-16 and decremented by one. When True (0-indexed), the - channel is validated as 0-15 and returned unchanged. - """ - - if self._zero_indexed_channels: - if not 0 <= channel <= 15: - raise ValueError(f"MIDI channel must be 0-15 (zero_indexed_channels=True), got {channel}") - return channel - else: - if not 1 <= channel <= 16: - raise ValueError(f"MIDI channel must be 1-16, got {channel}") - return channel - 1 - - def _resolve_mirrors ( - self, - mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]], - primary: typing.Optional[typing.Tuple[int, int]] = None, - ) -> typing.List[subsequence.pattern.MirrorSpec]: - - """ - Validate and normalise a list of mirror destinations. - - Each entry is a 2- or 3-element sequence — ``(device_idx, channel)`` or - ``(device_idx, channel, drum_note_map)`` — as a tuple, list, or any such - iterable. ``channel`` is expressed in the user's channel-numbering - convention (1-16 by default, 0-15 when ``zero_indexed_channels=True``); - this method converts it to canonical 0-indexed form and rejects - malformed entries. The optional ``drum_note_map`` is preserved verbatim - so the sequencer can re-resolve mirrored drum names per device. - - String device names are NOT supported here; users wanting a named - device should pass the integer index returned from ``midi_output()``. - - If ``primary=(device, channel)`` is supplied (canonical 0-indexed - form), a mirror entry whose ``(device, channel)`` matches it triggers a - ``logger.warning`` — this is almost always a user error (every event - would double-fire on the same destination). The optional map is ignored - for this comparison. Skipped when ``primary`` is ``None``, since the - runtime API call site supplies its own check. - """ - - if mirrors is None: - return [] - - resolved: typing.List[subsequence.pattern.MirrorSpec] = [] - - for entry in mirrors: - - # Accept any 2- or 3-element iterable (tuple, list, etc.) — config - # files and JSON sources naturally produce lists. Validate shape at - # decoration time so bad inputs surface here instead of producing - # inscrutable failures inside the sequencer. - try: - items = list(entry) - except TypeError: - raise ValueError(f"Mirror entry must be a (device, channel[, drum_note_map]) tuple — got {entry!r}") - - if len(items) not in (2, 3): - raise ValueError(f"Mirror entry must have 2 or 3 elements (device, channel[, drum_note_map]) — got {entry!r}") - - device = items[0] - channel = items[1] - drum_map = items[2] if len(items) == 3 else None - - if not isinstance(device, int) or isinstance(device, bool): - raise ValueError(f"Mirror device must be an integer index — got {type(device).__name__} ({device!r})") - - if not isinstance(channel, int) or isinstance(channel, bool): - raise ValueError(f"Mirror channel must be an integer — got {type(channel).__name__} ({channel!r})") - - if drum_map is not None and not isinstance(drum_map, dict): - raise ValueError(f"Mirror drum_note_map must be a dict or None — got {type(drum_map).__name__} ({drum_map!r})") - - resolved_channel = self._resolve_channel(channel) - - if primary is not None and (device, resolved_channel) == primary: - logger.warning( - f"Mirror destination {(device, resolved_channel)} matches the pattern's primary destination " - f"— every event will double-fire on this (device, channel). This is almost " - f"certainly unintended." - ) - - resolved_entry: subsequence.pattern.MirrorSpec = ( - (device, resolved_channel) - if drum_map is None - else (device, resolved_channel, drum_map) - ) - resolved.append(resolved_entry) - - return resolved - - @property - def harmonic_state (self) -> typing.Optional[subsequence.harmonic_state.HarmonicState]: - """The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called.""" - return self._harmonic_state - - def current_chord (self) -> typing.Optional[typing.Any]: - - """The chord sounding at the playhead, or ``None`` without harmony. - - Reads the harmony window at the current pulse, so it stays accurate - under variable harmonic rhythm and clock lookahead (the engine's - ``current_chord`` flips *lookahead* beats early — this does not). - Falls back to the engine's chord before playback starts. The chord - may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span - is spiced; it duck-types the ``Chord`` voicing protocol either way. - """ - - if not self._harmony_horizon.is_empty: - beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat - chord = self._harmony_horizon.chord_at(beat) - if chord is not None: - return chord - - if self._harmonic_state is not None: - return self._harmonic_state.get_current_chord() - - return None - - def _effective_key_scale ( - self, - section_info: typing.Optional["subsequence.form_state.SectionInfo"], - ) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: - - """Resolve the key and scale in force, by the key-source precedence. - - The layered chain, key and scale resolved **independently** so a - section can move the tonic, the mode, or both: - ``Section.key`` > form key (``form(key=)`` / ``Form(key=)``) > - ``Composition.key``, and likewise for scale. This is the one place - the tier order lives; every placement site routes through it so the - section key reaches every compositional element uniformly (the - three-intent model: only *key-relative* content reads this — absolute - content ignores it, chord-relative content tracks the chord). - """ - - key: typing.Optional[str] = None - scale: typing.Optional[str] = None - - if section_info is not None: - key = section_info.key - scale = section_info.scale - - if key is None: - key = self._form_key - if key is None: - key = self.key - - if scale is None: - scale = self._form_scale - if scale is None: - scale = self.scale - - return key, scale - - def _resolve_section_progression ( - self, - info: "subsequence.form_state.SectionInfo", - ) -> typing.Optional[Progression]: - - """Resolve a section's bound progression against its effective key/scale. - - Concrete progressions (names, ``PitchSet``, frozen captures) are - returned unchanged. Key-relative ones resolve against the section's - effective key+scale — memoised per ``(name, key, scale)`` so a stable - section reuses one realisation and span identity is stable across - ticks. If no key is resolvable at this moment the section is skipped - (returns ``None`` → falls through to the bound/live source) with a - warning; the authoritative check runs at :meth:`play`/:meth:`render`. - """ - - raw = self._section_progressions.get(info.name) - - if raw is None or raw.is_concrete: - return raw - - key, scale = self._effective_key_scale(info) - - if key is None: - logger.warning( - "section_chords(%r) is key-relative but no key resolves for this section — " - "skipping (the chords fall through to the live/bound source)", - info.name, - ) - return None - - cache_key = (info.name, key, scale) - cached = self._resolved_section_cache.get(cache_key) - - if cached is not None: - return cached - - try: - resolved = raw.resolve(key, scale or "ionian") - except ValueError as error: - # A degree out of range for the effective scale, or an unknown - # scale — never let it escape the clock callback (that would kill - # harmony for the rest of playback). Skip the section; the _run - # pre-flight catches the common case far earlier. - logger.warning( - "section_chords(%r) cannot resolve against %s %s (%s) — skipping; " - "the chords fall through to the live/bound source", - info.name, key, scale or "ionian", error, - ) - return None - - self._resolved_section_cache[cache_key] = resolved - return resolved - - @property - def form_state (self) -> typing.Optional["subsequence.form_state.FormState"]: - """The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called.""" - return self._form_state - - @property - def sequencer (self) -> subsequence.sequencer.Sequencer: - """The underlying ``Sequencer`` instance.""" - return self._sequencer - - @property - def running_patterns (self) -> typing.Dict[str, typing.Any]: - """The currently active patterns, keyed by name.""" - return self._running_patterns - - @property - def builder_bar (self) -> int: - """Current bar index used by pattern builders.""" - return self._builder_bar - - def _require_harmonic_state (self) -> subsequence.harmonic_state.HarmonicState: - """Return the active HarmonicState, raising ValueError if none is configured.""" - if self._harmonic_state is None: - raise ValueError( - "harmony() must be called before this action — " - "no harmonic state has been configured." - ) - return self._harmonic_state - - def _coerce_progression (self, source: typing.Any, what: str) -> Progression: - - """Coerce a Progression / element list / preset name and resolve it against the key. - - Binding freezes one realisation (the value type's identity), so - key-relative content resolves here, at bind time, against the - composition's key and scale. Used by the *global* bound progression - (``harmony(progression=)``) — which is not section-scoped, so it has - nothing to re-key against. - """ - - value = source if isinstance(source, Progression) else subsequence.progressions.progression(source) - - if not value.is_concrete: - if self.key is None: - raise ValueError( - f"{what} contains key-relative chords (degrees/romans) — " - "set key= on the Composition so they can resolve" - ) - value = value.resolve(self.key, self.scale or "ionian") - - return value - - def _coerce_section_progression (self, source: typing.Any) -> Progression: - - """Coerce a section progression, keeping key-relative content UNRESOLVED. - - Section harmony re-keys per occurrence (the section/form/composition - key in force when the section plays), so a key-relative progression is - stored relative and resolved late, in the clock, against the section's - effective key+scale — unlike the global bound progression, which - freezes at bind. Concrete content (chord names, frozen captures, - ``PitchSet``) is already absolute and never moves. - """ - - return source if isinstance(source, Progression) else subsequence.progressions.progression(source) - - def harmony ( - self, - style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None, - cycle_beats: int = 4, - dominant_7th: bool = True, - gravity: float = 1.0, - nir_strength: float = 0.5, - minor_turnaround_weight: float = 0.0, - root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, - reschedule_lookahead: float = 1, - progression: typing.Optional[typing.Any] = None, - ) -> None: - - """ - Configure the harmonic logic and chord change intervals. - - Two sources, combinable: a **bound progression** (``progression=`` — a - :class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``, - or chord names) walked span by span on the global clock; and/or a - **graph style** stepping live chords. With only a progression bound, - it loops on exhaustion; with a style configured too, exhaustion falls - through to live stepping (the frozen-replay bridge). Calling with - neither argument keeps today's default live engine - (``style="functional_major"``). - - Parameters: - style: The harmonic style to use. Built-in: "functional_major" - (alias "diatonic_major"), "hooktheory_major" (alias - "pop_major"), "turnaround", "aeolian_minor", - "phrygian_minor", "lydian_major", "dorian_minor", - "chromatic_mediant", "suspended", "mixolydian", "whole_tone", - "diminished". See README for full descriptions. - cycle_beats: How many beats each live chord lasts (default 4). - Bound progressions carry their own harmonic rhythm in their - spans, so this applies to live stepping only. A re-call - during playback takes effect from the next chord boundary; - a FIRST harmony() call mid-playback starts the clock itself. - dominant_7th: Whether to include V7 chords (default True). - gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord. - nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement - expectations. - minor_turnaround_weight: For "turnaround" style, influences major vs minor feel. - root_diversity: Root-repetition damping (0.0 to 1.0). Each recent - chord sharing a candidate's root reduces the weight to 40% at - the default (0.4). Set to 1.0 to disable. - reschedule_lookahead: How many beats in advance to calculate the - next chord. - progression: A progression to bind to the global clock. Key- - relative content resolves now, against the composition key - and scale (binding freezes one realisation). - - Example: - ```python - # A moody minor progression that changes every 8 beats - comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4) - - # Manual harmony driving everything — loops forever - comp.harmony(progression=subsequence.progression([1, 6, 3, 7])) - ``` - """ - - if style is None and progression is None: - # A parameter-only re-call (gravity=, cycle_beats=, ...) keeps the - # configured style — defaulting unconditionally here would silently - # replace e.g. aeolian_minor with functional_major. - style = self._last_harmony_style if self._last_harmony_style is not None else "functional_major" - - if style is not None: - - if self.key is None: - raise ValueError("Cannot configure harmony without a key - set key in the Composition constructor") - - preserved_history: typing.List[subsequence.chords.Chord] = [] - preserved_current: typing.Optional[subsequence.chords.Chord] = None - - if self._harmonic_state is not None: - preserved_history = self._harmonic_state.history.copy() - preserved_current = self._harmonic_state.current_chord - - # Per-call salted build stream (harmony:1, harmony:2, ...): a re-call - # gets its own deterministic stream while history and current chord - # are preserved above, and adding a re-call never shifts any other - # consumer's stream. - self._harmony_count += 1 - - self._harmonic_state = subsequence.harmonic_state.HarmonicState( - key_name = self.key, - graph_style = style, - include_dominant_7th = dominant_7th, - key_gravity_blend = gravity, - nir_strength = nir_strength, - minor_turnaround_weight = minor_turnaround_weight, - root_diversity = root_diversity, - rng = self._stream(f"harmony:{self._harmony_count}") - ) - - if preserved_history: - self._harmonic_state.history = preserved_history - if preserved_current is not None and self._harmonic_state.graph.get_transitions(preserved_current): - self._harmonic_state.current_chord = preserved_current - - self._harmony_style = style if isinstance(style, str) else None - self._last_harmony_style = style - - if progression is not None: - self._bound_progression = self._coerce_progression(progression, "harmony(progression=)") - - self._harmony_cycle_beats = cycle_beats - self._harmony_reschedule_lookahead = reschedule_lookahead - - # A re-call invalidates whatever the horizon had planned. - self._harmony_horizon.invalidate_future() - - # A FIRST harmony() call mid-playback must start the clock itself — - # _run() only schedules clocks for sources it can see at play() time. - # (Re-calls need nothing here: the clock reads its sources through - # getters on every tick.) - loop = self._sequencer._event_loop - - if loop is not None and loop.is_running() and not self._harmonic_clock_started: - try: - on_loop = asyncio.get_running_loop() is loop - except RuntimeError: - on_loop = False - - if on_loop: - loop.create_task(self._start_harmonic_clock()) - else: - asyncio.run_coroutine_threadsafe(self._start_harmonic_clock(), loop) - - async def _start_harmonic_clock (self, bar_beats: typing.Optional[float] = None, clock_lookahead: typing.Optional[float] = None) -> None: - - """Register the span-walking harmonic clock (idempotent per playback). - - Called from ``_run()`` when a harmony source exists at play time, and - from ``harmony()`` when the FIRST source arrives mid-playback. - ``bar_beats``/``clock_lookahead`` default to a fresh computation for - the mid-playback path; ``_run()`` passes the values it validated. - """ - - if self._harmonic_clock_started: - return - - self._harmonic_clock_started = True - - if bar_beats is None: - bar_beats = float(self.time_signature[0]) - - if clock_lookahead is None: - lookaheads = [pattern.reschedule_lookahead for pattern in self._running_patterns.values()] - clock_lookahead = min(bar_beats, max(1.0, float(self._harmony_reschedule_lookahead), float(max(lookaheads, default = 1)))) - - def _get_section_progression () -> typing.Optional[typing.Tuple[str, int, int, typing.Optional[Progression]]]: - """Return (section_name, section_index, bars, Progression|None) for the current section, or None. - - The progression is resolved against the section's effective - key/scale here (key-relative section harmony re-keys per - occurrence); concrete content passes through unchanged. - """ - if self._form_state is None: - return None - info = self._form_state.get_section_info() - if info is None: - return None - prog = self._resolve_section_progression(info) - return (info.name, info.index, info.bars, prog) - - def _resolve_cadence_formula (name: str) -> typing.List[subsequence.chords.Chord]: - """Resolve a cadence formula against the composition key and scale, at plan time.""" - hs = self._harmonic_state - key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else (hs.key_root_pc if hs is not None else 0) - spec = subsequence.cadences.cadence_formula(name) - return [ - subsequence.progressions.resolve_constraint(element, key_pc, self._constraint_scale(), f"cadence {name!r}") - for element in spec.formula - ] - - await schedule_harmonic_clock( - sequencer = self._sequencer, - get_harmonic_state = lambda: self._harmonic_state, - horizon = self._harmony_horizon, - bar_beats = bar_beats, - cycle_beats = self._harmony_cycle_beats or 4, - get_cycle_beats = lambda: self._harmony_cycle_beats or 4, - get_bound_progression = lambda: self._bound_progression, - get_section_progression = _get_section_progression, - get_pinned = self._resolve_pin, - cadence_requests = self._cadence_requests, - resolve_cadence = _resolve_cadence_formula, - get_section_cadence = self._section_cadences.get, - reschedule_lookahead = clock_lookahead, - ) - - def _constraint_scale (self) -> str: - - """The scale that hybrid-constraint ints resolve against. - - The composition's own scale when set; otherwise inferred from the - harmony style (``aeolian_minor`` → minor, matching - :meth:`Progression.generate`'s documented inference), falling back - to ionian. Roman strings carry their quality and never need it. - """ - - if self.scale is not None: - return self.scale - - return subsequence.progressions._STYLE_SCALES.get(self._harmony_style or "", "ionian") - - def freeze ( - self, - bars: int, - end: typing.Optional[typing.Any] = None, - pins: typing.Optional[typing.Dict[int, typing.Any]] = None, - avoid: typing.Optional[typing.Sequence[typing.Any]] = None, - cadence: typing.Optional[str] = None, - ) -> "Progression": - - """Capture a chord progression from the live harmony engine. - - Runs the harmony engine forward by *bars* chord changes, records each - chord, and returns it as a :class:`Progression` that can be bound to a - form section with :meth:`section_chords`. - - The engine state **advances** — successive ``freeze()`` calls produce a - continuing compositional journey so section progressions feel like parts - of a whole rather than isolated islands. - - The hybrid constraints compile into the walk: ``end=`` fixes the last - bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=`` - excludes chords throughout. Specs follow the progression-element - grammar (ints where diatonic, roman/name strings where chromatic) and - resolve against the composition key and scale. A backward - feasibility pass guarantees satisfiability before any chord is drawn; - the forward walk keeps the engine's real history-dependent weighting. - Bar 1 is always the engine's current chord — the journey continues — - so ``pins={1: ...}`` may only name it redundantly. - - Parameters: - bars: Number of chords to capture (one per harmony cycle). - end: The chord at the final bar — ``end="V"`` is the cadential - major dominant in minor. - pins: ``{bar: chord}`` — 1-based fiat positions. - avoid: Chords excluded from the walk. - cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ - ``"fakeout"``, theory aliases accepted) — its formula pins - the final bars, so the walk approaches the close. - Conflicts with ``end=`` or pins on those bars. - - Returns: - A :class:`Progression` with the captured chords and trailing - history for NIR continuity. - - Raises: - ValueError: If :meth:`harmony` has not been called first, or the - constraints are contradictory or unsatisfiable. - - Example:: - - composition.harmony(style="functional_major", cycle_beats=4) - verse = composition.freeze(8, end="V") # the verse sets up the chorus - chorus = composition.freeze(4) # next 4 chords, continuing on - composition.section_chords("verse", verse) - composition.section_chords("chorus", chorus) - """ - - hs = self._require_harmonic_state() - - if bars < 1: - raise ValueError("bars must be at least 1") - - if cadence is not None: - pins = subsequence.progressions.cadence_pins(cadence, bars, pins, end) - end = None - - scale = self._constraint_scale() - key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else hs.key_root_pc - - resolved_pins = { - position: subsequence.progressions.resolve_constraint(spec, key_pc, scale, f"pins[{position}]") - for position, spec in (pins or {}).items() - } - resolved_end = subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") if end is not None else None - resolved_avoid = [subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") for spec in (avoid or [])] - - if 1 in resolved_pins and resolved_pins[1] != hs.current_chord: - raise ValueError( - f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord " - f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; " - "pin a later bar, or use pin_chord() for playback fiat" - ) - - # Per-call salted stream (freeze:1, freeze:2, ...): each call's draws - # are independent of every other consumer, so frozen progressions are - # reproducible WITHOUT play() and adding a call cannot shift a - # neighbour's output. Engine state still advances normally — chord - # continuity comes from current_chord/history, randomness from the - # salted stream (swap-and-restore keeps hs.rng for play untouched). - self._freeze_count += 1 - stream = self._stream(f"freeze:{self._freeze_count}") - saved_rng = hs.rng - - if stream is not None: - hs.rng = stream - - try: - # The kernel with the engine's own hooks is draw-for-draw the old - # step() loop when unconstrained — one walk path for both. - def _commit (chosen: subsequence.chords.Chord) -> None: - hs.current_chord = chosen - - collected = subsequence.sequence_utils.constrained_walk( - hs.graph, - hs.current_chord, - bars, - rng = hs.rng, - pins = resolved_pins, - end = resolved_end, - avoid = resolved_avoid, - weight_modifier = hs._transition_weight, - before_choice = hs._record_transition_source, - after_choice = _commit, - ) - - # Advance past the last captured chord so the next freeze() call or - # live playback does not duplicate it. - hs.step() - - finally: - hs.rng = saved_rng - - span_beats = float(self._harmony_cycle_beats or 4) - - return Progression( - spans = tuple( - subsequence.progressions.ChordSpan(chord = chord, beats = span_beats) - for chord in collected - ), - trailing_history = tuple(hs.history), - ) - - def section_chords (self, section_name: str, progression: typing.Any) -> None: - - """Bind a :class:`Progression` to a named form section. - - Every time *section_name* plays, the harmonic clock walks the - progression's spans instead of calling the live engine. Sections - without a bound progression continue generating live chords. - - Accepts a :class:`Progression` value (from :meth:`freeze`, the - ``progression()`` factory, or hand-built) or anything the factory - accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord - names. - - **Key-relative content re-keys per occurrence.** A progression - written in degrees or romans is *key-relative* content: it resolves - late, each time the section plays, against that section's effective - key and scale (``Section.key`` > form key > composition key, with - mode following the same chain). So a ``Section(key="A")`` plays the - same numbered progression a tone higher — its chords and its degrees - share one tonic. *Absolute* content — chord names (``"Am"``), - :class:`~subsequence.progressions.PitchSet`, and frozen captures from - :meth:`freeze` — names exact chords and is never transposed by a key. - - On exhaustion mid-section the progression loops when no graph style - is configured (and always when it contains a ``PitchSet``); with a - live engine, exhaustion **falls through to live stepping in the - COMPOSITION key** — the live graph engine does not transpose for a - section (a stateful walk does not modulate mid-stream), so a - re-keyed section that runs out of written chords hands off to - composition-key harmony. Bind a full-length progression (or set - ``at_end``/loop intent) if you need the whole section in its key. - - Parameters: - section_name: Name of the section as defined in :meth:`form`. - progression: The progression to bind. - - Raises: - ValueError: If a graph-based form has been configured and - *section_name* is not one of its sections. List and generator - forms yield names lazily, so they cannot be validated here. - (A key-relative progression with no resolvable key for the - section is caught at :meth:`play`/:meth:`render`, once the - form's keys are known.) - - Example:: - - composition.section_chords("verse", verse_progression) - composition.section_chords("chorus", [1, 6, 3, 7]) - # "bridge" is not bound — it generates live chords - """ - - if ( - self._form_state is not None - and self._form_state._section_bars is not None - and section_name not in self._form_state._section_bars - ): - known = ", ".join(sorted(self._form_state._section_bars)) - raise ValueError( - f"Section '{section_name}' not found in form. " - f"Known sections: {known}" - ) - - self._section_progressions[section_name] = self._coerce_section_progression(progression) - self._resolved_section_cache = {} - self._harmony_horizon.invalidate_future() - - def pin_chord (self, bar: int, chord: typing.Optional[typing.Any]) -> None: - - """Force the chord sounding at a bar — fiat over live generation. - - Whatever the harmonic source (live walk, bound progression, section - progression) produces for *bar*, the pinned chord overrides it. - Pass ``None`` to remove a pin. - - Parameters: - bar: 1-based bar number (the musician count). - chord: A chord name, int degree, roman string, ``Chord``, - ``PitchSet``, or ``None`` to unpin. A **key-relative** spec - (int degree, roman) re-keys like section harmony: it resolves - late, against the effective key of the section sounding at - that bar (so ``pin_chord(8, "V")`` is the dominant of - wherever bar 8 lands). A **concrete** spec (name, ``Chord``, - ``PitchSet``) is absolute and never moves. - - Example:: - - composition.pin_chord(8, "E7") # the turnaround lands on E7 - composition.pin_chord(8, "V") # the dominant of bar 8's section - composition.pin_chord(8, None) # let it walk again - """ - - if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: - raise ValueError(f"bars are 1-based ints, got {bar!r}") - - if chord is None: - self._pinned_chords.pop(bar, None) - else: - # Store the parsed span — relative pins resolve late (per section) - # at the clock; concrete pins are absolute. - span = subsequence.progressions.parse_element(chord, beats = float(self.time_signature[0])) - - if not span.is_concrete: - # Raise early only when no key is resolvable for this bar — the - # bar's own section (sequence forms) may supply one even with no - # composition/form key. - probe_info = self._form_state.section_info_at_bar(bar) if self._form_state is not None else None - probe_key, _ = self._effective_key_scale(probe_info) - if probe_key is None: - raise ValueError( - "pin_chord with a key-relative spec (degree/roman) needs a key — set key= on " - "the Composition, a form key, or a Section.key for that bar (the pin re-keys " - "to the section's effective key)" - ) - - self._pinned_chords[bar] = span - - self._harmony_horizon.invalidate_future() - - def _resolve_pin (self, bar: int) -> typing.Optional[typing.Any]: - - """Resolve a stored pin to a chord-like, re-keying relative pins per section. - - Concrete pins return their chord directly; key-relative pins resolve - against the effective key of the section sounding now (the clock - reads this as it reaches each bar). Returns ``None`` (no pin / a - relative pin with no resolvable key, warned) so the clock falls - through to its normal source. - """ - - span = self._pinned_chords.get(bar) - - if span is None: - return None - - if span.is_concrete: - return _span_chord(span) - - # Key the pin to the section that OWNS this bar (the clock's lookahead - # can project a pin into a later, differently-keyed section while the - # playhead is still earlier) — fall back to the playhead section where - # a per-bar section is not computable (graph/generator forms). - info: typing.Optional["subsequence.form_state.SectionInfo"] = None - if self._form_state is not None: - info = self._form_state.section_info_at_bar(bar) - if info is None: - info = self._form_state.get_section_info() - - key, scale = self._effective_key_scale(info) - - if key is None: - logger.warning( - "pin_chord(%d, ...) is key-relative but no key resolves for that bar — ignoring the pin", - bar, - ) - return None - - return _span_chord(span.resolve(subsequence.chords.key_name_to_pc(key), scale or "ionian")) - - def request_cadence (self, cadence: str = "strong", bar: typing.Optional[int] = None) -> None: - - """Ask the live engine to approach a cadence arriving at a bar. - - The request hook: where :meth:`pin_chord` is fiat, this is a - *steered approach* — at the next chord boundary the clock plans the - remaining changes up to *bar* as a constrained walk through the - engine's real weights, pinned to the cadence formula at the tail - (``"strong"`` arrives V→I, ``"soft"`` IV→I, ``"open"`` IV→V, - ``"fakeout"`` V→vi; theory aliases accepted). The chords still - commit one boundary at a time, so the journey continues through the - close. - - One-shot: the request is consumed when planned. Live harmony only — - bound/section progressions are data and cannot be steered; a request - whose bar passes unserved expires with a warning. If the formula is - not walkable from where the harmony stands, the arrival lands by - fiat (loudly). Ask at least a pattern-lookahead ahead: patterns may - already have rendered against the previously planned chord. - - Parameters: - cadence: The cadence name. - bar: The 1-based bar the cadence's final chord arrives at - (required; in practice ≥ 2 — bar 1 cannot be approached). - - Example:: - - composition.request_cadence("open", bar=16) # hang on V at bar 16 - """ - - spec = subsequence.cadences.cadence_formula(cadence) - - if bar is None or not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: - raise ValueError(f"request_cadence needs bar= — the 1-based bar the cadence arrives at (got {bar!r})") - - self._cadence_requests[bar] = spec.name - self._harmony_horizon.invalidate_future() - - def section_cadence (self, section_name: str, cadence: typing.Optional[str] = "strong") -> None: - - """Close every pass of a section with a cadence — the standing request. - - Each time *section_name* is entered, the clock registers a - :meth:`request_cadence` arriving at the section's final bar, so the - harmony approaches the close as the section ends. Live harmony - only: a section with bound chords (:meth:`section_chords`) is data - and ignores the registration — its closes are written, not steered. - Pass ``None`` to unregister. - - Example:: - - composition.form([("verse", 8), ("chorus", 8)]) - composition.section_cadence("verse", "open") # every verse hangs on V - composition.section_cadence("chorus", "strong") # every chorus lands home - """ - - if cadence is None: - self._section_cadences.pop(section_name, None) - return - - spec = subsequence.cadences.cadence_formula(cadence) - self._section_cadences[section_name] = spec.name - - def section_motifs (self, section_name: str, value: typing.Any, part: typing.Optional[str] = None) -> None: - - """Bind a Motif or Phrase to a named form section (per optional part). - - Patterns read the binding back with ``p.section_motif(part)`` (or use - the one-call :meth:`phrase_part`); a section with no binding for the - part is silent for that part — bind material or don't, no fallback - guessing. Re-binding is idempotent, so the call is safe in a live - file: re-executing on save is the desired rebind. - - Parameters: - section_name: Name of the section as defined in :meth:`form`. - value: A ``Motif`` or ``Phrase`` (anything exposing - ``.length``/``.slice`` places). - part: Optional part label, so one section can carry several - bindings (``"lead"``, ``"bass"``, ...). - - Raises: - ValueError: If a graph-based form has been configured and - *section_name* is not one of its sections. - - Example:: - - composition.section_motifs("verse", verse_line, part="lead") - composition.section_motifs("chorus", chorus_line, part="lead") - """ - - if not hasattr(value, "length") or not hasattr(value, "slice"): - raise TypeError( - f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}" - ) - - if ( - self._form_state is not None - and self._form_state._section_bars is not None - and section_name not in self._form_state._section_bars - ): - known = ", ".join(sorted(self._form_state._section_bars)) - raise ValueError( - f"Section '{section_name}' not found in form. " - f"Known sections: {known}" - ) - - self._section_motifs[(section_name, part)] = value - - def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None: - - """ - Register a callback for a sequencer event (e.g., "bar", "start", "stop"). - """ - - self._sequencer.on_event(event_name, callback) - - - # ----------------------------------------------------------------------- - # Hotkey API - # ----------------------------------------------------------------------- - - def hotkeys (self, enabled: bool = True) -> None: - - """Enable or disable the global hotkey listener. - - Must be called **before** :meth:`play` to take effect. When enabled, a - background thread reads single keystrokes from stdin without requiring - Enter. The ``?`` key is always reserved and lists all active bindings. - - Hotkeys have zero impact on playback when disabled — the listener - thread is never started. - - Args: - enabled: ``True`` (default) to enable hotkeys; ``False`` to disable. - - Example:: - - composition.hotkeys() - composition.hotkey("a", lambda: composition.form_jump("chorus")) - composition.play() - """ - - self._hotkeys_enabled = enabled - - - def hotkey ( - self, - key: str, - action: typing.Callable[[], None], - quantize: int = 0, - label: typing.Optional[str] = None, - ) -> None: - - """Register a single-key shortcut that fires during playback. - - The listener must be enabled first with :meth:`hotkeys`. - - Most actions — form jumps, ``composition.data`` writes, and - :meth:`tweak` calls — should use ``quantize=0`` (the default). Their - musical effect is naturally delayed to the next pattern rebuild cycle, - which provides automatic musical quantization without extra configuration. - - Use ``quantize=N`` for actions where you want an explicit bar-boundary - guarantee, such as :meth:`mute` / :meth:`unmute`. - - The ``?`` key is reserved and cannot be overridden. - - Args: - key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``). - action: Zero-argument callable to execute. - quantize: ``0`` = execute immediately (default). ``N`` = execute - on the next global bar number divisible by *N*. - label: Display name for the ``?`` help listing. Auto-derived from - the function name or lambda body if omitted. - - Raises: - ValueError: If ``key`` is the reserved ``?`` character, or if - ``key`` is not exactly one character. - - Example:: - - composition.hotkeys() - - # Immediate — musical effect happens at next pattern rebuild - composition.hotkey("a", lambda: composition.form_jump("chorus")) - composition.hotkey("1", lambda: composition.data.update({"mode": "chill"})) - - # Explicit 4-bar phrase boundary - composition.hotkey("s", lambda: composition.mute("drums"), quantize=4) - - # Named function — label is derived automatically - def drop_to_breakdown (): - composition.form_jump("breakdown") - composition.mute("lead") - - composition.hotkey("d", drop_to_breakdown) - - composition.play() - """ - - if len(key) != 1: - raise ValueError(f"hotkey key must be a single character, got {key!r}") - - if key == _HOTKEY_RESERVED: - raise ValueError(f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys.") - - derived = label if label is not None else _derive_label(action) - - self._hotkey_bindings[key] = HotkeyBinding( - key = key, - action = action, - quantize = quantize, - label = derived, - ) - - - def form_jump (self, section_name: str) -> None: - - """Jump the form to a named section immediately. - - Delegates to :meth:`subsequence.form_state.FormState.jump_to`. Only works when the - composition uses graph-mode form (a dict passed to :meth:`form`). - - The musical effect is heard at the *next pattern rebuild cycle* — already- - queued MIDI notes are unaffected. This natural delay means ``form_jump`` - is effective without needing explicit quantization. - - Args: - section_name: The section to jump to. - - Raises: - ValueError: If no form is configured, or the form is not in graph - mode, or *section_name* is unknown. - - Example:: - - composition.hotkey("c", lambda: composition.form_jump("chorus")) - """ - - if self._form_state is None: - raise ValueError("form_jump() requires a form to be configured via composition.form().") - - self._form_state.jump_to(section_name) - - # The harmony horizon planned against the old section — revoke it. - self._harmony_horizon.invalidate_future() - - - def form_next (self, section_name: str) -> None: - - """Queue the next section — takes effect when the current section ends. - - Unlike :meth:`form_jump`, this does not interrupt the current section. - The queued section replaces the automatically pre-decided next section - and takes effect at the natural section boundary. The performer can - change their mind by calling ``form_next`` again before the boundary. - - Delegates to :meth:`subsequence.form_state.FormState.queue_next`. Only works when the - composition uses graph-mode form (a dict passed to :meth:`form`). - - Args: - section_name: The section to queue. - - Raises: - ValueError: If no form is configured, or the form is not in graph - mode, or *section_name* is unknown. - - Example:: - - composition.hotkey("c", lambda: composition.form_next("chorus")) - """ - - if self._form_state is None: - raise ValueError("form_next() requires a form to be configured via composition.form().") - - self._form_state.queue_next(section_name) - - # The harmony horizon planned against the old continuation — revoke it. - self._harmony_horizon.invalidate_future() - - - def _list_hotkeys (self) -> None: - - """Log all active hotkey bindings (triggered by the ``?`` key). - - Output appears via the standard logger so it scrolls cleanly above - the :class:`~subsequence.display.Display` status line. - """ - - lines = ["Active hotkeys:"] - for key in sorted(self._hotkey_bindings): - b = self._hotkey_bindings[key] - quant_str = "immediate" if b.quantize == 0 else f"quantize={b.quantize}" - lines.append(f" {key} \u2192 {b.label} ({quant_str})") - lines.append(f" ? \u2192 list hotkeys") - logger.info("\n".join(lines)) - - - def _process_hotkeys (self, bar: int) -> None: - - """Drain pending keystrokes and execute due actions. - - Called on every ``"bar"`` event by the sequencer when hotkeys are - enabled. Handles both immediate (``quantize=0``) and quantized actions. - - Both kinds run here, on the bar-event callback (the event loop): the - keystroke listener thread only enqueues keypresses (``drain()``), it - never executes actions. Immediate (``quantize=0``) bindings fire as soon - as the key is drained; quantized ones wait for their next boundary. - - Args: - bar: The current global bar number from the sequencer. - """ - - if self._keystroke_listener is None: - return - - # Process newly arrived keys. - for key in self._keystroke_listener.drain(): - - if key == _HOTKEY_RESERVED: - self._list_hotkeys() - continue - - binding = self._hotkey_bindings.get(key) - if binding is None: - continue - - if binding.quantize == 0: - # Immediate — execute now (we're on the bar-event callback, - # which is safe for all mutation methods). - try: - binding.action() - logger.info(f"Hotkey '{key}' \u2192 {binding.label}") - except Exception as exc: - logger.warning(f"Hotkey '{key}' action raised: {exc}") - else: - # Defer until the next quantize boundary. - self._pending_hotkey_actions.append( - _PendingHotkeyAction(binding=binding) - ) - - # Fire any pending actions whose bar boundary has arrived. - still_pending: typing.List[_PendingHotkeyAction] = [] - - for pending in self._pending_hotkey_actions: - if bar % pending.binding.quantize == 0: - try: - pending.binding.action() - logger.info( - f"Hotkey '{pending.binding.key}' \u2192 {pending.binding.label} " - f"(bar {bar})" - ) - except Exception as exc: - logger.warning( - f"Hotkey '{pending.binding.key}' action raised: {exc}" - ) - else: - still_pending.append(pending) - - self._pending_hotkey_actions = still_pending - - @property - def seed (self) -> typing.Optional[int]: - - """ - The composition's random seed, or None when unseeded. - - When set, every random decision derives deterministically from this - value through named streams (see ``seed_for()``), so the same script - produces the same music on every run. Assign to set it:: - - comp.seed = 42 - - (Formerly the method ``comp.seed(42)`` — the call form is a hard - break per the pre-1.0 rename policy.) - """ - - return self._seed - - @seed.setter - def seed (self, value: typing.Optional[int]) -> None: - - """Set the composition seed (``comp.seed = 42``).""" - - self._seed = value - - def _stream_seed (self, name: str) -> typing.Optional[int]: - - """ - Derive the effective integer seed for a named random stream. - - The derivation is ``zlib.crc32(f"{seed}:{name}")`` — crc32 rather - than ``hash()`` because it is stable across processes — plus the - per-name nonce when ``reroll()`` has been called. Returns None when - the composition is unseeded. - """ - - if self._seed is None: - return None - - nonce = self._reroll_nonces.get(name, 0) - key = f"{self._seed}:{name}" if nonce == 0 else f"{self._seed}:{name}:{nonce}" - return zlib.crc32(key.encode()) - - def _stream (self, name: str) -> typing.Optional[random.Random]: - - """A fresh ``random.Random`` for a named stream, or None when unseeded.""" - - stream_seed = self._stream_seed(name) - return None if stream_seed is None else random.Random(stream_seed) - - def seed_for (self, name: str) -> typing.Optional[int]: - - """ - Surface the effective derived seed for a named stream. - - Works for pattern names and equally for any name you invent for a - standalone value generator (``seed=composition.seed_for("hook")``), - so its randomness keys off the composition seed without sharing any - other consumer's stream. Reflects ``reroll()`` nonces. Returns None - when the composition is unseeded. - - Example: - ```python - hook_seed = composition.seed_for("hook") - ``` - """ - - return self._stream_seed(name) - - def reroll (self, name: str) -> None: - - """ - Deal a named stream a fresh deterministic seed — try a new variation. - - Bumps the per-name nonce and prints the new effective seed. The - nonce lives only in this process, so the printed seed is what lets a - variation you like survive a restart: note it down, or ``lock()`` the - name to pin it for the session. Refuses on locked names. - - Parameters: - name: The stream name — usually a pattern name. - - Example: - ```python - comp.reroll("lead") # prints: reroll('lead') -> effective seed ... - ``` - """ - - if name in self._locked_names: - print(f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first") - return - - self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1 - effective = self._stream_seed(name) - - if effective is None: - print(f"reroll('{name}'): composition has no seed - randomness is unseeded") - return - - running = self._running_patterns.get(name) - - if running is not None and hasattr(running, "_rng"): - running._rng = random.Random(effective) - - print(f"reroll('{name}') -> effective seed {effective} (nonce {self._reroll_nonces[name]})") - - def lock (self, name: str) -> None: - - """ - Pin a named stream: keep its current effective seed and realization. - - Engine-side state, so it survives live reload (it is never a builder - swap): a locked pattern re-deals its stream from the same effective - seed on every rebuild, so every cycle realizes identically, and - ``reroll()`` refuses with a message until ``unlock()``. - - Parameters: - name: The stream name — usually a pattern name. - """ - - self._locked_names.add(name) - - def unlock (self, name: str) -> None: - - """Release a ``lock()``: the stream runs free and ``reroll()`` works again.""" - - self._locked_names.discard(name) - - def tuning ( - self, - source: typing.Optional[typing.Union[str, "os.PathLike"]] = None, - *, - cents: typing.Optional[typing.List[float]] = None, - ratios: typing.Optional[typing.List[float]] = None, - equal: typing.Optional[int] = None, - bend_range: float = 2.0, - channels: typing.Optional[typing.List[int]] = None, - reference_note: int = 60, - exclude_drums: bool = True, - ) -> None: - - """Set a global microtonal tuning for the composition. - - The tuning is applied automatically after each pattern rebuild (before - the pattern is scheduled). Drum patterns (those registered with a - ``drum_note_map``) are excluded by default. - - Supply exactly one of the source parameters: - - - ``source``: path to a Scala ``.scl`` file. - - ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit). - - ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``). - - ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``). - - For polyphonic parts, supply a ``channels`` pool. Notes are spread - across those MIDI channels so each can carry an independent pitch bend. - The synth must be configured to match ``bend_range`` (its pitch-bend range - setting in semitones). - - Parameters: - source: Path to a ``.scl`` file. - cents: Cent offsets for scale degrees 1..N. - ratios: Frequency ratios for scale degrees 1..N. - equal: Number of equal divisions of the period. - bend_range: Synth pitch-bend range in semitones (default ±2). - channels: Channel pool for polyphonic rotation. - reference_note: MIDI note mapped to scale degree 0 (default 60 = C4). - exclude_drums: When True (default), skip patterns that have a - ``drum_note_map`` (they use fixed GM pitches, not tuned ones). - - Example: - ```python - # Quarter-comma meantone from a Scala file - comp.tuning("meanquar.scl") - - # Just intonation from ratios - comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2]) - - # 19-TET, monophonic - comp.tuning(equal=19, bend_range=2.0) - - # 31-TET with channel rotation for polyphony (channels 1-6) - comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5]) - ``` - """ - import subsequence.tuning as _tuning_mod - - given = sum(x is not None for x in [source, cents, ratios, equal]) - if given == 0: - raise ValueError("composition.tuning() requires one of: source, cents, ratios, or equal") - if given > 1: - raise ValueError("composition.tuning() accepts only one source parameter") - - if source is not None: - t = _tuning_mod.Tuning.from_scl(source) - elif cents is not None: - t = _tuning_mod.Tuning.from_cents(cents) - elif ratios is not None: - t = _tuning_mod.Tuning.from_ratios(ratios) - else: - t = _tuning_mod.Tuning.equal(equal) # type: ignore[arg-type] - - self._tuning = t - self._tuning_bend_range = bend_range - self._tuning_channels = channels - self._tuning_reference_note = reference_note - self._tuning_exclude_drums = exclude_drums - - def display (self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None: - - """ - Enable or disable the live terminal dashboard. - - When enabled, Subsequence uses a safe logging handler that allows a - persistent status line (BPM, Key, Bar, Section, Chord) to stay at - the bottom of the terminal while logs scroll above it. - - Parameters: - enabled: Whether to show the display (default True). - grid: When True, render an ASCII grid visualisation of all - running patterns above the status line. The grid updates - once per bar, showing which steps have notes and at what - velocity. - grid_scale: Horizontal zoom factor for the grid (default - ``1.0``). Higher values add visual columns between - grid steps, revealing micro-timing from swing and groove. - Snapped to the nearest integer internally for uniform - marker spacing. - """ - - if enabled: - self._display = subsequence.display.Display(self, grid=grid, grid_scale=grid_scale) - else: - self._display = None - - def web_ui (self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None: - - """ - Enable the realtime Web UI Dashboard. - - When enabled, Subsequence instantiates a WebSocket server that broadcasts - the current state, signals, and active patterns (with high-res timing and - note data) to any connected browser clients. - - Both servers bind to localhost by default. Pass ``http_host`` / ``ws_host`` - (e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but - broadcasts full composition state, so only do so on a trusted network. - """ - - self._web_ui_enabled = True - self._web_ui_http_host = http_host - self._web_ui_ws_host = ws_host - - def midi_input (self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None) -> None: - - """ - Configure a MIDI input device for external sync and MIDI messages. - - May be called multiple times to register additional input devices. - The first call sets the primary input (device 0). Subsequent calls - add additional input devices (device 1, 2, …). Only one device may - have ``clock_follow=True``. - - Parameters: - device: The name of the MIDI input port. - clock_follow: If True, Subsequence will slave its clock to incoming - MIDI Ticks. It will also follow MIDI Start/Stop/Continue - commands. Only one device can have this enabled at a time. - name: Optional alias for use with ``cc_map(input_device=…)`` and - ``cc_forward(input_device=…)``. When omitted, the raw device - name is used. - - Example: - ```python - # Single controller (unchanged usage) - comp.midi_input("Scarlett 2i4", clock_follow=True) - - # Multiple controllers - comp.midi_input("Arturia KeyStep", name="keys") - comp.midi_input("Faderfox EC4", name="faders") - ``` - """ - - if clock_follow: - if self.is_clock_following: - raise ValueError("Only one input device can be configured to follow external clock (clock_follow=True)") - - if self._input_device is None: - # First call: set primary input device (device 0) - self._input_device = device - self._input_device_alias = name - self._clock_follow = clock_follow - else: - # Subsequent calls: register additional input devices - self._additional_inputs.append((device, name, clock_follow)) - - def midi_output (self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0) -> int: - - """ - Register an additional MIDI output device. - - The first output device is always the one passed to - ``Composition(output_device=…)`` — that is device 0. - Each call to ``midi_output()`` adds the next device (1, 2, …). - - Parameters: - device: The exact name of the MIDI output port, as reported - by ``mido.get_output_names()``. Matching is strict — - partial names and substrings are rejected. See - ``Composition.__init__`` for the lookup snippet and a - note on ALSA name stability on Linux. - name: Optional alias for use with ``pattern(device=…)``, - ``cc_forward(output_device=…)``, etc. When omitted, the raw - device name is used. - latency_ms: Physical output latency of this device in - milliseconds, for delay compensation (default 0.0, must be - non-negative). Set this when the device sounds late (e.g. a - software sampler) so Subsequence delays faster devices to - line everything up. - - Returns: - The integer device index assigned (1, 2, 3, …). - - Example: - ```python - comp = subsequence.Composition(bpm=120, output_device="MOTU Express") - - # Returns 1 — use as device=1 or device="integra" - comp.midi_output("Roland Integra", name="integra") - - # A software sampler that sounds 20ms late - comp.midi_output("Subsample", name="sampler", latency_ms=20) - - @comp.pattern(channel=1, beats=4, device="integra") - def strings (p): - p.note(60, beat=0) - ``` - """ - - if latency_ms < 0: - raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") - - idx = 1 + len(self._additional_outputs) # device 0 is always the primary - self._additional_outputs.append(_AdditionalOutput(device=device, alias=name, latency_ms=latency_ms)) - return idx - - def _warn_if_high_latency (self) -> None: - - """Warn if delay compensation adds a large whole-rig latency. - - The slowest device defines the alignment point — every faster device is - delayed up to that amount — so a large maximum means the whole rig - responds late to live input. Emitted once at startup. - """ - - candidates: typing.List[typing.Tuple[str, float]] = [("primary output", self._output_latency_ms)] - candidates += [(out.alias or out.device, out.latency_ms) for out in self._additional_outputs] - - slow_name, max_ms = max(candidates, key=lambda c: c[1]) - - if max_ms > _LATENCY_WARN_THRESHOLD_MS: - logger.warning( - "Device latency compensation: '%s' is the slowest at %.0fms, so faster " - "devices are delayed up to %.0fms to stay aligned — live-input feel may suffer.", - slow_name, max_ms, max_ms, - ) - - def clock_output (self, enabled: bool = True) -> None: - - """ - Send MIDI timing clock to connected hardware. - - When enabled, Subsequence acts as a MIDI clock master and sends - standard clock messages on the output port: a Start message (0xFA) - when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), - and a Stop message (0xFC) when playback ends. - - This allows hardware synthesizers, drum machines, and effect units to - slave their tempo to Subsequence automatically. - - **Note:** Clock output is automatically disabled when ``midi_input()`` - is called with ``clock_follow=True``, to prevent a clock feedback loop. - - Parameters: - enabled: Whether to send MIDI clock (default True). - - Example: - ```python - comp = subsequence.Composition(bpm=120, output_device="...") - comp.clock_output() # hardware will follow Subsequence tempo - ``` - """ - - self._clock_output = enabled - - - def link (self, quantum: float = 4.0) -> "Composition": + def on_event( + self, event_name: str, callback: typing.Callable[..., typing.Any] + ) -> None: + """ + Register a callback for a sequencer event (e.g., "bar", "start", "stop"). + """ - """ - Enable Ableton Link tempo and phase synchronisation. + self._sequencer.on_event(event_name, callback) - When enabled, Subsequence joins the local Link session and slaves its - clock to the shared network tempo and beat phase. All other Link-enabled - apps on the same LAN — Ableton Live, iOS synths, other Subsequence - instances — will automatically stay in time. - - Playback starts on the next bar boundary aligned to the Link quantum, - so downbeats stay in sync across all participants. + # ----------------------------------------------------------------------- + # Hotkey API + # ----------------------------------------------------------------------- - Requires the ``link`` optional extra:: + def hotkeys(self, enabled: bool = True) -> None: + """Enable or disable the global hotkey listener. - pip install subsequence[link] + Must be called **before** :meth:`play` to take effect. When enabled, a + background thread reads single keystrokes from stdin without requiring + Enter. The ``?`` key is always reserved and lists all active bindings. - Parameters: - quantum: Beat cycle length. ``4.0`` (default) = one bar in 4/4 time. - Change this if your composition uses a different meter. + Hotkeys have zero impact on playback when disabled — the listener + thread is never started. - Example:: + Args: + enabled: ``True`` (default) to enable hotkeys; ``False`` to disable. - comp = subsequence.Composition(bpm=120, key="C") - comp.link() # join the Link session - comp.play() + Example:: - # On another machine / instance: - comp2 = subsequence.Composition(bpm=120) - comp2.link() # tempo and phase will lock to comp - comp2.play() - - Note: - ``set_bpm()`` proposes the new tempo to the Link network when Link - is active. The network-authoritative tempo is applied on the next - pulse, so there may be a brief lag before the change is visible. - """ - - # Eagerly check that aalink is installed — fail early with a clear message. - subsequence.link_clock._require_aalink() - - self._link_quantum = quantum - return self - - - def cc_map ( - self, - cc: int, - data_key: str, - channel: typing.Optional[int] = None, - min_val: float = 0.0, - max_val: float = 1.0, - input_device: subsequence.midi_utils.DeviceId = None, - ) -> None: - - """ - Map an incoming MIDI CC to a ``composition.data`` key. - - When the composition receives a CC message on the configured MIDI - input port, the value is scaled from the CC range (0–127) to - *[min_val, max_val]* and stored in ``composition.data[data_key]``. - - This lets hardware knobs, faders, and expression pedals control live - parameters without writing any callback code. - - **Requires** ``midi_input()`` to be called first to open an input port. - - Parameters: - cc: MIDI Control Change number (0–127). - data_key: The ``composition.data`` key to write. - channel: If given, only respond to CC messages on this channel. - Uses the same numbering convention as ``pattern()`` (1-16 - by default, or 0-15 with ``zero_indexed_channels=True``). - ``None`` matches any channel (default). - min_val: Scaled minimum — written when CC value is 0 (default 0.0). - max_val: Scaled maximum — written when CC value is 127 (default 1.0). - input_device: Only respond to CC messages from this input device - (index or name). ``None`` responds to any input device (default). - - Example: - ```python - comp.midi_input("Arturia KeyStep") - comp.cc_map(74, "filter_cutoff") # knob → 0.0–1.0 - comp.cc_map(7, "volume", min_val=0, max_val=127) # volume fader - - # Multi-device: only listen to CC 74 from the "faders" controller - comp.cc_map(74, "filter", input_device="faders") - ``` - """ - - resolved_channel = self._resolve_channel(channel) if channel is not None else None - - self._cc_mappings.append({ - 'cc': cc, - 'data_key': data_key, - 'channel': resolved_channel, - 'min_val': min_val, - 'max_val': max_val, - 'input_device': input_device, # resolved to int index in _run() - }) - - - def note_input ( - self, - channel: typing.Optional[int] = None, - release_ms: float = 30.0, - latch: bool = False, - input_device: subsequence.midi_utils.DeviceId = None, - ) -> None: - - """Track notes held on a MIDI keyboard for live arpeggiation. - - Incoming note-on/note-off messages build a live "currently held" set - that any pattern reads via ``p.held_notes()`` — typically fed straight - to ``p.arpeggio()``. The composition still authors the rhythm and - motion; the player's hands supply the pitch set. This is a live - *performance* layer over the deterministic, seeded composition: when - rendering headlessly there is no input, so ``p.held_notes()`` is empty - and seeded output is unchanged. - - **Requires** ``midi_input()`` to be called first to open an input port. - - Parameters: - channel: If given, only track notes on this channel. Uses the same - numbering convention as ``pattern()`` (1-16 by default, or 0-15 - with ``zero_indexed_channels=True``). ``None`` tracks any - channel (default). - release_ms: How long (milliseconds) a released note keeps counting - as held. This smooths the momentary all-keys-up gap during a - hand-position change so the arp does not drop to silence. - Default 30.0; set 0.0 to release instantly. Ignored when - ``latch`` is True. - latch: When True, the held set persists after you lift your hands - until you play a new chord (the first key after every key is up - replaces it) — like a hardware arp's latch. - input_device: Only track notes from this input device (index or - name). ``None`` tracks any input device (default). - - Example: - ```python - comp.midi_input("Arturia KeyStep") - comp.note_input(channel=1, release_ms=30) - - @comp.pattern(channel=6, beats=4) - def arp (p): - p.arpeggio(p.held_notes(), direction="up") # rests when silent - ``` - """ - - if self._note_input is not None: - raise RuntimeError("only one note_input source is supported — named multi-source is not yet available") - - resolved_channel = self._resolve_channel(channel) if channel is not None else None - - self._note_input = { - 'channel': resolved_channel, - 'release_ms': release_ms, - 'latch': latch, - 'input_device': input_device, # resolved to int index in _run() - } - - - @staticmethod - def _make_cc_forward_transform ( - output: typing.Union[str, typing.Callable], - cc: int, - output_channel: typing.Optional[int], - ) -> typing.Callable: - - """Build a transform callable from a preset string or user-supplied callable. - - The returned callable has signature ``(value: int, channel: int) -> Optional[mido.Message]`` - where ``channel`` is the 0-indexed incoming channel. - """ - - import mido as _mido - - def _out_ch (incoming: int) -> int: - return output_channel if output_channel is not None else incoming - - if callable(output): - if output_channel is None: - return output - def _wrapped (value: int, channel: int) -> typing.Optional[typing.Any]: - msg = output(value, channel) - - if msg is None: - return None - - # copy() re-channels without rebuilding: reconstructing from - # __dict__ passed 'type' twice and raised TypeError on every - # message, so callable+output_channel never forwarded anything. - return msg.copy(channel=output_channel) - return _wrapped - - if output == 'cc': - def _cc_identity (value: int, channel: int) -> typing.Any: - return _mido.Message('control_change', channel=_out_ch(channel), control=cc, value=value) - return _cc_identity - - if output.startswith('cc:'): - try: - target_cc = int(output[3:]) - except ValueError: - raise ValueError(f"cc_forward(): invalid preset '{output}' — expected 'cc:N' where N is 0–127") - if not 0 <= target_cc <= 127: - raise ValueError(f"cc_forward(): CC number {target_cc} out of range 0–127") - def _cc_remap (value: int, channel: int) -> typing.Any: - return _mido.Message('control_change', channel=_out_ch(channel), control=target_cc, value=value) - return _cc_remap - - if output == 'pitchwheel': - def _pitchwheel (value: int, channel: int) -> typing.Any: - pitch = int(value / 127 * 16383) - 8192 - return _mido.Message('pitchwheel', channel=_out_ch(channel), pitch=pitch) - return _pitchwheel - - raise ValueError( - f"cc_forward(): unknown preset '{output}'. " - "Use 'cc', 'cc:N' (e.g. 'cc:74'), 'pitchwheel', or a callable." - ) - - - def cc_forward ( - self, - cc: int, - output: typing.Union[str, typing.Callable], - *, - channel: typing.Optional[int] = None, - output_channel: typing.Optional[int] = None, - mode: str = "instant", - input_device: subsequence.midi_utils.DeviceId = None, - output_device: subsequence.midi_utils.DeviceId = None, - ) -> None: - - """ - Forward an incoming MIDI CC to the MIDI output in real-time. - - Unlike ``cc_map()`` which writes incoming CC values to ``composition.data`` - for use at pattern rebuild time, ``cc_forward()`` routes the signal - directly to the MIDI output — bypassing the pattern cycle entirely. - - Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC - number; they operate independently. - - Parameters: - cc: Incoming CC number to listen for (0–127). - output: What to send. Either a **preset string**: - - - ``"cc"`` — identity forward, same CC number and value. - - ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``). - - ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend. - - Or a **callable** with signature - ``(value: int, channel: int) -> Optional[mido.Message]``. - Return a fully formed ``mido.Message`` to send, or ``None`` to suppress. - ``channel`` is 0-indexed (the incoming channel). - channel: If given, only respond to CC messages on this channel. - Uses the same numbering convention as ``cc_map()``. - ``None`` matches any channel (default). - output_channel: Override the output channel. ``None`` uses the - incoming channel. Uses the same numbering convention as ``pattern()``. - input_device: Only respond to CC from this input device — an index, - a registered name, or ``None`` for any input (default), the - same convention as ``cc_map()``. - output_device: Send to this output device — an index, a registered - name, or ``None`` for the primary output (default). - mode: Dispatch mode: - - - ``"instant"`` *(default)* — send immediately on the MIDI input - callback thread. Lowest latency (~1–5 ms). Instant forwards are - **not** recorded when recording is enabled. - - ``"queued"`` — inject into the sequencer event queue and send at - the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards - **are** recorded when recording is enabled. - - Example: - ```python - comp.midi_input("Arturia KeyStep") - - # CC 1 → CC 1 (identity, instant) - comp.cc_forward(1, "cc") - - # CC 1 → pitch bend on channel 1, queued (recordable) - comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued") - - # CC 1 → CC 74, custom channel - comp.cc_forward(1, "cc:74", output_channel=2) - - # Custom transform — remap CC range 0–127 to CC 74 range 40–100 - import subsequence.midi as midi - comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)) - - # Forward AND map to data simultaneously — both active on the same CC - comp.cc_map(1, "mod_wheel") - comp.cc_forward(1, "cc:74") - ``` - """ - - if not 0 <= cc <= 127: - raise ValueError(f"cc_forward(): cc {cc} out of range 0–127") - - if mode not in ('instant', 'queued'): - raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'") - - resolved_in_channel = self._resolve_channel(channel) if channel is not None else None - resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None - - transform = self._make_cc_forward_transform(output, cc, resolved_out_channel) - - self._cc_forwards.append({ - 'cc': cc, - 'channel': resolved_in_channel, - 'output_channel': resolved_out_channel, - 'mode': mode, - 'transform': transform, - 'input_device': input_device, # resolved to int index in _run() - 'output_device': output_device, # resolved to int index in _run() - }) - - - def live (self, port: int = 5555) -> None: - - """ - Enable the live coding eval server. - - This allows you to connect to a running composition using the - ``subsequence.live_client`` REPL and hot-swap pattern code or - modify variables in real-time. - - Security: - The server executes arbitrary Python in this process — it is **not** a - sandbox. It binds to localhost only and is opt-in, but any process on - the same machine that can reach the port gains full code execution here. - Do not enable it on shared or multi-user hosts, and never expose the - port to a network. - - Parameters: - port: The TCP port to listen on (default 5555). - """ - - self._live_server = subsequence.live_server.LiveServer(self, port=port) - self._is_live = True - - def watch (self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25) -> None: - - """Watch a Python file and reload it into the composition on every save. - - The watched file is exec'd into a namespace with ``composition`` and - ``subsequence`` available. ``@composition.pattern`` decorators inside - the file hot-swap their corresponding running patterns in place; - patterns whose function bodies have been deleted from the file are - unregistered automatically on the next reload (notes stopped, - removed from the running-pattern set). - - An **initial synchronous load** happens here — if the file has a - ``SyntaxError`` or doesn't exist at this moment, the exception - propagates so the user knows immediately. Subsequent reloads - happen on the composition's event loop and tolerate transient - errors (logged, skipped). - - Call BEFORE ``composition.play()``. Reloads happen on the - composition's event loop, so all mutations are thread-safe. - - See the "Live coding via file watching" section of the README for - the recommended wrapper-script + live-file split. - - Parameters: - path: Path to the Python file to watch. - poll_interval: Seconds between ``mtime`` polls (default 0.25 s). - - Example:: - - # live_init.py — runs once - composition = subsequence.Composition(bpm=120, key="E") - composition.harmony(style="aeolian_minor") - composition.watch("live_patterns.py") - composition.play() - """ - - # Required for the decorator hot-swap path to fire on re-decoration. - self._is_live = True - - # Detect the single-file workflow: if watch() is called from inside - # the very file being watched, the outer Python script execution will - # already register the patterns (the decorators sit at module level - # below ``watch(__file__)``). In that case, _load_initial's re-exec - # would double-register every pattern, so skip it. For the two-file - # workflow (path != caller's __file__) the initial exec is essential - # — it's the only way the watched file's patterns ever reach the - # composition. - caller_file = self._caller_module_file() - self_watch = False - if caller_file is not None: - try: - self_watch = pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve() - except OSError: - self_watch = False - - self._live_reloader = subsequence.live_reloader.LiveReloader( - composition = self, - path = path, - poll_interval = poll_interval, - skip_initial_exec = self_watch, - ) - self._live_reloader.start() - - @staticmethod - def _caller_module_file () -> typing.Optional[str]: - - """Return ``__file__`` of the module that invoked the caller, if available. - - Walks one frame up the call stack — the immediate caller is - ``watch()``, so ``f_back`` is the user's code. Returns the - module-level ``__file__`` of that frame's globals; ``None`` when - the caller has no ``__file__`` (REPL, exec'd context, etc.). - """ - - frame = inspect.currentframe() - if frame is None or frame.f_back is None or frame.f_back.f_back is None: - return None - # f_back = watch(); f_back.f_back = user code calling watch(). - return frame.f_back.f_back.f_globals.get("__file__") - - def load_patterns ( - self, - source: str, - source_label: str = "", - ) -> None: - - """Compile and apply a pattern-source string to the composition. - - Equivalent to one ``watch()`` reload triggered by save, but with the - source presented in-memory rather than on disk. Useful for web / - REST handlers that accept pattern uploads from a trusted contributor, - or for one-shot session loads with no file backing. - - Behaviour mirrors ``watch()``: - - * The source is exec'd into a fresh namespace with ``composition`` - and ``subsequence`` in scope. - * ``@composition.pattern`` decorators in the source hot-swap their - corresponding running patterns in place. - * Patterns currently running but **not** declared in the source are - unregistered — the source is treated as the full new truth. - * If the composition is already playing, the swap happens on the - event loop thread; the call blocks until it completes. - * If the composition has not yet called ``play()``, the source runs - on the caller's thread; decorators populate ``_pending_patterns`` - and ``play()`` picks them up in the usual way. - - Errors are raised so the caller can act on them: - - * ``SyntaxError`` if ``source`` fails to compile. - * The exception raised inside ``exec()`` for any runtime error. - * ``RuntimeError`` if called from inside the composition's own - event loop thread (would deadlock — see Threading below). - - In either failure case, existing composition state is preserved — - the diff-and-unregister phase is skipped if exec raised, so a - half-broken upload cannot tear down working patterns. - - Threading: - Designed to be called from a thread DIFFERENT from the - composition's event loop — typically a web-handler worker. - Cannot be called from inside the loop itself (a pattern - callback, an asyncio task spawned by the composition). From - there, ``await composition._apply_source_async(...)`` directly. - - SECURITY WARNING: ``exec()`` is not sandboxed. The source has full - Python access in this process. Only pass source from trusted - senders. The built-in blocklist (``help``, ``input``, ``breakpoint``, - ``exit``, ``quit``) prevents calls that would stall the event loop; - it is not a security boundary. - - Parameters: - source: Python source declaring ``@composition.pattern`` - functions. - source_label: Identifier used in compile errors and tracebacks - (appears as the filename in ``SyntaxError`` and ``__file__``- - style traceback lines). Default ``""``. - """ - - # Required for the decorator hot-swap path to fire on re-decoration. - self._is_live = True - - # Compile on the caller's thread so SyntaxError comes back fast, - # before any cross-thread scheduling. - compiled = compile(source, source_label, "exec") - namespace = self._build_live_namespace(source_label = source_label) - - loop = self._sequencer._event_loop - - if loop is not None and loop.is_running(): - - # Refuse to deadlock: calling load_patterns() from inside the - # composition's own event loop (e.g. from a pattern callback or - # an asyncio task spawned by the composition) would have us - # block waiting for a coroutine that can only run when this - # thread yields. Tell the caller exactly what to do instead. - try: - current_loop: typing.Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop() - except RuntimeError: - current_loop = None - - if current_loop is loop: - raise RuntimeError( - "load_patterns() cannot be called from inside the composition's " - "event loop thread — it would deadlock waiting for the " - "scheduled coroutine to run on the very thread that's blocked. " - "From a worker thread, call it normally. From an async " - "coroutine already on the loop, " - "`await composition._apply_source_async(compile(source, label, 'exec'), " - "composition._build_live_namespace())` instead." - ) - - # Composition is playing — mutation must happen on the loop thread. - # future.result() blocks the caller until the coroutine finishes - # and re-raises any exception it threw. - future = asyncio.run_coroutine_threadsafe( - self._apply_source_async(compiled, namespace, source_key = source_label), - loop = loop, - ) - future.result() - - else: - # Pre-play: no event loop yet. Decorators populate - # _pending_patterns; play() graduates them in the usual way. - # Diff-and-unregister is unnecessary here — nothing is running, - # but RECORD what this source declares so a later post-play - # reload under the same label can tear down its deletions. - self._declared_names = set() - exec(compiled, namespace) - self._source_declared[source_label] = set(self._declared_names) - - async def _apply_source_async ( - self, - compiled: types.CodeType, - namespace: typing.Dict[str, typing.Any], - source_key: str = "", - ) -> None: - - """Execute pre-compiled live source against the running composition. - - Runs on the event loop thread. Performs ``exec()``, graduates any - newly-decorated patterns into ``_running_patterns``, then unregisters - any patterns that *this source* declared on its previous exec but no - longer declares (keyed by ``source_key`` — a watched file's path or a - ``load_patterns`` label). Patterns registered by the wrapper script - or by another source are never this source's to tear down. - - Raises whatever ``exec()`` raises. When that happens, the diff-and- - unregister phase is skipped — the namespace is incomplete, so any - patterns the source failed to reach would be misinterpreted as - deletions and torn down. - - Called from two places: - - * ``Composition.load_patterns()`` via ``run_coroutine_threadsafe``. - * ``LiveReloader._reload_async`` directly (already on the loop). - """ - - # Track which patterns the source declares this exec. pattern() and - # layer() add their (resolved) names to _declared_names as they run, so - # this covers decorated patterns AND layer()/merged patterns — the latter - # have no module-level callable to match against by name, which is why the - # old namespace-based diff tore layers down on every reload. - self._declared_names = set() - - # Bail before any state mutation if exec raises — propagates to - # the caller (load_patterns re-raises; LiveReloader catches + logs). - exec(compiled, namespace) - - # Graduate newly-decorated patterns from _pending_patterns into - # _running_patterns so they start firing on the next reschedule. - # Patterns that hot-swapped via the decorator/layer path don't appear - # in _pending_patterns and don't need this step. - await self._activate_new_pending_patterns() - - # Detect deletions: a name THIS source declared last time but not this - # time has been removed by the user and should be torn down. (An - # unknown source — first exec — tears down nothing: patterns running - # from the wrapper script or another source are not ours to remove.) - # Decorators/layer() do NOT remove from _running_patterns when a - # definition disappears from the source. - previous = self._source_declared.get(source_key) - - if previous is not None: - for name in previous - self._declared_names: - if name in self._running_patterns: - self.unregister(name) - - self._source_declared[source_key] = set(self._declared_names) - - def _build_live_namespace (self, source_label: str = "") -> typing.Dict[str, typing.Any]: - - """Build a fresh namespace dict for exec'ing live source. - - Provides ``composition`` (this Composition), ``subsequence`` (the - package), and a safe builtins set with ``help``, ``input``, - ``breakpoint``, ``exit``, ``quit`` blocked. - - Also injects two dunder globals that make the single-file live-coding - workflow ergonomic: - - * ``__name__ = "__live_reload__"`` — so ``if __name__ == "__main__":`` - blocks in the watched file are *skipped* during live reload. The - same file run directly with ``python my_session.py`` sees - ``__name__ == "__main__"`` and runs setup; saves trigger reload - with ``__name__ == "__live_reload__"``, skipping setup and only - re-running pattern definitions. - * ``__file__ = source_label`` — so ``composition.watch(__file__)`` - and any user code referencing ``__file__`` works inside the live - namespace. Set to the file path for ``LiveReloader``, the - user-supplied ``source_label`` for ``Composition.load_patterns``, - and ``""`` for ``LiveServer``. - - Single source of truth: ``live_reloader`` (file watching), - ``live_server`` (TCP REPL), and ``load_patterns`` (string source) - all call this so live source sees the same environment from any - entry point. - - The blocklist prevents calls that would stall the async event loop - running the sequencer. It is **not** a security sandbox — exec'd - code can still do anything Python allows. - - Parameters: - source_label: Value to bind to ``__file__`` in the namespace. - Defaults to ``""``. - """ - - import subsequence # local import: this module is imported during subsequence init + composition.hotkeys() + composition.hotkey("a", lambda: composition.form_jump("chorus")) + composition.play() + """ - safe_builtins = {name: getattr(builtins, name) for name in dir(builtins)} + self._hotkeys_enabled = enabled - blocked = {"help", "input", "breakpoint", "exit", "quit"} - - for name in blocked: - safe_builtins[name] = _live_blocked(name) + def hotkey( + self, + key: str, + action: typing.Callable[[], None], + quantize: int = 0, + label: typing.Optional[str] = None, + ) -> None: + """Register a single-key shortcut that fires during playback. - return { - "__builtins__": safe_builtins, - "__name__": "__live_reload__", - "__file__": source_label, - "composition": self, - "subsequence": subsequence, - } + The listener must be enabled first with :meth:`hotkeys`. - def osc (self, receive_port: int = 9000, send_port: int = 9001, send_host: str = "127.0.0.1", receive_host: str = "0.0.0.0") -> None: + Most actions — form jumps, ``composition.data`` writes, and + :meth:`tweak` calls — should use ``quantize=0`` (the default). Their + musical effect is naturally delayed to the next pattern rebuild cycle, + which provides automatic musical quantization without extra configuration. - """ - Enable bi-directional Open Sound Control (OSC). + Use ``quantize=N`` for actions where you want an explicit bar-boundary + guarantee, such as :meth:`mute` / :meth:`unmute`. - Subsequence will listen for commands (like ``/bpm`` or ``/mute``) and - broadcast its internal state (like ``/chord`` or ``/bar``) over UDP. + The ``?`` key is reserved and cannot be overridden. - Parameters: - receive_port: Port to listen for incoming OSC messages (default 9000). - send_port: Port to send state updates to (default 9001). - send_host: The IP address to send updates to (default "127.0.0.1"). - receive_host: Interface to listen on (default "0.0.0.0" — all - interfaces, so external OSC controllers on the LAN can reach it). - The listener can change tempo, mute patterns, and write data, so on - an untrusted network restrict it with ``receive_host="127.0.0.1"``. - """ - - self._osc_server = subsequence.osc.OscServer( - self, - receive_port = receive_port, - send_port = send_port, - send_host = send_host, - receive_host = receive_host - ) - - def osc_map (self, address: str, handler: typing.Callable) -> None: - - """ - Register a custom OSC handler. - - Must be called after :meth:`osc` has been configured. - - Parameters: - address: OSC address pattern to match (e.g. ``"/my/param"``). - handler: Callable invoked with ``(address, *args)`` when a - matching message arrives. - - Example:: - - composition.osc() - - def on_intensity (address, value): - composition.data["intensity"] = float(value) - - composition.osc_map("/intensity", on_intensity) - """ - - if self._osc_server is None: - raise RuntimeError("Call composition.osc() before composition.osc_map()") - - self._osc_server.map(address, handler) - - def set_bpm (self, bpm: float) -> None: - - """ - Instantly change the tempo. - - Parameters: - bpm: The new tempo in beats per minute. - - When Ableton Link is active, this proposes the new tempo to the Link - network instead of applying it locally. The network-authoritative tempo - is picked up on the next pulse. - """ - - self._sequencer.set_bpm(bpm) - - if not self.is_clock_following and self._link_quantum is None: - self.bpm = bpm - - def target_bpm (self, bpm: float, bars: int, shape: str = "linear") -> None: - - """ - Smoothly ramp the tempo to a target value over a number of bars. - - Parameters: - bpm: Target tempo in beats per minute. - bars: Duration of the transition in bars. - shape: Easing curve name. Defaults to ``"linear"``. - ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- - sounding tempo changes. See :mod:`subsequence.easing` for all - available shapes. - - Example: - ```python - # Accelerate to 140 BPM over the next 8 bars with a smooth S-curve - comp.target_bpm(140, bars=8, shape="ease_in_out") - ``` - - Note: - Ignored while Ableton Link is active — the shared session tempo is - authoritative. Use ``set_bpm()`` to propose a tempo to the Link network. - """ - - self._sequencer.set_target_bpm(bpm, bars, shape) - - def live_info (self) -> typing.Dict[str, typing.Any]: - - """ - Return a dictionary containing the current state of the composition. - - Includes BPM, key, current bar, active section, current chord, - running patterns, and custom data. - """ - - section_info = None - if self._form_state is not None: - section = self._form_state.get_section_info() - if section is not None: - section_info = { - "name": section.name, - "bar": section.bar, - "bars": section.bars, - "progress": section.progress - } - - chord_name = None - sounding_chord = self.current_chord() - if sounding_chord is not None: - chord_name = sounding_chord.name() - - pattern_list = [] - channel_offset = 0 if self._zero_indexed_channels else 1 - for name, pat in self._running_patterns.items(): - pattern_list.append({ - "name": name, - "channel": pat.channel + channel_offset, - "length": pat.length, - "cycle": pat._cycle_count, - "muted": pat._muted, - "tweaks": dict(pat._tweaks) - }) - - return { - "bpm": self._sequencer.current_bpm, - "key": self.key, - "bar": self._builder_bar, - "section": section_info, - "chord": chord_name, - "patterns": pattern_list, - "input_device": self._input_device, - "clock_follow": self.is_clock_following, - "data": self.data - } - - def mute (self, name: str) -> None: - - """ - Mute a running pattern by name. - - The pattern continues to 'run' and increment its cycle count in - the background, but it will not produce any MIDI notes until unmuted. - - Parameters: - name: The function name of the pattern to mute. - """ - - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") - - # The performer takes ownership: if a transition's approach window had - # muted this pattern, drop it from that set so the section boundary - # does not silently unmute it ("performer mutes win"). - self._transition_muted.discard(name) - - self._running_patterns[name]._muted = True - logger.info(f"Muted pattern: {name}") - - def unmute (self, name: str) -> None: - - """ - Unmute a previously muted pattern. - """ - - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") - - # Symmetric ownership claim: an explicit unmute means the transition - # machinery should no longer manage this pattern at the boundary. - self._transition_muted.discard(name) - - self._running_patterns[name]._muted = False - logger.info(f"Unmuted pattern: {name}") - - def unregister (self, name: str) -> None: - - """Fully remove a running pattern from rotation. - - Unlike ``mute()`` (which keeps the pattern alive but silent), - ``unregister()`` tears the pattern down entirely. It sets - ``pattern._removed = True`` so the sequencer's reschedule loop - skips re-adding it on the next pulse; sends ``note_off`` for any - of the pattern's currently-sounding notes on the primary - destination AND on every mirror destination (so drones and - sustaining notes stop immediately); and removes the entry from - ``_running_patterns`` so it no longer appears in ``live_info()``, - the terminal grid, or any other consumer that enumerates running - patterns. - - Already-queued events in the sequencer's event queue play out — - note_offs are paired with their note_ons at queue time, so notes - end at their natural duration; only drones rely on the targeted - ``_stop_pattern_notes`` pass. - - Idempotent: silently logs a ``debug`` and returns if the pattern - is already absent. Useful from both the live REPL - (``composition.live()``) and the file watcher - (``composition.watch()``), which calls this for any pattern - removed from the watched file between reloads. - - Parameters: - name: Function name of the pattern to remove. - """ - - if name not in self._running_patterns: - logger.debug(f"unregister() no-op: pattern '{name}' not running") - return - - pattern = self._running_patterns[name] - - # Mark for removal first so the reschedule loop sees the flag even if - # it fires concurrently with the note-off pass below. - pattern._removed = True - - # Stop sustaining notes (including drones) on every destination this - # pattern outputs to. Fire-and-forget across threads via the event - # loop; ``_stop_pattern_notes`` acquires the queue lock internally. - if self._sequencer._event_loop is not None: - asyncio.run_coroutine_threadsafe( - self._sequencer._stop_pattern_notes(pattern), - loop = self._sequencer._event_loop, - ) - - def _finalise_removal () -> None: - self._running_patterns.pop(name, None) - - # Forget any pending (not-yet-graduated) declaration too, so a - # later live reload cannot resurrect the pattern. - self._pending_patterns = [ - pending for pending in self._pending_patterns - if pending.builder_fn.__name__ != name - ] - - logger.info(f"Unregistered pattern: {name}") - - # The running-patterns dict is iterated by the display, web UI, and - # reschedule loop on the event loop thread — mutate it there when this - # call arrives from another thread (e.g. the live TCP server). - loop = self._sequencer._event_loop - - try: - on_loop = loop is not None and asyncio.get_running_loop() is loop - except RuntimeError: - on_loop = False - - if loop is not None and loop.is_running() and not on_loop: - loop.call_soon_threadsafe(_finalise_removal) - else: - _finalise_removal() - - def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None: - - """ - Add a mirror destination to a running pattern. - - Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone - event the pattern emits will also be sent to ``(device, channel)``, - starting from the next cycle rebuild. Idempotent on ``(device, channel)`` - — calling with the same destination twice does not double-fan; calling - again with a different ``drum_note_map`` re-points it in place. - - Parameters: - name: Function name of the pattern to mirror. - device: Output device index (the integer returned from - ``midi_output()``; 0 = primary device). - channel: MIDI channel using this composition's numbering convention - (1-16 by default; 0-15 if ``zero_indexed_channels=True``). - drum_note_map: Optional per-destination drum map. When set, mirrored - drum hits are re-resolved by name through it, so a named voice - lands on this device's own note number — see the README - "MIDI mirroring" section. - - Bandwidth: each mirror adds another full copy of the pattern's events. - See the README "MIDI mirroring" section for the tradeoffs. - """ - - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") - - resolved_channel = self._resolve_channel(channel) - prefix = (device, resolved_channel) - entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map) - - pattern = self._running_patterns[name] - - # Mirror-to-self check: comparing the (device, channel) prefix against the - # live pattern's resolved destination. Unlike the decorator path this is - # always concrete. - if prefix == (pattern.device, pattern.channel): - logger.warning( - f"Mirror destination {prefix} matches '{name}'s primary destination " - f"— every event will double-fire on this (device, channel). This is almost " - f"certainly unintended." - ) - - # Idempotent on (device, channel): replace any existing entry for the same - # destination (so its map can be re-pointed), else append. - existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None) - if existing_index is None: - pattern.mirrors.append(entry) - logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}") - elif pattern.mirrors[existing_index] != entry: - pattern.mirrors[existing_index] = entry - logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}") - else: - logger.debug(f"Mirror already present on {name}: device={device}, channel={resolved_channel}") + Args: + key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``). + action: Zero-argument callable to execute. + quantize: ``0`` = execute immediately (default). ``N`` = execute + on the next global bar number divisible by *N*. + label: Display name for the ``?`` help listing. Auto-derived from + the function name or lambda body if omitted. - def unmirror (self, name: str, device: int, channel: int) -> None: + Raises: + ValueError: If ``key`` is the reserved ``?`` character, or if + ``key`` is not exactly one character. - """ - Remove a single mirror destination from a running pattern. + Example:: - Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is - ignored. Idempotent: silently does nothing if the destination is not - currently mirrored. The change applies on the next cycle rebuild. - """ + composition.hotkeys() - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") + # Immediate — musical effect happens at next pattern rebuild + composition.hotkey("a", lambda: composition.form_jump("chorus")) + composition.hotkey("1", lambda: composition.data.update({"mode": "chill"})) - resolved_channel = self._resolve_channel(channel) - prefix = (device, resolved_channel) + # Explicit 4-bar phrase boundary + composition.hotkey("s", lambda: composition.mute("drums"), quantize=4) - pattern = self._running_patterns[name] + # Named function — label is derived automatically + def drop_to_breakdown (): + composition.form_jump("breakdown") + composition.mute("lead") - filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] - if len(filtered) != len(pattern.mirrors): - pattern.mirrors[:] = filtered - logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}") - else: - logger.debug(f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors") + composition.hotkey("d", drop_to_breakdown) - def unmirror_all (self, name: str) -> None: - - """ - Remove every mirror destination from a running pattern. - """ - - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") + composition.play() + """ - pattern = self._running_patterns[name] + if len(key) != 1: + raise ValueError(f"hotkey key must be a single character, got {key!r}") - if pattern.mirrors: - pattern.mirrors.clear() - logger.info(f"All mirrors cleared on pattern: {name}") - - def tweak (self, name: str, **kwargs: typing.Any) -> None: - - """Override parameters for a running pattern. - - Values set here are available inside the pattern's builder - function via ``p.param()``. They persist across rebuilds - until explicitly changed or cleared. Changes take effect - on the next rebuild cycle. - - Parameters: - name: The function name of the pattern. - ``**kwargs``: Parameter names and their new values. - - Example (from the live REPL):: - - composition.tweak("bass", pitches=[48, 52, 55, 60]) - """ - - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") - - self._running_patterns[name]._tweaks.update(kwargs) - logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}") - - def clear_tweak (self, name: str, *param_names: str) -> None: - - """Remove tweaked parameters from a running pattern. - - If no parameter names are given, all tweaks for the pattern - are cleared and every ``p.param()`` call reverts to its - default. - - Parameters: - name: The function name of the pattern. - *param_names: Specific parameter names to clear. If - omitted, all tweaks are removed. - """ - - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") - - if not param_names: - self._running_patterns[name]._tweaks.clear() - logger.info(f"Cleared all tweaks for pattern '{name}'") - else: - for param_name in param_names: - self._running_patterns[name]._tweaks.pop(param_name, None) - logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}") - - def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]: - - """Return a copy of the current tweaks for a running pattern. - - Parameters: - name: The function name of the pattern. - """ - - if name not in self._running_patterns: - raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") - - return dict(self._running_patterns[name]._tweaks) - - def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None: - - """ - Register a custom function to run on a repeating beat-based cycle. - - Subsequence automatically runs synchronous functions in a thread pool - so they don't block the timing-critical MIDI clock. Async functions - are run directly on the event loop. - - Parameters: - fn: The function to call. - cycle_beats: How often to call it (e.g., 4 = every bar). - reschedule_lookahead: How far in advance to schedule the next call. - wait_for_initial: If True, run the function once during startup - and wait for it to complete before playback begins. This - ensures ``composition.data`` is populated before patterns - first build. Implies ``defer=True`` for the repeating - schedule. - defer: If True, skip the pulse-0 fire and defer the first - repeating call to just before the second cycle boundary. - - Raises: - RuntimeError: If called after ``play()`` has started — scheduled - tasks register at startup, so a late registration would be - silently ignored otherwise. - """ - - if self._sequencer.running: - raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup") - - self._pending_scheduled.append(_PendingScheduled(fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer)) - - def form ( - self, - sections: typing.Union[ - "subsequence.forms.Form", - typing.List[typing.Any], - typing.Iterator[typing.Tuple[str, int]], - typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] - ], - loop: bool = False, - start: typing.Optional[str] = None, - at_end: str = "stop", - key: typing.Optional[str] = None, - scale: typing.Optional[str] = None, - ) -> None: - - """ - Define the structure (sections) of the composition. - - You can define form in four ways: - - 1. **Form value**: a frozen :class:`~subsequence.forms.Form` of - :class:`~subsequence.forms.Section` values — the payload home - (energy, key per section); editable, navigable. - 2. **Sequence (List)**: a fixed order of ``(name, bars)`` tuples - or Sections (lists coerce — they are the same form). - 3. **Graph (Dict)**: dynamic transitions based on weights. - 4. **Generator**: a Python generator that yields ``(name, bars)`` pairs. - - Form-value and list forms are **navigable**: ``form_jump()`` and - ``form_next()`` work on them (the jump lands on the next occurrence - of the name, wrapping). - - Re-binding ``form()`` during playback takes effect at the next bar — - the clock reads the current form state on every bar, so the new form - advances from there (its first section plays from its first bar). - - Parameters: - sections: The form definition (Form, List, Dict, or Generator). - loop: Sugar for ``at_end="loop"``. - start: The section to start with (Graph mode only). - at_end: What happens when a sequence form runs out — - ``"stop"`` (the form finishes and patterns see no section; - default), ``"hold"`` (the final section repeats until - navigated away from), or ``"loop"`` (start over). Graphs - end via their terminal sections instead. - key: A form-level key — the **form tier** of the key-source - chain (``Section.key`` overrides it; it overrides the - composition key). Re-anchors key-relative content for the - whole form. When *sections* is a ``Form`` value carrying its - own ``key``, that value is used unless this argument overrides. - scale: A form-level scale/mode, paired with ``key``. - - Example: - ```python - # A simple pop structure - comp.form([ - ("verse", 8), - ("chorus", 8), - ("verse", 8), - ("chorus", 16) - ]) - - # The same structure with payloads, held open at the end - S = subsequence.Section - comp.form(subsequence.Form([ - S("verse", 8, energy=0.5), S("chorus", 8, energy=0.9), - ]), at_end="hold") - ``` - """ - - # Seed FormState at form() time (per-call salt) so build-time walks — - # the frozen clones form_freeze will take — are deterministic without - # play(); the play-time stream is re-dealt name-keyed in _run(). - self._form_count += 1 - - self._form_state = subsequence.form_state.FormState( - sections, - loop = loop, - start = start, - rng = self._stream(f"form:{self._form_count}"), - at_end = at_end, - ) - - # A Form value carries energy payloads — that counts as an energy - # source for the min_energy registration check in _run(). - self._form_has_payload = isinstance(sections, subsequence.forms.Form) or ( - isinstance(sections, list) and any(isinstance(element, subsequence.forms.Section) for element in sections) - ) - - # Form-tier key/scale: an explicit argument wins; otherwise a Form - # value's own key/scale seeds the tier. Re-binding the form drops any - # stale per-section resolution cache. - if isinstance(sections, subsequence.forms.Form): - self._form_key = key if key is not None else sections.key - self._form_scale = scale if scale is not None else sections.scale - else: - self._form_key = key - self._form_scale = scale - - self._resolved_section_cache = {} - - def form_freeze (self, sections: typing.Optional[int] = None) -> "subsequence.forms.Form": - - """Freeze the graph form's walk into an editable :class:`~subsequence.forms.Form`. - - Walks a **clone** of the live form state — the same RNG state, so the - frozen path is exactly the path the live graph would have played — - and returns it as a Form value: inspect it, edit it - (``path.replace(3, bars=16)``), and rebind it with - ``composition.form(path, at_end=...)``. The live form state is - untouched (rebinding replaces it). - - Parameters: - sections: Number of sections to freeze. Without it, the walk - runs until a terminal section; a graph with no terminal - sections requires ``sections=`` explicitly. - - Raises: - ValueError: If no graph form is bound (a list form is already a - frozen sequence), the form has already finished, or the walk - cannot terminate. - - Example:: - - composition.form({...}, start="intro") - path = composition.form_freeze() # the walk, frozen - composition.form(path, at_end="stop") # rebind the editable value - """ - - fs = self._form_state - - if fs is None or fs._graph is None or fs._section_bars is None: - raise ValueError( - "form_freeze() freezes a graph form's walk — call form() with a dict first " - "(a list form is already a frozen sequence)" - ) - - if fs._current is None: - raise ValueError("the form has already finished — nothing left to freeze") - - if sections is not None and sections < 1: - raise ValueError("sections must be at least 1") - - if sections is None and not fs._terminal_sections: - raise ValueError( - "this graph has no terminal section, so the walk would never end — " - "pass sections=n to bound it" - ) - - # Clone the RNG state: the frozen walk reproduces the live form's - # future draws without consuming them. - rng = random.Random() - rng.setstate(fs._rng.getstate()) - - walked = [fs._current] - next_name = fs._next_section_name # already decided by the live state - - while next_name is not None: - if sections is not None and len(walked) >= sections: - break - if sections is None and len(walked) >= 10000: - raise ValueError( - "form_freeze() walked 10000 sections without reaching a terminal — " - "the terminals look unreachable; pass sections=n to bound the walk" - ) - - walked.append(subsequence.forms.Section(name = next_name, bars = fs._section_bars[next_name])) - next_name = None if next_name in fs._terminal_sections else fs._graph.choose_next(next_name, rng) - - # Carry the form-tier key/scale onto the frozen value so a freeze → - # rebind round-trip is lossless (an explicit form(key=) on rebind - # still overrides). - return subsequence.forms.Form(walked, key = self._form_key, scale = self._form_scale) - - def energy (self, energies: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]]) -> None: - - """Set per-section energy — the arranging dial, as one plain dict. - - ``{"verse": 0.5, "chorus": 0.9, "build": (0.3, 1.0)}`` — a float is - the section's level; a ``(start, end)`` tuple interpolates across the - section (a build). Patterns read ``p.energy`` (0.5 when nothing is - configured) and gate themselves, or declare ``min_energy=`` on - ``pattern()`` for automatic muting. - - The dict **overrides** any energy payload carried by bound - :class:`~subsequence.forms.Section` values — it is the later, - performance-level dial. Re-calling replaces the whole mapping - (idempotent, live-reload friendly). + if key == _HOTKEY_RESERVED: + raise ValueError( + f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys." + ) - Example:: + derived = label if label is not None else _derive_label(action) - composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95}) - """ + self._hotkey_bindings[key] = HotkeyBinding( + key=key, + action=action, + quantize=quantize, + label=derived, + ) - validated: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} + def form_jump(self, section_name: str) -> None: + """Jump the form to a named section immediately. - for name, value in energies.items(): - if isinstance(value, tuple): - if len(value) != 2: - raise ValueError(f"energy ramp for {name!r} must be (start, end), got {value!r}") - start_level, end_level = float(value[0]), float(value[1]) - for level in (start_level, end_level): - if not 0.0 <= level <= 1.0: - raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") - validated[name] = (start_level, end_level) - else: - level = float(value) - if not 0.0 <= level <= 1.0: - raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") - validated[name] = level + Delegates to :meth:`subsequence.form_state.FormState.jump_to`. Only works when the + composition uses graph-mode form (a dict passed to :meth:`form`). - self._energy_map = validated - - def _current_energy (self, info: typing.Optional[subsequence.form_state.SectionInfo]) -> float: - - """Resolve the energy for a section snapshot. - - Priority: the ``energy()`` dict (ramps interpolate by section - progress) > the bound Section payload > 0.5. - """ + The musical effect is heard at the *next pattern rebuild cycle* — already- + queued MIDI notes are unaffected. This natural delay means ``form_jump`` + is effective without needing explicit quantization. - if info is None: - return 0.5 + Args: + section_name: The section to jump to. - spec = self._energy_map.get(info.name) + Raises: + ValueError: If no form is configured, or the form is not in graph + mode, or *section_name* is unknown. - if spec is None: - return info.energy + Example:: - if isinstance(spec, tuple): - start_level, end_level = spec + composition.hotkey("c", lambda: composition.form_jump("chorus")) + """ - # A build reaches its declared end ON the final bar, so the ramp spans - # bar 0 → bar (bars-1). (info.progress is bar/bars, which would top - # out one bar short and never deliver end.) A one-bar section sits at - # the destination level. - span = info.bars - 1 - fraction = info.bar / span if span > 0 else 1.0 + if self._form_state is None: + raise ValueError( + "form_jump() requires a form to be configured via composition.form()." + ) - return start_level + (end_level - start_level) * fraction + self._form_state.jump_to(section_name) - return spec + # The harmony horizon planned against the old section — revoke it. + self._harmony_horizon.invalidate_future() - def on_section (self, callback: typing.Callable[..., typing.Any]) -> None: + def form_next(self, section_name: str) -> None: + """Queue the next section — takes effect when the current section ends. - """Register a callback fired on every section change. + Unlike :meth:`form_jump`, this does not interrupt the current section. + The queued section replaces the automatically pre-decided next section + and takes effect at the natural section boundary. The performer can + change their mind by calling ``form_next`` again before the boundary. - The callback receives the new :class:`~subsequence.form_state.SectionInfo` - (or ``None`` when the form finishes). It fires from the form clock, - one lookahead-beat **early** — in time to affect the new section's - first patterns — and once at play start for the opening section. + Delegates to :meth:`subsequence.form_state.FormState.queue_next`. Only works when the + composition uses graph-mode form (a dict passed to :meth:`form`). - Example:: + Args: + section_name: The section to queue. + + Raises: + ValueError: If no form is configured, or the form is not in graph + mode, or *section_name* is unknown. + + Example:: + + composition.hotkey("c", lambda: composition.form_next("chorus")) + """ - composition.on_section(lambda info: print(f"now: {info.name if info else 'end'}")) - """ + if self._form_state is None: + raise ValueError( + "form_next() requires a form to be configured via composition.form()." + ) - self.on_event("section", callback) + self._form_state.queue_next(section_name) - def transition ( - self, - before: str, - fill: typing.Optional[typing.Any] = None, - channel: typing.Optional[int] = None, - beat: float = 0.0, - mute: typing.Optional[typing.List[str]] = None, - beats: typing.Optional[float] = None, - drum_note_map: typing.Optional[typing.Dict[str, int]] = None, - device: subsequence.midi_utils.DeviceId = None, - ) -> None: + # The harmony horizon planned against the old continuation — revoke it. + self._harmony_horizon.invalidate_future() - """Declare boundary material — the automatic fill or mute, one line. + def _list_hotkeys(self) -> None: + """Log all active hotkey bindings (triggered by the ``?`` key). - ``before`` names the incoming section (``"chorus"``), or ``"*"`` for - any *different* section (repeats don't fire it). Two actions, - combinable: - - - ``fill=`` (+ ``channel=``, ``beat=``): a Motif played in the last - bar before the boundary, starting at ``beat`` of that bar. Drum - names resolve through ``drum_note_map=`` if given, otherwise the - map is borrowed from a registered pattern on the same channel. - - ``mute=`` (+ ``beats=``): pattern names muted over the approach - and unmuted at the boundary. Muting is **bar-granular** (the - existing rule), so ``beats`` rounds up to whole bars. Performer - mutes win: a pattern you muted yourself stays muted. - - Transitions stack — call once per rule. Registration is additive - and idempotent per identical rule. + Output appears via the standard logger so it scrolls cleanly above + the :class:`~subsequence.display.Display` status line. + """ - Example:: + lines = ["Active hotkeys:"] + for key in sorted(self._hotkey_bindings): + b = self._hotkey_bindings[key] + quant_str = "immediate" if b.quantize == 0 else f"quantize={b.quantize}" + lines.append(f" {key} \u2192 {b.label} ({quant_str})") + lines.append(f" ? \u2192 list hotkeys") + logger.info("\n".join(lines)) - composition.transition(before="*", fill=FILL, channel=10, beat=2.0) - composition.transition(before="drop", mute=["pads"], beats=4) - """ - - if fill is None and mute is None: - raise ValueError("transition() needs fill= and/or mute= — it declares what happens at the boundary") - - if fill is not None: - if channel is None: - raise ValueError("transition(fill=) needs channel= — the fill must land somewhere") - if not hasattr(fill, "events") or not hasattr(fill, "length"): - raise TypeError(f"fill must be a Motif-like value with .events/.length, got {type(fill).__name__}") - - if mute is not None and beats is None: - beats = float(self.time_signature[0]) # one bar by default + def _process_hotkeys(self, bar: int) -> None: + """Drain pending keystrokes and execute due actions. - rule = _Transition( - before = before, - fill = fill, - channel = self._resolve_channel(channel) if channel is not None else None, - beat = float(beat), - mute = list(mute) if mute is not None else None, - beats = beats, - drum_note_map = drum_note_map, - device = device, # resolved at fire time — names aren't known until play() - ) + Called on every ``"bar"`` event by the sequencer when hotkeys are + enabled. Handles both immediate (``quantize=0``) and quantized actions. - if rule not in self._transitions: - self._transitions.append(rule) + Both kinds run here, on the bar-event callback (the event loop): the + keystroke listener thread only enqueues keypresses (``drain()``), it + never executes actions. Immediate (``quantize=0``) bindings fire as soon + as the key is drained; quantized ones wait for their next boundary. - def _transition_drum_map (self, channel: typing.Optional[int]) -> typing.Optional[typing.Dict[str, int]]: + Args: + bar: The current global bar number from the sequencer. + """ + + if self._keystroke_listener is None: + return - """Borrow a drum map from a registered pattern on the same channel.""" - - if channel is None: - return None - - for pending in self._pending_patterns: - if pending.channel == channel and pending.drum_note_map: - return pending.drum_note_map - - for running in self._running_patterns.values(): - candidate = getattr(running, "_drum_note_map", None) - if running.channel == channel and candidate: - return typing.cast(typing.Dict[str, int], candidate) - - return None - - def _fire_fill (self, rule: _Transition, start_pulse: int) -> None: - - """Build a transition fill as a one-shot pattern and schedule it.""" - - assert rule.fill is not None and rule.channel is not None - - drum_map = rule.drum_note_map if rule.drum_note_map is not None else self._transition_drum_map(rule.channel) - - pattern = subsequence.pattern.Pattern( - channel = rule.channel, - length = float(rule.fill.length), - device = self._resolve_device_id(rule.device), - ) - - harmony_view: typing.Optional[HarmonyView] = None - if not self._harmony_horizon.is_empty: - harmony_view = HarmonyView(self._harmony_horizon, start_pulse / self._sequencer.pulses_per_beat) - - # The fill sounds in the outgoing section's final bar, so a degree- - # bearing fill resolves against THAT section's effective key/scale — - # previously it took the composition key, ignoring the section. - fill_section = self._form_state.get_section_info() if self._form_state else None - fill_key, fill_scale = self._effective_key_scale(fill_section) - - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - drum_note_map = drum_map, - section = fill_section, - bar = self._builder_bar, - conductor = self.conductor, - rng = self._stream(f"transition:{rule.before}:{start_pulse}") or random.Random(), - tweaks = {}, - default_grid = 16, - data = self.data, - key = fill_key, - scale = fill_scale, - time_signature = self.time_signature, - harmony = harmony_view, - ) - - try: - builder.motif(rule.fill) - except Exception: - logger.exception("transition fill failed to build — the boundary plays without it") - return - - self._schedule_one_shot(pattern, start_pulse) - - def _check_transitions (self, boundary_pulse: int, section_changed: bool) -> None: - - """The form clock's boundary hook: fire fills, manage approach mutes. - - Called once per bar (lookahead-early, with the bar-line pulse). - Fill rules fire when the current bar is the section's last before a - matching boundary; mute rules close over the approach window - (rounded up to whole bars — muting is bar-granular) and reopen at - the boundary. Performer mutes are never touched. - """ - - if section_changed and self._transition_muted: - # The boundary arrived — restore only what we muted ourselves. - for name in self._transition_muted: - running = self._running_patterns.get(name) - if running is not None: - running._muted = False - self._transition_muted.clear() - - if not self._transitions or self._form_state is None: - return - - info = self._form_state.get_section_info() - - if info is None or info.next_section is None: - return - - bar_beats = float(self.time_signature[0]) - bars_remaining = info.bars - info.bar - - for rule in self._transitions: - - if rule.before == "*": - if info.next_section == info.name: - continue # a repeat is not a boundary - elif info.next_section != rule.before: - continue - - if rule.fill is not None and bars_remaining == 1: - self._fire_fill(rule, boundary_pulse + int(round(rule.beat * self._sequencer.pulses_per_beat))) - - if rule.mute: - window_beats = rule.beats if rule.beats is not None else bar_beats - window_bars = max(1, int((window_beats + bar_beats - 1e-9) // bar_beats)) - - if bars_remaining <= window_bars: - for name in rule.mute: - running = self._running_patterns.get(name) - if running is None or name in self._transition_muted: - continue - if running._muted: - continue # the performer's mute — not ours to manage - running._muted = True - self._transition_muted.add(name) - - @staticmethod - def _resolve_length ( - beats: typing.Optional[float], - bars: typing.Optional[float], - steps: typing.Optional[float], - step_duration: typing.Optional[float], - default: float = 4.0, - beats_per_bar: int = 4, - ) -> typing.Tuple[float, int]: - - """ - Resolve the beat_length and default_grid from the duration parameters. - - Two modes: - - - **Duration mode** (no ``step_duration``): specify ``beats=`` or ``bars=``. - ``beats=4`` = 4 quarter notes; ``bars=2`` = 8 beats. - - **Step mode** (with ``step_duration``): specify ``steps=`` and ``step_duration=``. - ``steps=6, step_duration=dur.SIXTEENTH`` = 6 sixteenth notes = 1.5 beats. - - Constraints: - - - ``beats`` and ``bars`` are mutually exclusive. - - ``steps`` requires ``step_duration``; ``step_duration`` requires ``steps``. - - ``steps`` cannot be combined with ``beats`` or ``bars``. - - Returns: - (beat_length, default_grid) — beat_length in beats (quarter notes); - default_grid the number of grid steps (16th-notes in beat mode, or the - explicit ``steps`` value directly in step mode). - """ - - if beats is not None and bars is not None: - raise ValueError("Specify only one of beats= or bars=") - - if steps is not None and (beats is not None or bars is not None): - raise ValueError("steps= cannot be combined with beats= or bars=") - - if step_duration is not None and steps is None: - raise ValueError("step_duration= requires steps= (e.g. steps=6, step_duration=dur.SIXTEENTH)") - - if steps is not None: - if step_duration is None: - raise ValueError("steps= requires step_duration= (e.g. step_duration=dur.SIXTEENTH)") - return steps * step_duration, int(steps) - - if bars is not None: - raw = bars * beats_per_bar - elif beats is not None: - raw = beats - else: - raw = default - - return raw, round(raw / subsequence.constants.durations.SIXTEENTH) - - def pattern ( - self, - channel: int, - beats: typing.Optional[float] = None, - bars: typing.Optional[float] = None, - steps: typing.Optional[float] = None, - step_duration: typing.Optional[float] = None, - drum_note_map: typing.Optional[typing.Dict[str, int]] = None, - cc_name_map: typing.Optional[typing.Dict[str, int]] = None, - nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, - reschedule_lookahead: float = 1, - voice_leading: bool = False, - device: subsequence.midi_utils.DeviceId = None, - mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, - min_energy: typing.Optional[float] = None, - ) -> typing.Callable: - - """ - Register a function as a repeating MIDI pattern. - - The decorated function will be called once per cycle to 'rebuild' its - content. This allows for generative logic that evolves over time. - - Two ways to specify pattern length: - - - **Duration mode** (default): use ``beats=`` or ``bars=``. - The grid defaults to sixteenth-note resolution. - - **Step mode**: use ``steps=`` paired with ``step_duration=``. - The grid equals the step count, so ``p.hit_steps()`` indices map - directly to steps. - - Parameters: - channel: MIDI channel. By default uses 1-based numbering (1-16). - Set ``zero_indexed_channels=True`` on the ``Composition`` to use - 0-based numbering (0-15), matching the raw MIDI protocol, instead. - beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. - bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. - steps: Step count for step mode. Requires ``step_duration=``. - step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). - Requires ``steps=``. - drum_note_map: Optional mapping for drum instruments. - cc_name_map: Optional mapping of CC names to MIDI CC numbers. - Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. - nrpn_name_map: Optional mapping of NRPN parameter names (strings) to - 14-bit parameter numbers (0–16383). Enables string-based names - in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a - device-specific dictionary (e.g. Sequential Take 5's - ``Osc1FreqFine`` → 9). - reschedule_lookahead: Beats in advance to compute the next cycle. - voice_leading: If True, chords in this pattern will automatically - use inversions that minimize voice movement. - mirrors: Optional list of additional ``(device, channel)`` destinations - to duplicate every event from this pattern onto. Notes, CCs, pitch - bend, NRPN/RPN bursts, program changes, SysEx, and drone events are - all mirrored; OSC events are not (OSC is not bound to a MIDI port). - ``device`` is the integer index returned by ``midi_output()`` (0 = - primary). ``channel`` follows this composition's channel-numbering - convention. See also ``mirror()`` / ``unmirror()`` for live toggling. - min_energy: Automatic energy gating — the pattern is silent while - the current section's energy (``composition.energy()`` dict, - or the bound Section payload) is below this threshold. - Composes with ``mute()``: a performer mute always wins. - - Example: - ```python - @comp.pattern(channel=1, beats=4) - def chords (p): - p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) - - @comp.pattern(channel=1, bars=2) - def long_phrase (p): - ... - - @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) - def riff (p): - p.sequence(steps=[0, 1, 3, 5], pitches=60) - ``` - """ - - channel = self._resolve_channel(channel) - - beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) - - # Resolve device string name to index if possible now; otherwise store - # the raw DeviceId and resolve it in _run() once all devices are open. - resolved_device: subsequence.midi_utils.DeviceId = device - - # Mirror-to-self check is only reliable when the primary device is a - # concrete integer at decoration time. ``None`` resolves to device 0 - # downstream, so we treat it as 0 here too. Strings are deferred to - # ``_run()`` and we skip the check for them. - primary: typing.Optional[typing.Tuple[int, int]] - if isinstance(resolved_device, str): - primary = None - else: - primary = (resolved_device if resolved_device is not None else 0, channel) - resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) - - def decorator (fn: typing.Callable) -> typing.Callable: - - """ - Wrap the builder function and register it as a pending pattern. - During live sessions, hot-swap an existing pattern's builder instead. - """ - - # Record this declaration so the live-reload deletion diff knows the - # pattern is still present in the source (see _apply_source_async). - self._declared_names.add(fn.__name__) - - # Hot-swap: if we're live and a pattern with this name exists, replace its builder. - if self._is_live and fn.__name__ in self._running_patterns: - running = self._running_patterns[fn.__name__] - running._builder_fn = fn - running._wants_chord = _fn_has_parameter(fn, "chord") - logger.info(f"Hot-swapped pattern: {fn.__name__}") - return fn - - # Names key the seeded stream, mutes, tweaks, and reroll/lock — a - # duplicate means two scheduled copies sharing one stream with - # only one reachable by name. Warn loudly at registration. - if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns): - logger.warning( - f"Duplicate pattern name '{fn.__name__}': both copies will be " - f"scheduled, they share one seeded stream, and only one is " - f"reachable by name — rename one of them." - ) - - pending = _PendingPattern( - builder_fn = fn, - channel = channel, # already resolved to 0-indexed - length = beat_length, - default_grid = default_grid, - drum_note_map = drum_note_map, - cc_name_map = cc_name_map, - nrpn_name_map = nrpn_name_map, - reschedule_lookahead = reschedule_lookahead, - voice_leading = voice_leading, - # For int/None: resolve immediately. For str: store 0 as - # placeholder; _resolve_pending_devices() fixes it in _run(). - device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device, - raw_device = resolved_device, - mirrors = resolved_mirrors, - min_energy = min_energy, - ) - - self._pending_patterns.append(pending) - - return fn - - return decorator - - def layer ( - self, - *builder_fns: typing.Callable, - channel: int, - beats: typing.Optional[float] = None, - bars: typing.Optional[float] = None, - steps: typing.Optional[float] = None, - step_duration: typing.Optional[float] = None, - drum_note_map: typing.Optional[typing.Dict[str, int]] = None, - cc_name_map: typing.Optional[typing.Dict[str, int]] = None, - nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, - reschedule_lookahead: float = 1, - voice_leading: bool = False, - device: subsequence.midi_utils.DeviceId = None, - mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, - ) -> None: - - """ - Combine multiple functions into a single MIDI pattern. - - This is useful for composing complex patterns out of reusable - building blocks (e.g., a 'kick' function and a 'snare' function). - - See ``pattern()`` for the full description of ``beats``, ``bars``, - ``steps``, and ``step_duration``. - - Parameters: - builder_fns: One or more pattern builder functions. - channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). - beats: Duration in beats (quarter notes). - bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). - steps: Step count for step mode. Requires ``step_duration=``. - step_duration: Duration of one step in beats. Requires ``steps=``. - drum_note_map: Optional mapping for drum instruments. - cc_name_map: Optional mapping of CC names to MIDI CC numbers. - nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit - parameter numbers. - reschedule_lookahead: Beats in advance to compute the next cycle. - voice_leading: If True, chords use smooth voice leading. - mirrors: Optional list of additional ``(device, channel)`` destinations - to duplicate every event onto. See ``pattern()`` for details. - """ - - beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) - - # Resolve channel up-front so the mirror-to-self check has the canonical - # primary form to compare against. - resolved_channel = self._resolve_channel(channel) - - # See pattern() for the same comment about None / str handling. - primary: typing.Optional[typing.Tuple[int, int]] - if isinstance(device, str): - primary = None - else: - primary = (device if device is not None else 0, resolved_channel) - resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) - - wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) - - if wants_chord: - - def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None: - - for fn in builder_fns: - if _fn_has_parameter(fn, "chord"): - fn(p, chord) - else: - fn(p) - - else: - - def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] - - for fn in builder_fns: - fn(p) - - # Give the merged builder a stable, unique name derived from its - # components so multiple layer() calls don't all register under - # "merged_builder" and collide in _running_patterns (which made - # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't - # appear in a Python identifier, so this never clashes with a real - # pattern function's name. - base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}" - merged_name = base_name - suffix = 2 - - # Two layers with the same components (e.g. on different saves of a - # live file) must map to the same names pass-over-pass, while two - # DIFFERENT layers sharing components in one pass must not collide. - while merged_name in self._declared_names: - merged_name = f"{base_name}#{suffix}" - suffix += 1 - - merged_builder.__name__ = merged_name - - # Record the declaration for the live-reload deletion diff, and hot-swap - # in place when this layer is already running so a reload picks up edits - # to the component functions without losing the pattern's cycle count, - # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). - self._declared_names.add(merged_builder.__name__) - - if self._is_live and merged_builder.__name__ in self._running_patterns: - running = self._running_patterns[merged_builder.__name__] - running._builder_fn = merged_builder - running._wants_chord = wants_chord - logger.info(f"Hot-swapped layer: {merged_builder.__name__}") - return - - pending = _PendingPattern( - builder_fn = merged_builder, - channel = resolved_channel, # already resolved to 0-indexed above - length = beat_length, - default_grid = default_grid, - drum_note_map = drum_note_map, - cc_name_map = cc_name_map, - nrpn_name_map = nrpn_name_map, - reschedule_lookahead = reschedule_lookahead, - voice_leading = voice_leading, - mirrors = resolved_mirrors, - device = 0 if (device is None or isinstance(device, str)) else device, - raw_device = device, - ) - - self._pending_patterns.append(pending) - - def chords ( - self, - *, - channel: int, - progression: subsequence.progressions.ProgressionSource, - harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, - bars: typing.Optional[float] = None, - beats: typing.Optional[float] = None, - voicing: subsequence.progressions.VoicingSpec = (3, 4), - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, - detached: typing.Optional[float] = None, - root: int = 60, - key: typing.Optional[str] = None, - seed: typing.Optional[int] = None, - device: subsequence.midi_utils.DeviceId = None, - mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, - ) -> subsequence.progressions.Progression: - - """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. - - The one-call form of ``p.progression()`` — it registers a pattern on - *channel* that plays *progression* across *bars* (or *beats*), each chord - lasting a length drawn from *harmonic_rhythm* (the musical term for how often - the chords change). It needs no ``composition.harmony()`` call and, with an - explicit chord list or a ``key=``, no composition key either — so a - drums-plus-one-chord-part sketch stays simple. - - The progression is realised once, up front, and the same timeline plays every - cycle (a stable phrase). That timeline is returned so you can see exactly what - was chosen — ``print(comp.chords(...))``. - - Parameters: - channel: MIDI channel for the chord part. - progression: A chord-graph style name to generate from, or an explicit list - of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). - harmonic_rhythm: How long each chord lasts — a number, a list of lengths, - or ``between(low, high, step=...)``. See ``p.progression()``. - bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the - composition's time signature. - voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). - velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. - detached: Beats of silence before each next chord (``duration = length - detached``). - root: MIDI root the voicings are centred on (e.g. 48 = C3). - key: Key for a generated progression; defaults to the composition key. - seed: Seed for the (otherwise fixed) realisation; defaults to the - composition seed, so the part is reproducible. - device: Optional output-device override. - mirrors: Optional additional ``(device, channel)`` destinations. - - Returns: - The realised :class:`~subsequence.progressions.Progression`. - """ - - beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) - resolved_channel = self._resolve_channel(channel) - resolved_key = key if key is not None else self.key - - rng = random.Random(seed if seed is not None else self._seed) - timeline = subsequence.progressions.realize( - source = progression, - harmonic_rhythm = harmonic_rhythm, - key = resolved_key, - length = beat_length, - rng = rng, - scale = self.scale or "ionian", - ) - - captured_root = root - captured_velocity = velocity - captured_detached = detached - captured_voicing = voicing - - def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: - - """Replay the realised timeline as block chords each cycle (voicing per chord).""" - - for chord, start, length in timeline: - ring = length - captured_detached if (captured_detached and captured_detached < length) else length - voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng) - p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity) - - # Unique, stable name so multiple chord parts don't collide in - # _running_patterns — including two parts on the SAME channel, which - # get a deterministic #2/#3 suffix in declaration order. - base_name = f"chords@ch{resolved_channel}" - chords_name = base_name - suffix = 2 - - while chords_name in self._declared_names: - chords_name = f"{base_name}#{suffix}" - suffix += 1 - - chords_builder.__name__ = chords_name - self._declared_names.add(chords_name) - - primary: typing.Optional[typing.Tuple[int, int]] - if isinstance(device, str): - primary = None - else: - primary = (device if device is not None else 0, resolved_channel) - resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) - - if self._is_live and chords_builder.__name__ in self._running_patterns: - running = self._running_patterns[chords_builder.__name__] - running._builder_fn = chords_builder - running._wants_chord = False - logger.info(f"Hot-swapped chords: {chords_builder.__name__}") - return timeline - - pending = _PendingPattern( - builder_fn = chords_builder, - channel = resolved_channel, - length = beat_length, - default_grid = default_grid, - drum_note_map = None, - reschedule_lookahead = 1, - voice_leading = False, - mirrors = resolved_mirrors, - device = 0 if (device is None or isinstance(device, str)) else device, - raw_device = device, - ) - self._pending_patterns.append(pending) - return timeline - - def phrase_part ( - self, - *, - channel: int, - part: typing.Optional[str] = None, - root: int = 60, - bars: typing.Optional[float] = None, - beats: typing.Optional[float] = None, - velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, - fit: typing.Optional[float] = None, - device: subsequence.midi_utils.DeviceId = None, - mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, - ) -> None: - - """Declare a part that plays each section's bound Motif/Phrase. - - The one-call consumer for :meth:`section_motifs` — it registers a - pattern on *channel* that walks whatever value is bound to the - current section for *part* (stateless position from the cycle - counter, via ``p.phrase()``). A section with no binding for the - part is **silent** for that part — bind material or don't; no - fallback guessing. - - Parameters: - channel: MIDI channel for the part. - part: The part label to read from the registry (``None`` = the - unlabelled binding). - root: Register anchor for degree resolution. - bars / beats: Cycle length of the part (defaults to 4 beats); - the phrase is sliced one cycle window at a time. - velocity: Optional override applied to every note. - fit: Passed through (active with the melody engine stage). - device: Optional output-device override. - mirrors: Optional additional ``(device, channel)`` destinations. - - Example:: - - composition.section_motifs("verse", verse_line, part="lead") - composition.section_motifs("chorus", chorus_line, part="lead") - composition.phrase_part(channel=4, part="lead", root=72, bars=2) - """ - - beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) - resolved_channel = self._resolve_channel(channel) - - captured_part = part - captured_root = root - captured_velocity = velocity - captured_fit = fit - - def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: - - """Walk the current section's bound value (silent when unbound).""" - - value = p.section_motif(captured_part) - - if value is None: - return # unbound section: silence for this part, by design - - p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit) - - # Unique, stable name so multiple phrase parts don't collide — - # including two parts on the SAME channel (deterministic #2/#3 - # suffixes in declaration order, the chords() convention). - base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}" - phrase_name = base_name - suffix = 2 - - while phrase_name in self._declared_names: - phrase_name = f"{base_name}#{suffix}" - suffix += 1 - - phrase_builder.__name__ = phrase_name - self._declared_names.add(phrase_name) - - primary: typing.Optional[typing.Tuple[int, int]] - if isinstance(device, str): - primary = None - else: - primary = (device if device is not None else 0, resolved_channel) - resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) - - if self._is_live and phrase_builder.__name__ in self._running_patterns: - running = self._running_patterns[phrase_builder.__name__] - running._builder_fn = phrase_builder - running._wants_chord = False - logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") - return - - pending = _PendingPattern( - builder_fn = phrase_builder, - channel = resolved_channel, - length = beat_length, - default_grid = default_grid, - drum_note_map = None, - reschedule_lookahead = 1, - voice_leading = False, - mirrors = resolved_mirrors, - device = 0 if (device is None or isinstance(device, str)) else device, - raw_device = device, - ) - self._pending_patterns.append(pending) - - def trigger ( - self, - fn: typing.Callable, - channel: int, - beats: typing.Optional[float] = None, - bars: typing.Optional[float] = None, - steps: typing.Optional[float] = None, - step_duration: typing.Optional[float] = None, - quantize: float = 0, - drum_note_map: typing.Optional[typing.Dict[str, int]] = None, - cc_name_map: typing.Optional[typing.Dict[str, int]] = None, - nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, - chord: bool = False, - device: subsequence.midi_utils.DeviceId = None, - mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, - ) -> None: - - """ - Trigger a one-shot pattern immediately or on a quantized boundary. - - This is useful for real-time response to sensors, OSC messages, or other - external events. The builder function is called immediately with a fresh - PatternBuilder, and the generated events are injected into the queue at - the specified quantize boundary. - - The builder function has the same API as a ``@composition.pattern`` - decorated function and can use all PatternBuilder methods: ``p.note()``, - ``p.euclidean()``, ``p.arpeggio()``, and so on. - - See ``pattern()`` for the full description of ``beats``, ``bars``, - ``steps``, and ``step_duration``. Default is 1 beat. - - Parameters: - fn: The pattern builder function (same signature as ``@comp.pattern``). - channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). - beats: Duration in beats (quarter notes, default 1). - bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). - steps: Step count for step mode. Requires ``step_duration=``. - step_duration: Duration of one step in beats. Requires ``steps=``. - quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), - ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` - constants from ``subsequence.constants.durations``. - drum_note_map: Optional drum name mapping for this pattern. - cc_name_map: Optional mapping of CC names to MIDI CC numbers. - nrpn_name_map: Optional mapping of NRPN parameter names to - 14-bit parameter numbers. - chord: If ``True``, the builder function receives the current chord as - a second parameter (same as ``@composition.pattern``). - mirrors: Optional list of additional ``(device, channel)`` destinations - to fire this one-shot onto in parallel with the primary destination. - - Example: - ```python - # Immediate single note (channels are 1-16 by default) - composition.trigger( - lambda p: p.note(60, beat=0, velocity=100, duration=0.5), - channel=1 - ) - - # Quantized fill (next bar) — channel 10 is the GM drum channel - import subsequence.constants.durations as dur - composition.trigger( - lambda p: p.euclidean("snare", pulses=7, velocity=90), - channel=10, - drum_note_map=gm_drums.GM_DRUM_MAP, - quantize=dur.WHOLE - ) - - # With chord context — the builder receives the chord as a second - # argument when chord=True. - composition.trigger( - lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), - channel=1, - quantize=dur.QUARTER, - chord=True - ) - ``` - """ - - # Resolve channel numbering - resolved_channel = self._resolve_channel(channel) - - beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0]) - - # Resolve device index — for trigger() this is always concrete by call time, - # so the mirror-to-self check has the full primary tuple available. - resolved_device_idx = self._resolve_device_id(device) - resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel)) - - # Create a temporary Pattern - pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors) - - # Resolve the section context once: the one-shot inherits the section's - # effective key/scale (so a triggered degree resolves like everywhere - # else) and a harmony view at the current playhead (so ChordTone / - # Approach resolve too). - trigger_section = self._form_state.get_section_info() if self._form_state else None - trigger_key, trigger_scale = self._effective_key_scale(trigger_section) - - trigger_harmony: typing.Optional[HarmonyView] = None - if not self._harmony_horizon.is_empty: - trigger_harmony = HarmonyView(self._harmony_horizon, self._sequencer.pulse_count / self._sequencer.pulses_per_beat) - - # Create a PatternBuilder - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 - drum_note_map=drum_note_map, - cc_name_map=cc_name_map, - nrpn_name_map=nrpn_name_map, - section=trigger_section, - bar=self._builder_bar, - conductor=self.conductor, - rng=random.Random(), # Fresh random state for each trigger - tweaks={}, - default_grid=default_grid, - data=self.data, - # A one-shot resolves key-relative content against the same - # effective key/scale as the section it fires into (previously - # omitted entirely — degrees raised even in a keyed composition). - key=trigger_key, - scale=trigger_scale, - time_signature=self.time_signature, - held_notes=self._sequencer._held_notes, - harmony=trigger_harmony, - energy=self._current_energy(trigger_section) - ) - - # Call the builder function - try: - - current_chord = self.current_chord() if chord else None - - if current_chord is not None: - injected = _InjectedChord(current_chord, None) # No voice leading for one-shots - fn(builder, injected) - - else: - fn(builder) - - except Exception: - logger.exception("Error in trigger builder — pattern will be silent") - return - - # Calculate the start pulse based on quantize - current_pulse = self._sequencer.pulse_count - pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE - - if quantize == 0: - # Immediate: use current pulse - start_pulse = current_pulse - - else: - # Quantize to the next multiple of (quantize * pulses_per_beat) - quantize_pulses = int(quantize * pulses_per_beat) - start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses - - self._schedule_one_shot(pattern, start_pulse) - - def _schedule_one_shot (self, pattern: subsequence.pattern.Pattern, start_pulse: int) -> None: - - """Schedule a one-shot pattern at an absolute pulse, thread-safely.""" - - try: - # Probe only: raises RuntimeError when not on the event loop. - asyncio.get_running_loop() - asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse)) - - except RuntimeError: - # Not on the event loop — hand the coroutine to the loop thread. - if self._sequencer._event_loop is not None: - asyncio.run_coroutine_threadsafe( - self._sequencer.schedule_pattern(pattern, start_pulse), - loop=self._sequencer._event_loop - ) - else: - logger.warning("trigger() called before playback started; pattern ignored") - - @property - def is_clock_following (self) -> bool: - - """True if either the primary or any additional device is following external clock.""" - - return self._clock_follow or any(cf for _, _, cf in self._additional_inputs) - - - def play (self) -> None: - - """ - Start the composition. - - This call blocks until the program is interrupted (e.g., via Ctrl+C). - It initializes the MIDI hardware, launches the background sequencer, - and begins playback. - """ - - try: - asyncio.run(self._run()) - - except KeyboardInterrupt: - pass - - - def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None: - - """Render the composition to a MIDI file without real-time playback. - - Runs the sequencer as fast as possible (no timing delays) and stops - when the first active limit is reached. The result is saved as a - standard MIDI file that can be imported into any DAW. - - All patterns, scheduled callbacks, and harmony logic run exactly as - they would during live playback — BPM transitions, generative fills, - and probabilistic gates all work in render mode. The only difference - is that time is simulated rather than wall-clock driven. - - Parameters: - bars: Number of bars to render, or ``None`` for no bar limit - (default ``None``). When both *bars* and *max_minutes* are - active, playback stops at whichever limit is reached first. - filename: Output MIDI filename (default ``"render.mid"``). - max_minutes: Safety cap on the length of rendered MIDI in minutes - (default ``60.0``). Pass ``None`` to disable the time - cap — you must then provide an explicit *bars* value. - - Raises: - ValueError: If both *bars* and *max_minutes* are ``None``, which - would produce an infinite render. - - Examples: - ```python - # Default: renders up to 60 minutes of MIDI content. - composition.render() - - # Render exactly 64 bars (time cap still active as backstop). - composition.render(bars=64, filename="demo.mid") - - # Render up to 5 minutes of an infinite generative composition. - composition.render(max_minutes=5, filename="five_min.mid") - - # Remove the time cap — must supply bars instead. - composition.render(bars=128, max_minutes=None, filename="long.mid") - ``` - """ - - if bars is None and max_minutes is None: - raise ValueError( - "render() requires at least one limit: provide bars=, max_minutes=, or both. " - "Passing both as None would produce an infinite render." - ) - - self._sequencer.recording = True - self._sequencer.record_filename = filename - self._sequencer.render_mode = True - self._sequencer.render_bars = bars if bars is not None else 0 - self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None - asyncio.run(self._run()) - - def _broadcast_osc_status (self, bar: int) -> None: - - """ - Send the per-bar OSC status snapshot: bar number, current tempo, - and (when active) the current chord name and form section. - """ - - if self._osc_server: - self._osc_server.send("/bar", bar) - self._osc_server.send("/bpm", self._sequencer.current_bpm) - - sounding = self.current_chord() - if sounding is not None: - self._osc_server.send("/chord", sounding.name()) - - if self._form_state: - info = self._form_state.get_section_info() - if info: - self._osc_server.send("/section", info.name) - - async def _run (self) -> None: - - """ - Async entry point that schedules all patterns and runs the sequencer. - """ - - # 1. Pre-calculate MIDI input indices and configure sequencer clock follow. - if self._input_device is not None: - self._sequencer.input_device_name = self._input_device - self._sequencer.clock_follow = self._clock_follow - self._sequencer.clock_device_idx = 0 - - if not self._clock_follow: - # Find first additional input that wants to be the clock master. - for idx, (_, _, cf) in enumerate(self._additional_inputs, start=1): - if cf: - self._sequencer.clock_follow = True - self._sequencer.clock_device_idx = idx - break - - # Populate input device name mapping early (before opening ports) so we can - # resolve CC mappings to integer device indices immediately. - if self._sequencer.input_device_name: - self._input_device_names[self._sequencer.input_device_name] = 0 - if self._input_device_alias is not None: - self._input_device_names[self._input_device_alias] = 0 - - for idx, (dev_name, alias, _) in enumerate(self._additional_inputs, start=1): - self._input_device_names[dev_name] = idx - if alias: - self._input_device_names[alias] = idx - - # 2. Pre-calculate output device names. - if self._sequencer.output_device_name: - self._output_device_names[self._sequencer.output_device_name] = 0 - # Primary device (index 0) is open by now (_init_midi_output ran in - # the Sequencer constructor), so its latency can be set safely here. - if self._output_latency_ms: - self._sequencer.set_device_latency(0, self._output_latency_ms) - - # 3. Resolve name-based INPUT device ids in cc_map/cc_forward early — the - # input-names map is fully populated above, and the callback thread needs - # integer indices as soon as ports open. OUTPUT names (cc_forward - # output_device=, pattern device=) resolve after the additional outputs - # are opened below; resolving them here matched against a map containing - # only the primary and silently routed everything to device 0. - for mapping in self._cc_mappings: - raw = mapping.get('input_device') - if isinstance(raw, str): - mapping['input_device'] = self._resolve_input_device_id(raw) - for fwd in self._cc_forwards: - raw_in = fwd.get('input_device') - if isinstance(raw_in, str): - fwd['input_device'] = self._resolve_input_device_id(raw_in) - - # 4. Share CC input mappings, forwards, and a reference to composition.data - # with the sequencer BEFORE opening the ports. This ensures that any initial - # messages in the OS buffer are correctly mapped as soon as the port opens. - self._sequencer.cc_mappings = self._cc_mappings - self._sequencer.cc_forwards = self._cc_forwards - self._sequencer._composition_data = self.data - - # Held-note input: create the tracker and resolve its channel/device - # filter so the callback thread can buffer matching note events. - if self._note_input is not None: - if self._input_device is None and not self._additional_inputs: - raise RuntimeError("note_input() requires a MIDI input — call composition.midi_input(device) first") - raw_dev = self._note_input.get('input_device') - if isinstance(raw_dev, str): - raw_dev = self._resolve_input_device_id(raw_dev) - self._sequencer._note_input_channel = self._note_input['channel'] - self._sequencer._note_input_device = raw_dev - self._sequencer._held_notes = subsequence.held_notes.HeldNotes( - release_ms = self._note_input['release_ms'], - latch = self._note_input['latch'], - ) - - # 5. Open MIDI input ports early. Even without a deliberate sleep, opening - # them before pattern building minimizes the window for missed messages. - # Primary input - self._sequencer._open_midi_inputs() - - # Additional inputs - for idx, (dev_name, alias, cf) in enumerate(self._additional_inputs, start=1): - # Use the pre-calculated index - callback = self._sequencer._make_input_callback(idx) - open_name, port = subsequence.midi_utils.select_input_device(dev_name, callback) - if open_name and port is not None: - self._sequencer.add_input_device(open_name, port) - else: - logger.warning(f"Could not open additional input device '{dev_name}'") - - # 6. Open additional MIDI output devices. - for out in self._additional_outputs: - open_name, port = subsequence.midi_utils.select_output_device(out.device) - if open_name and port is not None: - idx = self._sequencer.add_output_device(open_name, port, out.latency_ms) - self._output_device_names[open_name] = idx - if out.alias is not None: - self._output_device_names[out.alias] = idx - else: - logger.warning(f"Could not open additional output device '{out.device}'") - - # Warn if latency compensation adds noticeable whole-rig delay: the - # slowest device defines the alignment point, so every faster device is - # delayed up to that amount and live-input feel suffers. - self._warn_if_high_latency() - - # Resolve any name-based output device IDs on patterns that may have been added - # for additional output devices. - self._resolve_pending_devices() - - # Resolve cc_forward output-device names now that every output port and - # alias is registered (resolving earlier silently routed to device 0). - for fwd in self._cc_forwards: - raw_out = fwd.get('output_device') - if isinstance(raw_out, str): - fwd['output_device'] = self._resolve_device_id(raw_out) - - # Pass clock output flag (suppressed automatically when clock_follow=True). - self._sequencer.clock_output = self._clock_output and not self.is_clock_following - - # Create Ableton Link clock if comp.link() was called. - if self._link_quantum is not None: - self._sequencer._link_clock = subsequence.link_clock.LinkClock( - bpm = self.bpm, - quantum = self._link_quantum, - loop = asyncio.get_running_loop(), - ) - - # Deal play-time streams. Every stream is NAME-keyed (crc32 of - # "seed:name", see _stream_seed) rather than dealt from one master in - # registration order: adding or removing one consumer can never shift - # another's stream, and patterns added live derive identically in - # _build_pattern_from_pending. When no seed is set, components keep - # their own unseeded RNGs (existing behaviour). - if self._seed is not None: - - harmony_stream = self._stream("play:harmony") - if self._harmonic_state is not None and harmony_stream is not None: - self._harmonic_state.rng = harmony_stream - - form_stream = self._stream("play:form") - if self._form_state is not None and form_stream is not None: - self._form_state._rng = form_stream - - # The clocks fire BEFORE pattern rebuilds at the same pulse, and their - # lookahead is RAISED to the maximum pattern lookahead (never patterns - # clamped down): when a pattern rebuilds for its next cycle, the form - # state and the harmony window already describe that cycle. - bar_beats = float(self.time_signature[0]) - - pattern_lookaheads = [pending.reschedule_lookahead for pending in self._pending_patterns] - pattern_lookaheads += [pattern.reschedule_lookahead for pattern in self._running_patterns.values()] - max_pattern_lookahead = max(pattern_lookaheads, default = 1) - - clock_lookahead = max(1.0, float(self._harmony_reschedule_lookahead), float(max_pattern_lookahead)) - - if clock_lookahead > bar_beats: - logger.warning( - "A pattern's reschedule_lookahead (%.2g beats) exceeds the bar length (%.2g) — " - "the harmony/form clocks fire at most one bar ahead, so that pattern may " - "rebuild before the window covers its cycle start.", - clock_lookahead, bar_beats, - ) - clock_lookahead = bar_beats - - # Minimum span >= maximum lookahead: the clock cannot prepare a chord - # boundary that arrives sooner than it fires. Harmonic motion faster - # than this floor stays available at the part level (p.progression), - # where placement is not clock-bound. - def _check_span_floor (progression: typing.Optional[Progression], label: str) -> None: - if progression is None: - return - shortest = min(span.beats for span in progression.spans) - if shortest < clock_lookahead - 1e-9: - raise ValueError( - f"{label}: shortest chord span ({shortest:g} beats) is below the clock " - f"lookahead ({clock_lookahead:g} beats — the largest pattern lookahead). " - "Lengthen the span, lower the pattern lookaheads, or place fast harmony " - "at the part level with p.progression()." - ) - - _check_span_floor(self._bound_progression, "harmony(progression=)") - for section_name, section_progression in self._section_progressions.items(): - _check_span_floor(section_progression, f"section_chords({section_name!r})") - - # Key-relative section progressions resolve late, per occurrence — so - # verify they WILL resolve now, before playback, rather than surfacing - # a silent skip (or a dead clock) mid-render. For each occurrence's - # effective key+scale: a missing key, or a degree/scale that does not - # resolve, is raised here with an actionable message. - fs = self._form_state - - for section_name, section_progression in self._section_progressions.items(): - if section_progression.is_concrete: - continue - - # The (key, scale) contexts this section may be resolved against. - contexts: typing.List[typing.Tuple[typing.Optional[str], typing.Optional[str]]] = [] - if fs is not None and fs._sequence is not None and any(s.name == section_name for s in fs._sequence): - for section in fs._sequence: - if section.name != section_name: - continue - ctx = (section.key or self._form_key or self.key, section.scale or self._form_scale or self.scale) - if ctx not in contexts: - contexts.append(ctx) - else: - contexts.append((self._form_key or self.key, self._form_scale or self.scale)) - - for ctx_key, ctx_scale in contexts: - if ctx_key is None: - raise ValueError( - f"section_chords({section_name!r}) is key-relative (degrees/romans) but no key " - "resolves for it — set key= on the Composition, a form key (form(key=...)), or " - f"a Section.key on every {section_name!r} section." - ) - try: - section_progression.resolve(ctx_key, ctx_scale or "ionian") - except ValueError as error: - raise ValueError( - f"section_chords({section_name!r}) does not resolve against its effective key " - f"{ctx_key} {ctx_scale or 'ionian'}: {error}" - ) - - # min_energy with nothing feeding p.energy is a silent no-op — warn loudly. - energy_gated = [p.builder_fn.__name__ for p in self._pending_patterns if p.min_energy is not None] - - if energy_gated and not self._energy_map and not self._form_has_payload: - logger.warning( - f"min_energy is set on {', '.join(energy_gated)} but no energy source is " - "configured — p.energy is always 0.5 (call composition.energy() or bind a " - "Form whose Sections carry energy)" - ) - - # The form clock MUST be registered before the harmonic clock: same-pulse - # fixed callbacks fire in registration order (and all fixed callbacks fire - # before callback sequences), and on a section-boundary bar the harmonic - # clock reads the current section (via _get_section_progression) to decide - # whether to walk that section's chords. Registering harmony first would - # make it read the OLD section on every boundary, shifting section_chords() - # replays by one bar and bleeding them across sections. - if self._form_state is not None: - - await schedule_form( - sequencer = self._sequencer, - form_state = self._form_state, - reschedule_lookahead = clock_lookahead, - on_bar = self._check_transitions, - # Re-read every bar so a mid-playback form() re-bind advances - # the NEW state instead of the abandoned object. - get_form_state = lambda: self._form_state, - ) - - self._harmony_horizon.reset() - self._harmonic_clock_started = False - - if self._harmonic_state is not None or self._bound_progression is not None or self._section_progressions: - await self._start_harmonic_clock(bar_beats, clock_lookahead) - - # Bar counter - always active so p.bar is available to all builders. - def _advance_builder_bar (pulse: int) -> None: - self._builder_bar += 1 - - first_bar_pulse = int(self.time_signature[0] * self._sequencer.pulses_per_beat) - - await self._sequencer.schedule_callback_repeating( - callback = _advance_builder_bar, - interval_beats = self.time_signature[0], - start_pulse = first_bar_pulse, - # Same raised lookahead as the form/harmony clocks: a pattern - # rebuilding lookahead-early for its next cycle must read the bar - # that cycle starts in, not the previous one. - reschedule_lookahead = clock_lookahead - ) - - # Run wait_for_initial=True scheduled functions and block until all complete. - # This ensures composition.data is populated before patterns build. - initial_tasks = [t for t in self._pending_scheduled if t.wait_for_initial] - - if initial_tasks: - - names = ", ".join(getattr(t.fn, '__name__', repr(t.fn)) for t in initial_tasks) - logger.info(f"Waiting for initial scheduled {'function' if len(initial_tasks) == 1 else 'functions'} before start: {names}") - - async def _run_initial (fn: typing.Callable) -> None: - - accepts_ctx = _fn_has_parameter(fn, "p") - ctx = ScheduleContext(cycle=0) - - try: - if inspect.iscoroutinefunction(fn): - await (fn(ctx) if accepts_ctx else fn()) - else: - loop = asyncio.get_running_loop() - call = (lambda: fn(ctx)) if accepts_ctx else fn - await loop.run_in_executor(None, call) - except Exception as exc: - logger.warning(f"Initial run of {getattr(fn, '__name__', repr(fn))!r} failed: {exc}") - - await asyncio.gather(*[_run_initial(t.fn) for t in initial_tasks]) - - for pending_task in self._pending_scheduled: - - accepts_ctx = _fn_has_parameter(pending_task.fn, "p") - - # A wait_for_initial task already ran once as cycle 0 (the blocking - # pre-roll above), so its repeating wrapper starts at cycle 1 — keeping - # ScheduleContext.cycle monotonic across the initial and repeating runs. - wrapped = _make_safe_callback( - pending_task.fn, - accepts_context = accepts_ctx, - start_cycle = 1 if pending_task.wait_for_initial else 0, - ) - - # wait_for_initial=True implies defer — no point firing at pulse 0 - # after the blocking run just completed. defer=True skips the - # backshift fire so the first repeating call happens one full cycle - # later. - if pending_task.wait_for_initial or pending_task.defer: - start_pulse = int(pending_task.cycle_beats * self._sequencer.pulses_per_beat) - else: - start_pulse = 0 - - await self._sequencer.schedule_callback_repeating( - callback = wrapped, - interval_beats = pending_task.cycle_beats, - start_pulse = start_pulse, - reschedule_lookahead = pending_task.reschedule_lookahead - ) - - # Build Pattern objects from pending registrations. - patterns: typing.List[subsequence.pattern.Pattern] = [] - - for i, pending in enumerate(self._pending_patterns): - - pattern = self._build_pattern_from_pending(pending) - patterns.append(pattern) - - await schedule_patterns( - sequencer = self._sequencer, - patterns = patterns, - start_pulse = 0 - ) - - # Populate the running patterns dict for live hot-swap and mute/unmute. - for i, pending in enumerate(self._pending_patterns): - name = pending.builder_fn.__name__ - self._running_patterns[name] = patterns[i] - - # Everything pending is running now; drop the declarations so a later - # live reload cannot graduate stale copies. - self._pending_patterns = [] - - if self._display is not None and not self._sequencer.render_mode: - self._display.start() - self._sequencer.on_event("bar", self._display.update) - self._sequencer.on_event("beat", self._display.update) - - if self._live_server is not None: - await self._live_server.start() - - if self._osc_server is not None: - await self._osc_server.start() - self._sequencer.osc_server = self._osc_server - self._sequencer.on_event("bar", self._broadcast_osc_status) - - # Start keystroke listener if hotkeys are enabled and not in render mode. - if self._hotkeys_enabled and not self._sequencer.render_mode: - self._keystroke_listener = subsequence.keystroke.KeystrokeListener() - self._keystroke_listener.start() - - if self._keystroke_listener.active: - # Listener started successfully — register the bar handler - # and show all bindings so the user knows what's available. - self._sequencer.on_event("bar", self._process_hotkeys) - self._list_hotkeys() - # If not active, KeystrokeListener.start() already logged a warning. - - if self._web_ui_enabled and not self._sequencer.render_mode: - self._web_ui_server = subsequence.web_ui.WebUI(self, http_host=self._web_ui_http_host, ws_host=self._web_ui_ws_host) - self._web_ui_server.start() - - try: - await run_until_stopped(self._sequencer) - finally: - # Tear down every service even if run_until_stopped (or an earlier - # stop) raised, and guard each individually, so one failure can't - # strand the rest — most importantly the keystroke listener's - # terminal restore. - if self._web_ui_server is not None: - try: - self._web_ui_server.stop() - except Exception: - logger.exception("Error stopping web UI") - - if self._live_server is not None: - try: - await self._live_server.stop() - except Exception: - logger.exception("Error stopping live server") - - if self._live_reloader is not None: - try: - self._live_reloader.stop() - except Exception: - logger.exception("Error stopping live reloader") - - if self._osc_server is not None: - try: - await self._osc_server.stop() - except Exception: - logger.exception("Error stopping OSC server") - self._sequencer.osc_server = None - - if self._display is not None: - try: - self._display.stop() - except Exception: - logger.exception("Error stopping display") - - if self._keystroke_listener is not None: - try: - self._keystroke_listener.stop() - except Exception: - logger.exception("Error stopping keystroke listener") - self._keystroke_listener = None - - def _build_pattern_from_pending (self, pending: _PendingPattern, start_pulse: int = 0) -> subsequence.pattern.Pattern: - - """ - Create a Pattern from a pending registration using a temporary subclass. - - The pattern's play stream is dealt here, keyed by NAME (crc32 of - "seed:name" plus any reroll nonce), so registration order is - irrelevant and a pattern added live gets exactly the stream it would - have had at startup. ``start_pulse`` anchors the first cycle on the - beat axis so the initial build reads the harmony window at the right - place (the sequencer keeps the anchor current on every reschedule). - """ - - composition_ref = self - rng = self._stream(pending.builder_fn.__name__) - - class _DecoratorPattern (subsequence.pattern.Pattern): - - """ - Pattern subclass that delegates to a builder function on each reschedule. - """ - - def __init__ (self, pending: _PendingPattern, pattern_rng: typing.Optional[random.Random] = None) -> None: - - """ - Initialize the decorator pattern from pending registration details. - """ - - super().__init__( - channel = pending.channel, - length = pending.length, - reschedule_lookahead = pending.reschedule_lookahead, - device = pending.device, - mirrors = pending.mirrors, - ) - - self._builder_fn = pending.builder_fn - self._drum_note_map = pending.drum_note_map - self._cc_name_map = pending.cc_name_map - self._nrpn_name_map = pending.nrpn_name_map - self._default_grid: int = pending.default_grid - self._wants_chord = _fn_has_parameter(pending.builder_fn, "chord") - self._cycle_count = 0 - self._rng = pattern_rng - self._muted = False - self._min_energy = pending.min_energy - self._energy_gated = False - self._voice_leading_state: typing.Optional[subsequence.voicings.VoiceLeadingState] = ( - subsequence.voicings.VoiceLeadingState() if pending.voice_leading else None - ) - self._tweaks: typing.Dict[str, typing.Any] = {} - - # Anchor of the cycle being built, on the absolute pulse axis. - # The sequencer updates this on every reschedule; the initial - # value is the pattern's first scheduled start. - self._cycle_start_pulse = start_pulse - - self._rebuild() - - def _rebuild (self) -> None: - - """ - Clear steps and call the builder function to repopulate. - """ - - self.steps = {} - self.cc_events = [] - self.osc_events = [] - self.raw_note_events = [] - current_cycle = self._cycle_count - self._cycle_count += 1 - - # lock(): re-deal the stream from its effective seed every - # rebuild so a locked pattern realizes identically each cycle. - # Checked here (engine-side) so it survives live reload. - if self._builder_fn.__name__ in composition_ref._locked_names: - locked_seed = composition_ref._stream_seed(self._builder_fn.__name__) - if locked_seed is not None: - self._rng = random.Random(locked_seed) - - if self._muted: - return - - section_info = composition_ref._form_state.get_section_info() if composition_ref._form_state else None - energy = composition_ref._current_energy(section_info) - effective_key, effective_scale = composition_ref._effective_key_scale(section_info) - - # Automatic energy gating: below the threshold the pattern is - # silent this cycle (composing with _muted — a performer mute - # always wins). Gate flips log once. - if self._min_energy is not None: - gated = energy < self._min_energy - - if gated != self._energy_gated: - state_word = "closed" if gated else "open" - logger.info( - f"Pattern '{self._builder_fn.__name__}': energy gate {state_word} " - f"(energy {energy:.2f}, min_energy {self._min_energy:g})" - ) - self._energy_gated = gated - - if gated: - return - - # The harmony view for this cycle, anchored at its start beat — - # under variable harmonic rhythm the window, not the engine's - # mutating singleton, is the source of truth. - harmony_view: typing.Optional[HarmonyView] = None - - if not composition_ref._harmony_horizon.is_empty: - origin_beat = self._cycle_start_pulse / composition_ref._sequencer.pulses_per_beat - harmony_view = HarmonyView(composition_ref._harmony_horizon, origin_beat) - - builder = subsequence.pattern_builder.PatternBuilder( - pattern = self, - cycle = current_cycle, - drum_note_map = self._drum_note_map, - cc_name_map = self._cc_name_map, - nrpn_name_map = self._nrpn_name_map, - section = section_info, - bar = composition_ref._builder_bar, - conductor = composition_ref.conductor, - rng = self._rng, - tweaks = self._tweaks, - default_grid = self._default_grid, - data = composition_ref.data, - # The effective key/scale re-anchors key-relative content - # (degrees, romans, generated material) to the section / - # form / composition tier in force — mode travels too. - key = effective_key, - scale = effective_scale, - time_signature = composition_ref.time_signature, - held_notes = composition_ref._sequencer._held_notes, - harmony = harmony_view, - section_motifs = composition_ref._section_motifs, - energy = energy, - ) - - try: - - if self._wants_chord: - - # The two-parameter convention: the injected chord is - # the cycle-start snapshot from the window (falling - # back to the engine before the clock has run). - chord = harmony_view.chord if harmony_view is not None else ( - composition_ref._harmonic_state.get_current_chord() - if composition_ref._harmonic_state is not None else None - ) - - if chord is not None: - injected = _InjectedChord( - chord, - self._voice_leading_state, - next_chord = harmony_view.next_chord if harmony_view is not None else None, - beats_remaining = harmony_view.until_change if harmony_view is not None else None, - ) - self._builder_fn(builder, injected) - else: - self._builder_fn(builder) - - else: - self._builder_fn(builder) - - except Exception: - # Discard whatever the builder placed before it raised — - # otherwise a half-built pattern plays and the log lies. - self.steps = {} - self.cc_events = [] - self.osc_events = [] - self.raw_note_events = [] - logger.exception("Error in pattern builder '%s' (cycle %d) - pattern will be silent this cycle", self._builder_fn.__name__, current_cycle) - - # Auto-apply global tuning if set and not already applied by the builder. - if ( - composition_ref._tuning is not None - and not builder._tuning_applied - and not (composition_ref._tuning_exclude_drums and self._drum_note_map) - ): - import subsequence.tuning as _tuning_mod - _tuning_mod.apply_tuning_to_pattern( - self, - composition_ref._tuning, - bend_range=composition_ref._tuning_bend_range, - channels=composition_ref._tuning_channels, - reference_note=composition_ref._tuning_reference_note, - ) - - def on_reschedule (self) -> None: - - """ - Rebuild the pattern from the builder function before the next cycle. - """ - - self._rebuild() - - return _DecoratorPattern(pending, rng) + # Process newly arrived keys. + for key in self._keystroke_listener.drain(): + if key == _HOTKEY_RESERVED: + self._list_hotkeys() + continue + + binding = self._hotkey_bindings.get(key) + if binding is None: + continue + + if binding.quantize == 0: + # Immediate — execute now (we're on the bar-event callback, + # which is safe for all mutation methods). + try: + binding.action() + logger.info(f"Hotkey '{key}' \u2192 {binding.label}") + except Exception as exc: + logger.warning(f"Hotkey '{key}' action raised: {exc}") + else: + # Defer until the next quantize boundary. + self._pending_hotkey_actions.append( + _PendingHotkeyAction(binding=binding) + ) + + # Fire any pending actions whose bar boundary has arrived. + still_pending: typing.List[_PendingHotkeyAction] = [] + + for pending in self._pending_hotkey_actions: + if bar % pending.binding.quantize == 0: + try: + pending.binding.action() + logger.info( + f"Hotkey '{pending.binding.key}' \u2192 {pending.binding.label} " + f"(bar {bar})" + ) + except Exception as exc: + logger.warning( + f"Hotkey '{pending.binding.key}' action raised: {exc}" + ) + else: + still_pending.append(pending) + + self._pending_hotkey_actions = still_pending + + @property + def seed(self) -> typing.Optional[int]: + """ + The composition's random seed, or None when unseeded. + + When set, every random decision derives deterministically from this + value through named streams (see ``seed_for()``), so the same script + produces the same music on every run. Assign to set it:: + + comp.seed = 42 + + (Formerly the method ``comp.seed(42)`` — the call form is a hard + break per the pre-1.0 rename policy.) + """ + + return self._seed + + @seed.setter + def seed(self, value: typing.Optional[int]) -> None: + """Set the composition seed (``comp.seed = 42``).""" + + self._seed = value + + def _stream_seed(self, name: str) -> typing.Optional[int]: + """ + Derive the effective integer seed for a named random stream. + + The derivation is ``zlib.crc32(f"{seed}:{name}")`` — crc32 rather + than ``hash()`` because it is stable across processes — plus the + per-name nonce when ``reroll()`` has been called. Returns None when + the composition is unseeded. + """ + + if self._seed is None: + return None + + nonce = self._reroll_nonces.get(name, 0) + key = f"{self._seed}:{name}" if nonce == 0 else f"{self._seed}:{name}:{nonce}" + return zlib.crc32(key.encode()) + + def _stream(self, name: str) -> typing.Optional[random.Random]: + """A fresh ``random.Random`` for a named stream, or None when unseeded.""" + + stream_seed = self._stream_seed(name) + return None if stream_seed is None else random.Random(stream_seed) + + def seed_for(self, name: str) -> typing.Optional[int]: + """ + Surface the effective derived seed for a named stream. + + Works for pattern names and equally for any name you invent for a + standalone value generator (``seed=composition.seed_for("hook")``), + so its randomness keys off the composition seed without sharing any + other consumer's stream. Reflects ``reroll()`` nonces. Returns None + when the composition is unseeded. + + Example: + ```python + hook_seed = composition.seed_for("hook") + ``` + """ + + return self._stream_seed(name) + + def reroll(self, name: str) -> None: + """ + Deal a named stream a fresh deterministic seed — try a new variation. + + Bumps the per-name nonce and prints the new effective seed. The + nonce lives only in this process, so the printed seed is what lets a + variation you like survive a restart: note it down, or ``lock()`` the + name to pin it for the session. Refuses on locked names. + + Parameters: + name: The stream name — usually a pattern name. + + Example: + ```python + comp.reroll("lead") # prints: reroll('lead') -> effective seed ... + ``` + """ + + if name in self._locked_names: + print( + f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first" + ) + return + + self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1 + effective = self._stream_seed(name) + + if effective is None: + print(f"reroll('{name}'): composition has no seed - randomness is unseeded") + return + + running = self._running_patterns.get(name) + + if running is not None and hasattr(running, "_rng"): + running._rng = random.Random(effective) + + print( + f"reroll('{name}') -> effective seed {effective} (nonce {self._reroll_nonces[name]})" + ) + + def lock(self, name: str) -> None: + """ + Pin a named stream: keep its current effective seed and realization. + + Engine-side state, so it survives live reload (it is never a builder + swap): a locked pattern re-deals its stream from the same effective + seed on every rebuild, so every cycle realizes identically, and + ``reroll()`` refuses with a message until ``unlock()``. + + Parameters: + name: The stream name — usually a pattern name. + """ + + self._locked_names.add(name) + + def unlock(self, name: str) -> None: + """Release a ``lock()``: the stream runs free and ``reroll()`` works again.""" + + self._locked_names.discard(name) + + def tuning( + self, + source: typing.Optional[typing.Union[str, "os.PathLike"]] = None, + *, + cents: typing.Optional[typing.List[float]] = None, + ratios: typing.Optional[typing.List[float]] = None, + equal: typing.Optional[int] = None, + bend_range: float = 2.0, + channels: typing.Optional[typing.List[int]] = None, + reference_note: int = 60, + exclude_drums: bool = True, + ) -> None: + """Set a global microtonal tuning for the composition. + + The tuning is applied automatically after each pattern rebuild (before + the pattern is scheduled). Drum patterns (those registered with a + ``drum_note_map``) are excluded by default. + + Supply exactly one of the source parameters: + + - ``source``: path to a Scala ``.scl`` file. + - ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit). + - ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``). + - ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``). + + For polyphonic parts, supply a ``channels`` pool. Notes are spread + across those MIDI channels so each can carry an independent pitch bend. + The synth must be configured to match ``bend_range`` (its pitch-bend range + setting in semitones). + + Parameters: + source: Path to a ``.scl`` file. + cents: Cent offsets for scale degrees 1..N. + ratios: Frequency ratios for scale degrees 1..N. + equal: Number of equal divisions of the period. + bend_range: Synth pitch-bend range in semitones (default ±2). + channels: Channel pool for polyphonic rotation. + reference_note: MIDI note mapped to scale degree 0 (default 60 = C4). + exclude_drums: When True (default), skip patterns that have a + ``drum_note_map`` (they use fixed GM pitches, not tuned ones). + + Example: + ```python + # Quarter-comma meantone from a Scala file + comp.tuning("meanquar.scl") + + # Just intonation from ratios + comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2]) + + # 19-TET, monophonic + comp.tuning(equal=19, bend_range=2.0) + + # 31-TET with channel rotation for polyphony (channels 1-6) + comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5]) + ``` + """ + import subsequence.tuning as _tuning_mod + + given = sum(x is not None for x in [source, cents, ratios, equal]) + if given == 0: + raise ValueError( + "composition.tuning() requires one of: source, cents, ratios, or equal" + ) + if given > 1: + raise ValueError("composition.tuning() accepts only one source parameter") + + if source is not None: + t = _tuning_mod.Tuning.from_scl(source) + elif cents is not None: + t = _tuning_mod.Tuning.from_cents(cents) + elif ratios is not None: + t = _tuning_mod.Tuning.from_ratios(ratios) + else: + t = _tuning_mod.Tuning.equal(equal) # type: ignore[arg-type] + + self._tuning = t + self._tuning_bend_range = bend_range + self._tuning_channels = channels + self._tuning_reference_note = reference_note + self._tuning_exclude_drums = exclude_drums + + def display( + self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0 + ) -> None: + """ + Enable or disable the live terminal dashboard. + + When enabled, Subsequence uses a safe logging handler that allows a + persistent status line (BPM, Key, Bar, Section, Chord) to stay at + the bottom of the terminal while logs scroll above it. + + Parameters: + enabled: Whether to show the display (default True). + grid: When True, render an ASCII grid visualisation of all + running patterns above the status line. The grid updates + once per bar, showing which steps have notes and at what + velocity. + grid_scale: Horizontal zoom factor for the grid (default + ``1.0``). Higher values add visual columns between + grid steps, revealing micro-timing from swing and groove. + Snapped to the nearest integer internally for uniform + marker spacing. + """ + + if enabled: + self._display = subsequence.display.Display( + self, grid=grid, grid_scale=grid_scale + ) + else: + self._display = None + + def web_ui(self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None: + """ + Enable the realtime Web UI Dashboard. + + When enabled, Subsequence instantiates a WebSocket server that broadcasts + the current state, signals, and active patterns (with high-res timing and + note data) to any connected browser clients. + + Both servers bind to localhost by default. Pass ``http_host`` / ``ws_host`` + (e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but + broadcasts full composition state, so only do so on a trusted network. + """ + + self._web_ui_enabled = True + self._web_ui_http_host = http_host + self._web_ui_ws_host = ws_host + + def midi_input( + self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None + ) -> None: + """ + Configure a MIDI input device for external sync and MIDI messages. + + May be called multiple times to register additional input devices. + The first call sets the primary input (device 0). Subsequent calls + add additional input devices (device 1, 2, …). Only one device may + have ``clock_follow=True``. + + Parameters: + device: The name of the MIDI input port. + clock_follow: If True, Subsequence will slave its clock to incoming + MIDI Ticks. It will also follow MIDI Start/Stop/Continue + commands. Only one device can have this enabled at a time. + name: Optional alias for use with ``cc_map(input_device=…)`` and + ``cc_forward(input_device=…)``. When omitted, the raw device + name is used. + + Example: + ```python + # Single controller (unchanged usage) + comp.midi_input("Scarlett 2i4", clock_follow=True) + + # Multiple controllers + comp.midi_input("Arturia KeyStep", name="keys") + comp.midi_input("Faderfox EC4", name="faders") + ``` + """ + + if clock_follow: + if self.is_clock_following: + raise ValueError( + "Only one input device can be configured to follow external clock (clock_follow=True)" + ) + + if self._input_device is None: + # First call: set primary input device (device 0) + self._input_device = device + self._input_device_alias = name + self._clock_follow = clock_follow + else: + # Subsequent calls: register additional input devices + self._additional_inputs.append((device, name, clock_follow)) + + def midi_output( + self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0 + ) -> int: + """ + Register an additional MIDI output device. + + The first output device is always the one passed to + ``Composition(output_device=…)`` — that is device 0. + Each call to ``midi_output()`` adds the next device (1, 2, …). + + Parameters: + device: The exact name of the MIDI output port, as reported + by ``mido.get_output_names()``. Matching is strict — + partial names and substrings are rejected. See + ``Composition.__init__`` for the lookup snippet and a + note on ALSA name stability on Linux. + name: Optional alias for use with ``pattern(device=…)``, + ``cc_forward(output_device=…)``, etc. When omitted, the raw + device name is used. + latency_ms: Physical output latency of this device in + milliseconds, for delay compensation (default 0.0, must be + non-negative). Set this when the device sounds late (e.g. a + software sampler) so Subsequence delays faster devices to + line everything up. + + Returns: + The integer device index assigned (1, 2, 3, …). + + Example: + ```python + comp = subsequence.Composition(bpm=120, output_device="MOTU Express") + + # Returns 1 — use as device=1 or device="integra" + comp.midi_output("Roland Integra", name="integra") + + # A software sampler that sounds 20ms late + comp.midi_output("Subsample", name="sampler", latency_ms=20) + + @comp.pattern(channel=1, beats=4, device="integra") + def strings (p): + p.note(60, beat=0) + ``` + """ + + if latency_ms < 0: + raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") + + idx = 1 + len(self._additional_outputs) # device 0 is always the primary + self._additional_outputs.append( + _AdditionalOutput(device=device, alias=name, latency_ms=latency_ms) + ) + return idx + + def _warn_if_high_latency(self) -> None: + """Warn if delay compensation adds a large whole-rig latency. + + The slowest device defines the alignment point — every faster device is + delayed up to that amount — so a large maximum means the whole rig + responds late to live input. Emitted once at startup. + """ + + candidates: typing.List[typing.Tuple[str, float]] = [ + ("primary output", self._output_latency_ms) + ] + candidates += [ + (out.alias or out.device, out.latency_ms) + for out in self._additional_outputs + ] + + slow_name, max_ms = max(candidates, key=lambda c: c[1]) + + if max_ms > _LATENCY_WARN_THRESHOLD_MS: + logger.warning( + "Device latency compensation: '%s' is the slowest at %.0fms, so faster " + "devices are delayed up to %.0fms to stay aligned — live-input feel may suffer.", + slow_name, + max_ms, + max_ms, + ) + + def clock_output(self, enabled: bool = True) -> None: + """ + Send MIDI timing clock to connected hardware. + + When enabled, Subsequence acts as a MIDI clock master and sends + standard clock messages on the output port: a Start message (0xFA) + when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), + and a Stop message (0xFC) when playback ends. + + This allows hardware synthesizers, drum machines, and effect units to + slave their tempo to Subsequence automatically. + + **Note:** Clock output is automatically disabled when ``midi_input()`` + is called with ``clock_follow=True``, to prevent a clock feedback loop. + + Parameters: + enabled: Whether to send MIDI clock (default True). + + Example: + ```python + comp = subsequence.Composition(bpm=120, output_device="...") + comp.clock_output() # hardware will follow Subsequence tempo + ``` + """ + + self._clock_output = enabled + + def link(self, quantum: float = 4.0) -> "Composition": + """ + Enable Ableton Link tempo and phase synchronisation. + + When enabled, Subsequence joins the local Link session and slaves its + clock to the shared network tempo and beat phase. All other Link-enabled + apps on the same LAN — Ableton Live, iOS synths, other Subsequence + instances — will automatically stay in time. + + Playback starts on the next bar boundary aligned to the Link quantum, + so downbeats stay in sync across all participants. + + Requires the ``link`` optional extra:: + + pip install subsequence[link] + + Parameters: + quantum: Beat cycle length. ``4.0`` (default) = one bar in 4/4 time. + Change this if your composition uses a different meter. + + Example:: + + comp = subsequence.Composition(bpm=120, key="C") + comp.link() # join the Link session + comp.play() + + # On another machine / instance: + comp2 = subsequence.Composition(bpm=120) + comp2.link() # tempo and phase will lock to comp + comp2.play() + + Note: + ``set_bpm()`` proposes the new tempo to the Link network when Link + is active. The network-authoritative tempo is applied on the next + pulse, so there may be a brief lag before the change is visible. + """ + + # Eagerly check that aalink is installed — fail early with a clear message. + subsequence.link_clock._require_aalink() + + self._link_quantum = quantum + return self + + def cc_map( + self, + cc: int, + data_key: str, + channel: typing.Optional[int] = None, + min_val: float = 0.0, + max_val: float = 1.0, + input_device: subsequence.midi_utils.DeviceId = None, + ) -> None: + """ + Map an incoming MIDI CC to a ``composition.data`` key. + + When the composition receives a CC message on the configured MIDI + input port, the value is scaled from the CC range (0–127) to + *[min_val, max_val]* and stored in ``composition.data[data_key]``. + + This lets hardware knobs, faders, and expression pedals control live + parameters without writing any callback code. + + **Requires** ``midi_input()`` to be called first to open an input port. + + Parameters: + cc: MIDI Control Change number (0–127). + data_key: The ``composition.data`` key to write. + channel: If given, only respond to CC messages on this channel. + Uses the same numbering convention as ``pattern()`` (1-16 + by default, or 0-15 with ``zero_indexed_channels=True``). + ``None`` matches any channel (default). + min_val: Scaled minimum — written when CC value is 0 (default 0.0). + max_val: Scaled maximum — written when CC value is 127 (default 1.0). + input_device: Only respond to CC messages from this input device + (index or name). ``None`` responds to any input device (default). + + Example: + ```python + comp.midi_input("Arturia KeyStep") + comp.cc_map(74, "filter_cutoff") # knob → 0.0–1.0 + comp.cc_map(7, "volume", min_val=0, max_val=127) # volume fader + + # Multi-device: only listen to CC 74 from the "faders" controller + comp.cc_map(74, "filter", input_device="faders") + ``` + """ + + resolved_channel = ( + self._resolve_channel(channel) if channel is not None else None + ) + + self._cc_mappings.append( + { + "cc": cc, + "data_key": data_key, + "channel": resolved_channel, + "min_val": min_val, + "max_val": max_val, + "input_device": input_device, # resolved to int index in _run() + } + ) + + def note_input( + self, + channel: typing.Optional[int] = None, + release_ms: float = 30.0, + latch: bool = False, + input_device: subsequence.midi_utils.DeviceId = None, + ) -> None: + """Track notes held on a MIDI keyboard for live arpeggiation. + + Incoming note-on/note-off messages build a live "currently held" set + that any pattern reads via ``p.held_notes()`` — typically fed straight + to ``p.arpeggio()``. The composition still authors the rhythm and + motion; the player's hands supply the pitch set. This is a live + *performance* layer over the deterministic, seeded composition: when + rendering headlessly there is no input, so ``p.held_notes()`` is empty + and seeded output is unchanged. + + **Requires** ``midi_input()`` to be called first to open an input port. + + Parameters: + channel: If given, only track notes on this channel. Uses the same + numbering convention as ``pattern()`` (1-16 by default, or 0-15 + with ``zero_indexed_channels=True``). ``None`` tracks any + channel (default). + release_ms: How long (milliseconds) a released note keeps counting + as held. This smooths the momentary all-keys-up gap during a + hand-position change so the arp does not drop to silence. + Default 30.0; set 0.0 to release instantly. Ignored when + ``latch`` is True. + latch: When True, the held set persists after you lift your hands + until you play a new chord (the first key after every key is up + replaces it) — like a hardware arp's latch. + input_device: Only track notes from this input device (index or + name). ``None`` tracks any input device (default). + + Example: + ```python + comp.midi_input("Arturia KeyStep") + comp.note_input(channel=1, release_ms=30) + + @comp.pattern(channel=6, beats=4) + def arp (p): + p.arpeggio(p.held_notes(), direction="up") # rests when silent + ``` + """ + + if self._note_input is not None: + raise RuntimeError( + "only one note_input source is supported — named multi-source is not yet available" + ) + + resolved_channel = ( + self._resolve_channel(channel) if channel is not None else None + ) + + self._note_input = { + "channel": resolved_channel, + "release_ms": release_ms, + "latch": latch, + "input_device": input_device, # resolved to int index in _run() + } + + @staticmethod + def _make_cc_forward_transform( + output: typing.Union[str, typing.Callable], + cc: int, + output_channel: typing.Optional[int], + ) -> typing.Callable: + """Build a transform callable from a preset string or user-supplied callable. + + The returned callable has signature ``(value: int, channel: int) -> Optional[mido.Message]`` + where ``channel`` is the 0-indexed incoming channel. + """ + + import mido as _mido + + def _out_ch(incoming: int) -> int: + return output_channel if output_channel is not None else incoming + + if callable(output): + if output_channel is None: + return output + + def _wrapped(value: int, channel: int) -> typing.Optional[typing.Any]: + msg = output(value, channel) + + if msg is None: + return None + + # copy() re-channels without rebuilding: reconstructing from + # __dict__ passed 'type' twice and raised TypeError on every + # message, so callable+output_channel never forwarded anything. + return msg.copy(channel=output_channel) + + return _wrapped + + if output == "cc": + + def _cc_identity(value: int, channel: int) -> typing.Any: + return _mido.Message( + "control_change", channel=_out_ch(channel), control=cc, value=value + ) + + return _cc_identity + + if output.startswith("cc:"): + try: + target_cc = int(output[3:]) + except ValueError: + raise ValueError( + f"cc_forward(): invalid preset '{output}' — expected 'cc:N' where N is 0–127" + ) + if not 0 <= target_cc <= 127: + raise ValueError( + f"cc_forward(): CC number {target_cc} out of range 0–127" + ) + + def _cc_remap(value: int, channel: int) -> typing.Any: + return _mido.Message( + "control_change", + channel=_out_ch(channel), + control=target_cc, + value=value, + ) + + return _cc_remap + + if output == "pitchwheel": + + def _pitchwheel(value: int, channel: int) -> typing.Any: + pitch = int(value / 127 * 16383) - 8192 + return _mido.Message( + "pitchwheel", channel=_out_ch(channel), pitch=pitch + ) + + return _pitchwheel + + raise ValueError( + f"cc_forward(): unknown preset '{output}'. " + "Use 'cc', 'cc:N' (e.g. 'cc:74'), 'pitchwheel', or a callable." + ) + + def cc_forward( + self, + cc: int, + output: typing.Union[str, typing.Callable], + *, + channel: typing.Optional[int] = None, + output_channel: typing.Optional[int] = None, + mode: str = "instant", + input_device: subsequence.midi_utils.DeviceId = None, + output_device: subsequence.midi_utils.DeviceId = None, + ) -> None: + """ + Forward an incoming MIDI CC to the MIDI output in real-time. + + Unlike ``cc_map()`` which writes incoming CC values to ``composition.data`` + for use at pattern rebuild time, ``cc_forward()`` routes the signal + directly to the MIDI output — bypassing the pattern cycle entirely. + + Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC + number; they operate independently. + + Parameters: + cc: Incoming CC number to listen for (0–127). + output: What to send. Either a **preset string**: + + - ``"cc"`` — identity forward, same CC number and value. + - ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``). + - ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend. + + Or a **callable** with signature + ``(value: int, channel: int) -> Optional[mido.Message]``. + Return a fully formed ``mido.Message`` to send, or ``None`` to suppress. + ``channel`` is 0-indexed (the incoming channel). + channel: If given, only respond to CC messages on this channel. + Uses the same numbering convention as ``cc_map()``. + ``None`` matches any channel (default). + output_channel: Override the output channel. ``None`` uses the + incoming channel. Uses the same numbering convention as ``pattern()``. + input_device: Only respond to CC from this input device — an index, + a registered name, or ``None`` for any input (default), the + same convention as ``cc_map()``. + output_device: Send to this output device — an index, a registered + name, or ``None`` for the primary output (default). + mode: Dispatch mode: + + - ``"instant"`` *(default)* — send immediately on the MIDI input + callback thread. Lowest latency (~1–5 ms). Instant forwards are + **not** recorded when recording is enabled. + - ``"queued"`` — inject into the sequencer event queue and send at + the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards + **are** recorded when recording is enabled. + + Example: + ```python + comp.midi_input("Arturia KeyStep") + + # CC 1 → CC 1 (identity, instant) + comp.cc_forward(1, "cc") + + # CC 1 → pitch bend on channel 1, queued (recordable) + comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued") + + # CC 1 → CC 74, custom channel + comp.cc_forward(1, "cc:74", output_channel=2) + + # Custom transform — remap CC range 0–127 to CC 74 range 40–100 + import subsequence.midi as midi + comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)) + + # Forward AND map to data simultaneously — both active on the same CC + comp.cc_map(1, "mod_wheel") + comp.cc_forward(1, "cc:74") + ``` + """ + + if not 0 <= cc <= 127: + raise ValueError(f"cc_forward(): cc {cc} out of range 0–127") + + if mode not in ("instant", "queued"): + raise ValueError( + f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'" + ) + + resolved_in_channel = ( + self._resolve_channel(channel) if channel is not None else None + ) + resolved_out_channel = ( + self._resolve_channel(output_channel) + if output_channel is not None + else None + ) + + transform = self._make_cc_forward_transform(output, cc, resolved_out_channel) + + self._cc_forwards.append( + { + "cc": cc, + "channel": resolved_in_channel, + "output_channel": resolved_out_channel, + "mode": mode, + "transform": transform, + "input_device": input_device, # resolved to int index in _run() + "output_device": output_device, # resolved to int index in _run() + } + ) + + def live(self, port: int = 5555) -> None: + """ + Enable the live coding eval server. + + This allows you to connect to a running composition using the + ``subsequence.live_client`` REPL and hot-swap pattern code or + modify variables in real-time. + + Security: + The server executes arbitrary Python in this process — it is **not** a + sandbox. It binds to localhost only and is opt-in, but any process on + the same machine that can reach the port gains full code execution here. + Do not enable it on shared or multi-user hosts, and never expose the + port to a network. + + Parameters: + port: The TCP port to listen on (default 5555). + """ + + self._live_server = subsequence.live_server.LiveServer(self, port=port) + self._is_live = True + + def watch( + self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25 + ) -> None: + """Watch a Python file and reload it into the composition on every save. + + The watched file is exec'd into a namespace with ``composition`` and + ``subsequence`` available. ``@composition.pattern`` decorators inside + the file hot-swap their corresponding running patterns in place; + patterns whose function bodies have been deleted from the file are + unregistered automatically on the next reload (notes stopped, + removed from the running-pattern set). + + An **initial synchronous load** happens here — if the file has a + ``SyntaxError`` or doesn't exist at this moment, the exception + propagates so the user knows immediately. Subsequent reloads + happen on the composition's event loop and tolerate transient + errors (logged, skipped). + + Call BEFORE ``composition.play()``. Reloads happen on the + composition's event loop, so all mutations are thread-safe. + + See the "Live coding via file watching" section of the README for + the recommended wrapper-script + live-file split. + + Parameters: + path: Path to the Python file to watch. + poll_interval: Seconds between ``mtime`` polls (default 0.25 s). + + Example:: + + # live_init.py — runs once + composition = subsequence.Composition(bpm=120, key="E") + composition.harmony(style="aeolian_minor") + composition.watch("live_patterns.py") + composition.play() + """ + + # Required for the decorator hot-swap path to fire on re-decoration. + self._is_live = True + + # Detect the single-file workflow: if watch() is called from inside + # the very file being watched, the outer Python script execution will + # already register the patterns (the decorators sit at module level + # below ``watch(__file__)``). In that case, _load_initial's re-exec + # would double-register every pattern, so skip it. For the two-file + # workflow (path != caller's __file__) the initial exec is essential + # — it's the only way the watched file's patterns ever reach the + # composition. + caller_file = self._caller_module_file() + self_watch = False + if caller_file is not None: + try: + self_watch = ( + pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve() + ) + except OSError: + self_watch = False + + self._live_reloader = subsequence.live_reloader.LiveReloader( + composition=self, + path=path, + poll_interval=poll_interval, + skip_initial_exec=self_watch, + ) + self._live_reloader.start() + + @staticmethod + def _caller_module_file() -> typing.Optional[str]: + """Return ``__file__`` of the module that invoked the caller, if available. + + Walks one frame up the call stack — the immediate caller is + ``watch()``, so ``f_back`` is the user's code. Returns the + module-level ``__file__`` of that frame's globals; ``None`` when + the caller has no ``__file__`` (REPL, exec'd context, etc.). + """ + + frame = inspect.currentframe() + if frame is None or frame.f_back is None or frame.f_back.f_back is None: + return None + # f_back = watch(); f_back.f_back = user code calling watch(). + return frame.f_back.f_back.f_globals.get("__file__") + + def load_patterns( + self, + source: str, + source_label: str = "", + ) -> None: + """Compile and apply a pattern-source string to the composition. + + Equivalent to one ``watch()`` reload triggered by save, but with the + source presented in-memory rather than on disk. Useful for web / + REST handlers that accept pattern uploads from a trusted contributor, + or for one-shot session loads with no file backing. + + Behaviour mirrors ``watch()``: + + * The source is exec'd into a fresh namespace with ``composition`` + and ``subsequence`` in scope. + * ``@composition.pattern`` decorators in the source hot-swap their + corresponding running patterns in place. + * Patterns currently running but **not** declared in the source are + unregistered — the source is treated as the full new truth. + * If the composition is already playing, the swap happens on the + event loop thread; the call blocks until it completes. + * If the composition has not yet called ``play()``, the source runs + on the caller's thread; decorators populate ``_pending_patterns`` + and ``play()`` picks them up in the usual way. + + Errors are raised so the caller can act on them: + + * ``SyntaxError`` if ``source`` fails to compile. + * The exception raised inside ``exec()`` for any runtime error. + * ``RuntimeError`` if called from inside the composition's own + event loop thread (would deadlock — see Threading below). + + In either failure case, existing composition state is preserved — + the diff-and-unregister phase is skipped if exec raised, so a + half-broken upload cannot tear down working patterns. + + Threading: + Designed to be called from a thread DIFFERENT from the + composition's event loop — typically a web-handler worker. + Cannot be called from inside the loop itself (a pattern + callback, an asyncio task spawned by the composition). From + there, ``await composition._apply_source_async(...)`` directly. + + SECURITY WARNING: ``exec()`` is not sandboxed. The source has full + Python access in this process. Only pass source from trusted + senders. The built-in blocklist (``help``, ``input``, ``breakpoint``, + ``exit``, ``quit``) prevents calls that would stall the event loop; + it is not a security boundary. + + Parameters: + source: Python source declaring ``@composition.pattern`` + functions. + source_label: Identifier used in compile errors and tracebacks + (appears as the filename in ``SyntaxError`` and ``__file__``- + style traceback lines). Default ``""``. + """ + + # Required for the decorator hot-swap path to fire on re-decoration. + self._is_live = True + + # Compile on the caller's thread so SyntaxError comes back fast, + # before any cross-thread scheduling. + compiled = compile(source, source_label, "exec") + namespace = self._build_live_namespace(source_label=source_label) + + loop = self._sequencer._event_loop + + if loop is not None and loop.is_running(): + # Refuse to deadlock: calling load_patterns() from inside the + # composition's own event loop (e.g. from a pattern callback or + # an asyncio task spawned by the composition) would have us + # block waiting for a coroutine that can only run when this + # thread yields. Tell the caller exactly what to do instead. + try: + current_loop: typing.Optional[asyncio.AbstractEventLoop] = ( + asyncio.get_running_loop() + ) + except RuntimeError: + current_loop = None + + if current_loop is loop: + raise RuntimeError( + "load_patterns() cannot be called from inside the composition's " + "event loop thread — it would deadlock waiting for the " + "scheduled coroutine to run on the very thread that's blocked. " + "From a worker thread, call it normally. From an async " + "coroutine already on the loop, " + "`await composition._apply_source_async(compile(source, label, 'exec'), " + "composition._build_live_namespace())` instead." + ) + + # Composition is playing — mutation must happen on the loop thread. + # future.result() blocks the caller until the coroutine finishes + # and re-raises any exception it threw. + future = asyncio.run_coroutine_threadsafe( + self._apply_source_async(compiled, namespace, source_key=source_label), + loop=loop, + ) + future.result() + + else: + # Pre-play: no event loop yet. Decorators populate + # _pending_patterns; play() graduates them in the usual way. + # Diff-and-unregister is unnecessary here — nothing is running, + # but RECORD what this source declares so a later post-play + # reload under the same label can tear down its deletions. + self._declared_names = set() + exec(compiled, namespace) + self._source_declared[source_label] = set(self._declared_names) + + async def _apply_source_async( + self, + compiled: types.CodeType, + namespace: typing.Dict[str, typing.Any], + source_key: str = "", + ) -> None: + """Execute pre-compiled live source against the running composition. + + Runs on the event loop thread. Performs ``exec()``, graduates any + newly-decorated patterns into ``_running_patterns``, then unregisters + any patterns that *this source* declared on its previous exec but no + longer declares (keyed by ``source_key`` — a watched file's path or a + ``load_patterns`` label). Patterns registered by the wrapper script + or by another source are never this source's to tear down. + + Raises whatever ``exec()`` raises. When that happens, the diff-and- + unregister phase is skipped — the namespace is incomplete, so any + patterns the source failed to reach would be misinterpreted as + deletions and torn down. + + Called from two places: + + * ``Composition.load_patterns()`` via ``run_coroutine_threadsafe``. + * ``LiveReloader._reload_async`` directly (already on the loop). + """ + + # Track which patterns the source declares this exec. pattern() and + # layer() add their (resolved) names to _declared_names as they run, so + # this covers decorated patterns AND layer()/merged patterns — the latter + # have no module-level callable to match against by name, which is why the + # old namespace-based diff tore layers down on every reload. + self._declared_names = set() + + # Bail before any state mutation if exec raises — propagates to + # the caller (load_patterns re-raises; LiveReloader catches + logs). + exec(compiled, namespace) + + # Graduate newly-decorated patterns from _pending_patterns into + # _running_patterns so they start firing on the next reschedule. + # Patterns that hot-swapped via the decorator/layer path don't appear + # in _pending_patterns and don't need this step. + await self._activate_new_pending_patterns() + + # Detect deletions: a name THIS source declared last time but not this + # time has been removed by the user and should be torn down. (An + # unknown source — first exec — tears down nothing: patterns running + # from the wrapper script or another source are not ours to remove.) + # Decorators/layer() do NOT remove from _running_patterns when a + # definition disappears from the source. + previous = self._source_declared.get(source_key) + + if previous is not None: + for name in previous - self._declared_names: + if name in self._running_patterns: + self.unregister(name) + + self._source_declared[source_key] = set(self._declared_names) + + def _build_live_namespace( + self, source_label: str = "" + ) -> typing.Dict[str, typing.Any]: + """Build a fresh namespace dict for exec'ing live source. + + Provides ``composition`` (this Composition), ``subsequence`` (the + package), and a safe builtins set with ``help``, ``input``, + ``breakpoint``, ``exit``, ``quit`` blocked. + + Also injects two dunder globals that make the single-file live-coding + workflow ergonomic: + + * ``__name__ = "__live_reload__"`` — so ``if __name__ == "__main__":`` + blocks in the watched file are *skipped* during live reload. The + same file run directly with ``python my_session.py`` sees + ``__name__ == "__main__"`` and runs setup; saves trigger reload + with ``__name__ == "__live_reload__"``, skipping setup and only + re-running pattern definitions. + * ``__file__ = source_label`` — so ``composition.watch(__file__)`` + and any user code referencing ``__file__`` works inside the live + namespace. Set to the file path for ``LiveReloader``, the + user-supplied ``source_label`` for ``Composition.load_patterns``, + and ``""`` for ``LiveServer``. + + Single source of truth: ``live_reloader`` (file watching), + ``live_server`` (TCP REPL), and ``load_patterns`` (string source) + all call this so live source sees the same environment from any + entry point. + + The blocklist prevents calls that would stall the async event loop + running the sequencer. It is **not** a security sandbox — exec'd + code can still do anything Python allows. + + Parameters: + source_label: Value to bind to ``__file__`` in the namespace. + Defaults to ``""``. + """ + + import subsequence # local import: this module is imported during subsequence init + + safe_builtins = {name: getattr(builtins, name) for name in dir(builtins)} + + blocked = {"help", "input", "breakpoint", "exit", "quit"} + + for name in blocked: + safe_builtins[name] = _live_blocked(name) + + return { + "__builtins__": safe_builtins, + "__name__": "__live_reload__", + "__file__": source_label, + "composition": self, + "subsequence": subsequence, + } + + def osc( + self, + receive_port: int = 9000, + send_port: int = 9001, + send_host: str = "127.0.0.1", + receive_host: str = "0.0.0.0", + ) -> None: + """ + Enable bi-directional Open Sound Control (OSC). + + Subsequence will listen for commands (like ``/bpm`` or ``/mute``) and + broadcast its internal state (like ``/chord`` or ``/bar``) over UDP. + + Parameters: + receive_port: Port to listen for incoming OSC messages (default 9000). + send_port: Port to send state updates to (default 9001). + send_host: The IP address to send updates to (default "127.0.0.1"). + receive_host: Interface to listen on (default "0.0.0.0" — all + interfaces, so external OSC controllers on the LAN can reach it). + The listener can change tempo, mute patterns, and write data, so on + an untrusted network restrict it with ``receive_host="127.0.0.1"``. + """ + + self._osc_server = subsequence.osc.OscServer( + self, + receive_port=receive_port, + send_port=send_port, + send_host=send_host, + receive_host=receive_host, + ) + + def osc_map(self, address: str, handler: typing.Callable) -> None: + """ + Register a custom OSC handler. + + Must be called after :meth:`osc` has been configured. + + Parameters: + address: OSC address pattern to match (e.g. ``"/my/param"``). + handler: Callable invoked with ``(address, *args)`` when a + matching message arrives. + + Example:: + + composition.osc() + + def on_intensity (address, value): + composition.data["intensity"] = float(value) + + composition.osc_map("/intensity", on_intensity) + """ + + if self._osc_server is None: + raise RuntimeError("Call composition.osc() before composition.osc_map()") + + self._osc_server.map(address, handler) + + def set_bpm(self, bpm: float) -> None: + """ + Instantly change the tempo. + + Parameters: + bpm: The new tempo in beats per minute. + + When Ableton Link is active, this proposes the new tempo to the Link + network instead of applying it locally. The network-authoritative tempo + is picked up on the next pulse. + """ + + self._sequencer.set_bpm(bpm) + + if not self.is_clock_following and self._link_quantum is None: + self.bpm = bpm + + def target_bpm(self, bpm: float, bars: int, shape: str = "linear") -> None: + """ + Smoothly ramp the tempo to a target value over a number of bars. + + Parameters: + bpm: Target tempo in beats per minute. + bars: Duration of the transition in bars. + shape: Easing curve name. Defaults to ``"linear"``. + ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- + sounding tempo changes. See :mod:`subsequence.easing` for all + available shapes. + + Example: + ```python + # Accelerate to 140 BPM over the next 8 bars with a smooth S-curve + comp.target_bpm(140, bars=8, shape="ease_in_out") + ``` + + Note: + Ignored while Ableton Link is active — the shared session tempo is + authoritative. Use ``set_bpm()`` to propose a tempo to the Link network. + """ + + self._sequencer.set_target_bpm(bpm, bars, shape) + + def live_info(self) -> typing.Dict[str, typing.Any]: + """ + Return a dictionary containing the current state of the composition. + + Includes BPM, key, current bar, active section, current chord, + running patterns, and custom data. + """ + + section_info = None + if self._form_state is not None: + section = self._form_state.get_section_info() + if section is not None: + section_info = { + "name": section.name, + "bar": section.bar, + "bars": section.bars, + "progress": section.progress, + } + + chord_name = None + sounding_chord = self.current_chord() + if sounding_chord is not None: + chord_name = sounding_chord.name() + + pattern_list = [] + channel_offset = 0 if self._zero_indexed_channels else 1 + for name, pat in self._running_patterns.items(): + pattern_list.append( + { + "name": name, + "channel": pat.channel + channel_offset, + "length": pat.length, + "cycle": pat._cycle_count, + "muted": pat._muted, + "tweaks": dict(pat._tweaks), + } + ) + + return { + "bpm": self._sequencer.current_bpm, + "key": self.key, + "bar": self._builder_bar, + "section": section_info, + "chord": chord_name, + "patterns": pattern_list, + "input_device": self._input_device, + "clock_follow": self.is_clock_following, + "data": self.data, + } + + def mute(self, name: str) -> None: + """ + Mute a running pattern by name. + + The pattern continues to 'run' and increment its cycle count in + the background, but it will not produce any MIDI notes until unmuted. + + Parameters: + name: The function name of the pattern to mute. + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + # The performer takes ownership: if a transition's approach window had + # muted this pattern, drop it from that set so the section boundary + # does not silently unmute it ("performer mutes win"). + self._transition_muted.discard(name) + + self._running_patterns[name]._muted = True + logger.info(f"Muted pattern: {name}") + + def unmute(self, name: str) -> None: + """ + Unmute a previously muted pattern. + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + # Symmetric ownership claim: an explicit unmute means the transition + # machinery should no longer manage this pattern at the boundary. + self._transition_muted.discard(name) + + self._running_patterns[name]._muted = False + logger.info(f"Unmuted pattern: {name}") + + def unregister(self, name: str) -> None: + """Fully remove a running pattern from rotation. + + Unlike ``mute()`` (which keeps the pattern alive but silent), + ``unregister()`` tears the pattern down entirely. It sets + ``pattern._removed = True`` so the sequencer's reschedule loop + skips re-adding it on the next pulse; sends ``note_off`` for any + of the pattern's currently-sounding notes on the primary + destination AND on every mirror destination (so drones and + sustaining notes stop immediately); and removes the entry from + ``_running_patterns`` so it no longer appears in ``live_info()``, + the terminal grid, or any other consumer that enumerates running + patterns. + + Already-queued events in the sequencer's event queue play out — + note_offs are paired with their note_ons at queue time, so notes + end at their natural duration; only drones rely on the targeted + ``_stop_pattern_notes`` pass. + + Idempotent: silently logs a ``debug`` and returns if the pattern + is already absent. Useful from both the live REPL + (``composition.live()``) and the file watcher + (``composition.watch()``), which calls this for any pattern + removed from the watched file between reloads. + + Parameters: + name: Function name of the pattern to remove. + """ + + if name not in self._running_patterns: + logger.debug(f"unregister() no-op: pattern '{name}' not running") + return + + pattern = self._running_patterns[name] + + # Mark for removal first so the reschedule loop sees the flag even if + # it fires concurrently with the note-off pass below. + pattern._removed = True + + # Stop sustaining notes (including drones) on every destination this + # pattern outputs to. Fire-and-forget across threads via the event + # loop; ``_stop_pattern_notes`` acquires the queue lock internally. + if self._sequencer._event_loop is not None: + asyncio.run_coroutine_threadsafe( + self._sequencer._stop_pattern_notes(pattern), + loop=self._sequencer._event_loop, + ) + + def _finalise_removal() -> None: + self._running_patterns.pop(name, None) + + # Forget any pending (not-yet-graduated) declaration too, so a + # later live reload cannot resurrect the pattern. + self._pending_patterns = [ + pending + for pending in self._pending_patterns + if pending.builder_fn.__name__ != name + ] + + logger.info(f"Unregistered pattern: {name}") + + # The running-patterns dict is iterated by the display, web UI, and + # reschedule loop on the event loop thread — mutate it there when this + # call arrives from another thread (e.g. the live TCP server). + loop = self._sequencer._event_loop + + try: + on_loop = loop is not None and asyncio.get_running_loop() is loop + except RuntimeError: + on_loop = False + + if loop is not None and loop.is_running() and not on_loop: + loop.call_soon_threadsafe(_finalise_removal) + else: + _finalise_removal() + + def mirror( + self, + name: str, + device: int, + channel: int, + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + ) -> None: + """ + Add a mirror destination to a running pattern. + + Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone + event the pattern emits will also be sent to ``(device, channel)``, + starting from the next cycle rebuild. Idempotent on ``(device, channel)`` + — calling with the same destination twice does not double-fan; calling + again with a different ``drum_note_map`` re-points it in place. + + Parameters: + name: Function name of the pattern to mirror. + device: Output device index (the integer returned from + ``midi_output()``; 0 = primary device). + channel: MIDI channel using this composition's numbering convention + (1-16 by default; 0-15 if ``zero_indexed_channels=True``). + drum_note_map: Optional per-destination drum map. When set, mirrored + drum hits are re-resolved by name through it, so a named voice + lands on this device's own note number — see the README + "MIDI mirroring" section. + + Bandwidth: each mirror adds another full copy of the pattern's events. + See the README "MIDI mirroring" section for the tradeoffs. + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + resolved_channel = self._resolve_channel(channel) + prefix = (device, resolved_channel) + entry: subsequence.pattern.MirrorSpec = ( + prefix + if drum_note_map is None + else (device, resolved_channel, drum_note_map) + ) + + pattern = self._running_patterns[name] + + # Mirror-to-self check: comparing the (device, channel) prefix against the + # live pattern's resolved destination. Unlike the decorator path this is + # always concrete. + if prefix == (pattern.device, pattern.channel): + logger.warning( + f"Mirror destination {prefix} matches '{name}'s primary destination " + f"— every event will double-fire on this (device, channel). This is almost " + f"certainly unintended." + ) + + # Idempotent on (device, channel): replace any existing entry for the same + # destination (so its map can be re-pointed), else append. + existing_index = next( + (idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), + None, + ) + if existing_index is None: + pattern.mirrors.append(entry) + logger.info( + f"Mirror added: {name} -> device={device}, channel={resolved_channel}" + ) + elif pattern.mirrors[existing_index] != entry: + pattern.mirrors[existing_index] = entry + logger.info( + f"Mirror updated: {name} -> device={device}, channel={resolved_channel}" + ) + else: + logger.debug( + f"Mirror already present on {name}: device={device}, channel={resolved_channel}" + ) + + def unmirror(self, name: str, device: int, channel: int) -> None: + """ + Remove a single mirror destination from a running pattern. + + Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is + ignored. Idempotent: silently does nothing if the destination is not + currently mirrored. The change applies on the next cycle rebuild. + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + resolved_channel = self._resolve_channel(channel) + prefix = (device, resolved_channel) + + pattern = self._running_patterns[name] + + filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] + if len(filtered) != len(pattern.mirrors): + pattern.mirrors[:] = filtered + logger.info( + f"Mirror removed: {name} -> device={device}, channel={resolved_channel}" + ) + else: + logger.debug( + f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors" + ) + + def unmirror_all(self, name: str) -> None: + """ + Remove every mirror destination from a running pattern. + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + pattern = self._running_patterns[name] + + if pattern.mirrors: + pattern.mirrors.clear() + logger.info(f"All mirrors cleared on pattern: {name}") + + def tweak(self, name: str, **kwargs: typing.Any) -> None: + """Override parameters for a running pattern. + + Values set here are available inside the pattern's builder + function via ``p.param()``. They persist across rebuilds + until explicitly changed or cleared. Changes take effect + on the next rebuild cycle. + + Parameters: + name: The function name of the pattern. + ``**kwargs``: Parameter names and their new values. + + Example (from the live REPL):: + + composition.tweak("bass", pitches=[48, 52, 55, 60]) + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + self._running_patterns[name]._tweaks.update(kwargs) + logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}") + + def clear_tweak(self, name: str, *param_names: str) -> None: + """Remove tweaked parameters from a running pattern. + + If no parameter names are given, all tweaks for the pattern + are cleared and every ``p.param()`` call reverts to its + default. + + Parameters: + name: The function name of the pattern. + *param_names: Specific parameter names to clear. If + omitted, all tweaks are removed. + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + if not param_names: + self._running_patterns[name]._tweaks.clear() + logger.info(f"Cleared all tweaks for pattern '{name}'") + else: + for param_name in param_names: + self._running_patterns[name]._tweaks.pop(param_name, None) + logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}") + + def get_tweaks(self, name: str) -> typing.Dict[str, typing.Any]: + """Return a copy of the current tweaks for a running pattern. + + Parameters: + name: The function name of the pattern. + """ + + if name not in self._running_patterns: + raise ValueError( + f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}" + ) + + return dict(self._running_patterns[name]._tweaks) + + def schedule( + self, + fn: typing.Callable, + cycle_beats: int, + reschedule_lookahead: int = 1, + wait_for_initial: bool = False, + defer: bool = False, + ) -> None: + """ + Register a custom function to run on a repeating beat-based cycle. + + Subsequence automatically runs synchronous functions in a thread pool + so they don't block the timing-critical MIDI clock. Async functions + are run directly on the event loop. + + Parameters: + fn: The function to call. + cycle_beats: How often to call it (e.g., 4 = every bar). + reschedule_lookahead: How far in advance to schedule the next call. + wait_for_initial: If True, run the function once during startup + and wait for it to complete before playback begins. This + ensures ``composition.data`` is populated before patterns + first build. Implies ``defer=True`` for the repeating + schedule. + defer: If True, skip the pulse-0 fire and defer the first + repeating call to just before the second cycle boundary. + + Raises: + RuntimeError: If called after ``play()`` has started — scheduled + tasks register at startup, so a late registration would be + silently ignored otherwise. + """ + + if self._sequencer.running: + raise RuntimeError( + "schedule() must be called before play() - scheduled tasks register at startup" + ) + + self._pending_scheduled.append( + _PendingScheduled( + fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer + ) + ) + + def form( + self, + sections: typing.Union[ + "subsequence.forms.Form", + typing.List[typing.Any], + typing.Iterator[typing.Tuple[str, int]], + typing.Dict[ + str, + typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]], + ], + ], + loop: bool = False, + start: typing.Optional[str] = None, + at_end: str = "stop", + key: typing.Optional[str] = None, + scale: typing.Optional[str] = None, + ) -> None: + """ + Define the structure (sections) of the composition. + + You can define form in four ways: + + 1. **Form value**: a frozen :class:`~subsequence.forms.Form` of + :class:`~subsequence.forms.Section` values — the payload home + (energy, key per section); editable, navigable. + 2. **Sequence (List)**: a fixed order of ``(name, bars)`` tuples + or Sections (lists coerce — they are the same form). + 3. **Graph (Dict)**: dynamic transitions based on weights. + 4. **Generator**: a Python generator that yields ``(name, bars)`` pairs. + + Form-value and list forms are **navigable**: ``form_jump()`` and + ``form_next()`` work on them (the jump lands on the next occurrence + of the name, wrapping). + + Re-binding ``form()`` during playback takes effect at the next bar — + the clock reads the current form state on every bar, so the new form + advances from there (its first section plays from its first bar). + + Parameters: + sections: The form definition (Form, List, Dict, or Generator). + loop: Sugar for ``at_end="loop"``. + start: The section to start with (Graph mode only). + at_end: What happens when a sequence form runs out — + ``"stop"`` (the form finishes and patterns see no section; + default), ``"hold"`` (the final section repeats until + navigated away from), or ``"loop"`` (start over). Graphs + end via their terminal sections instead. + key: A form-level key — the **form tier** of the key-source + chain (``Section.key`` overrides it; it overrides the + composition key). Re-anchors key-relative content for the + whole form. When *sections* is a ``Form`` value carrying its + own ``key``, that value is used unless this argument overrides. + scale: A form-level scale/mode, paired with ``key``. + + Example: + ```python + # A simple pop structure + comp.form([ + ("verse", 8), + ("chorus", 8), + ("verse", 8), + ("chorus", 16) + ]) + + # The same structure with payloads, held open at the end + S = subsequence.Section + comp.form(subsequence.Form([ + S("verse", 8, energy=0.5), S("chorus", 8, energy=0.9), + ]), at_end="hold") + ``` + """ + + # Seed FormState at form() time (per-call salt) so build-time walks — + # the frozen clones form_freeze will take — are deterministic without + # play(); the play-time stream is re-dealt name-keyed in _run(). + self._form_count += 1 + + self._form_state = subsequence.form_state.FormState( + sections, + loop=loop, + start=start, + rng=self._stream(f"form:{self._form_count}"), + at_end=at_end, + ) + + # A Form value carries energy payloads — that counts as an energy + # source for the min_energy registration check in _run(). + self._form_has_payload = isinstance(sections, subsequence.forms.Form) or ( + isinstance(sections, list) + and any( + isinstance(element, subsequence.forms.Section) for element in sections + ) + ) + + # Form-tier key/scale: an explicit argument wins; otherwise a Form + # value's own key/scale seeds the tier. Re-binding the form drops any + # stale per-section resolution cache. + if isinstance(sections, subsequence.forms.Form): + self._form_key = key if key is not None else sections.key + self._form_scale = scale if scale is not None else sections.scale + else: + self._form_key = key + self._form_scale = scale + + self._resolved_section_cache = {} + + def form_freeze( + self, sections: typing.Optional[int] = None + ) -> "subsequence.forms.Form": + """Freeze the graph form's walk into an editable :class:`~subsequence.forms.Form`. + + Walks a **clone** of the live form state — the same RNG state, so the + frozen path is exactly the path the live graph would have played — + and returns it as a Form value: inspect it, edit it + (``path.replace(3, bars=16)``), and rebind it with + ``composition.form(path, at_end=...)``. The live form state is + untouched (rebinding replaces it). + + Parameters: + sections: Number of sections to freeze. Without it, the walk + runs until a terminal section; a graph with no terminal + sections requires ``sections=`` explicitly. + + Raises: + ValueError: If no graph form is bound (a list form is already a + frozen sequence), the form has already finished, or the walk + cannot terminate. + + Example:: + + composition.form({...}, start="intro") + path = composition.form_freeze() # the walk, frozen + composition.form(path, at_end="stop") # rebind the editable value + """ + + fs = self._form_state + + if fs is None or fs._graph is None or fs._section_bars is None: + raise ValueError( + "form_freeze() freezes a graph form's walk — call form() with a dict first " + "(a list form is already a frozen sequence)" + ) + + if fs._current is None: + raise ValueError("the form has already finished — nothing left to freeze") + + if sections is not None and sections < 1: + raise ValueError("sections must be at least 1") + + if sections is None and not fs._terminal_sections: + raise ValueError( + "this graph has no terminal section, so the walk would never end — " + "pass sections=n to bound it" + ) + + # Clone the RNG state: the frozen walk reproduces the live form's + # future draws without consuming them. + rng = random.Random() + rng.setstate(fs._rng.getstate()) + + walked = [fs._current] + next_name = fs._next_section_name # already decided by the live state + + while next_name is not None: + if sections is not None and len(walked) >= sections: + break + if sections is None and len(walked) >= 10000: + raise ValueError( + "form_freeze() walked 10000 sections without reaching a terminal — " + "the terminals look unreachable; pass sections=n to bound the walk" + ) + + walked.append( + subsequence.forms.Section( + name=next_name, bars=fs._section_bars[next_name] + ) + ) + next_name = ( + None + if next_name in fs._terminal_sections + else fs._graph.choose_next(next_name, rng) + ) + + # Carry the form-tier key/scale onto the frozen value so a freeze → + # rebind round-trip is lossless (an explicit form(key=) on rebind + # still overrides). + return subsequence.forms.Form( + walked, key=self._form_key, scale=self._form_scale + ) + + def energy( + self, + energies: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]], + ) -> None: + """Set per-section energy — the arranging dial, as one plain dict. + + ``{"verse": 0.5, "chorus": 0.9, "build": (0.3, 1.0)}`` — a float is + the section's level; a ``(start, end)`` tuple interpolates across the + section (a build). Patterns read ``p.energy`` (0.5 when nothing is + configured) and gate themselves, or declare ``min_energy=`` on + ``pattern()`` for automatic muting. + + The dict **overrides** any energy payload carried by bound + :class:`~subsequence.forms.Section` values — it is the later, + performance-level dial. Re-calling replaces the whole mapping + (idempotent, live-reload friendly). + + Example:: + + composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95}) + """ + + validated: typing.Dict[ + str, typing.Union[float, typing.Tuple[float, float]] + ] = {} + + for name, value in energies.items(): + if isinstance(value, tuple): + if len(value) != 2: + raise ValueError( + f"energy ramp for {name!r} must be (start, end), got {value!r}" + ) + start_level, end_level = float(value[0]), float(value[1]) + for level in (start_level, end_level): + if not 0.0 <= level <= 1.0: + raise ValueError( + f"energy for {name!r} must be 0.0–1.0, got {value!r}" + ) + validated[name] = (start_level, end_level) + else: + level = float(value) + if not 0.0 <= level <= 1.0: + raise ValueError( + f"energy for {name!r} must be 0.0–1.0, got {value!r}" + ) + validated[name] = level + + self._energy_map = validated + + def _current_energy( + self, info: typing.Optional[subsequence.form_state.SectionInfo] + ) -> float: + """Resolve the energy for a section snapshot. + + Priority: the ``energy()`` dict (ramps interpolate by section + progress) > the bound Section payload > 0.5. + """ + + if info is None: + return 0.5 + + spec = self._energy_map.get(info.name) + + if spec is None: + return info.energy + + if isinstance(spec, tuple): + start_level, end_level = spec + + # A build reaches its declared end ON the final bar, so the ramp spans + # bar 0 → bar (bars-1). (info.progress is bar/bars, which would top + # out one bar short and never deliver end.) A one-bar section sits at + # the destination level. + span = info.bars - 1 + fraction = info.bar / span if span > 0 else 1.0 + + return start_level + (end_level - start_level) * fraction + + return spec + + def on_section(self, callback: typing.Callable[..., typing.Any]) -> None: + """Register a callback fired on every section change. + + The callback receives the new :class:`~subsequence.form_state.SectionInfo` + (or ``None`` when the form finishes). It fires from the form clock, + one lookahead-beat **early** — in time to affect the new section's + first patterns — and once at play start for the opening section. + + Example:: + + composition.on_section(lambda info: print(f"now: {info.name if info else 'end'}")) + """ + + self.on_event("section", callback) + + def transition( + self, + before: str, + fill: typing.Optional[typing.Any] = None, + channel: typing.Optional[int] = None, + beat: float = 0.0, + mute: typing.Optional[typing.List[str]] = None, + beats: typing.Optional[float] = None, + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + device: subsequence.midi_utils.DeviceId = None, + ) -> None: + """Declare boundary material — the automatic fill or mute, one line. + + ``before`` names the incoming section (``"chorus"``), or ``"*"`` for + any *different* section (repeats don't fire it). Two actions, + combinable: + + - ``fill=`` (+ ``channel=``, ``beat=``): a Motif played in the last + bar before the boundary, starting at ``beat`` of that bar. Drum + names resolve through ``drum_note_map=`` if given, otherwise the + map is borrowed from a registered pattern on the same channel. + - ``mute=`` (+ ``beats=``): pattern names muted over the approach + and unmuted at the boundary. Muting is **bar-granular** (the + existing rule), so ``beats`` rounds up to whole bars. Performer + mutes win: a pattern you muted yourself stays muted. + + Transitions stack — call once per rule. Registration is additive + and idempotent per identical rule. + + Example:: + + composition.transition(before="*", fill=FILL, channel=10, beat=2.0) + composition.transition(before="drop", mute=["pads"], beats=4) + """ + + if fill is None and mute is None: + raise ValueError( + "transition() needs fill= and/or mute= — it declares what happens at the boundary" + ) + + if fill is not None: + if channel is None: + raise ValueError( + "transition(fill=) needs channel= — the fill must land somewhere" + ) + if not hasattr(fill, "events") or not hasattr(fill, "length"): + raise TypeError( + f"fill must be a Motif-like value with .events/.length, got {type(fill).__name__}" + ) + + if mute is not None and beats is None: + beats = float(self.time_signature[0]) # one bar by default + + rule = _Transition( + before=before, + fill=fill, + channel=self._resolve_channel(channel) if channel is not None else None, + beat=float(beat), + mute=list(mute) if mute is not None else None, + beats=beats, + drum_note_map=drum_note_map, + device=device, # resolved at fire time — names aren't known until play() + ) + + if rule not in self._transitions: + self._transitions.append(rule) + + def _transition_drum_map( + self, channel: typing.Optional[int] + ) -> typing.Optional[typing.Dict[str, int]]: + """Borrow a drum map from a registered pattern on the same channel.""" + + if channel is None: + return None + + for pending in self._pending_patterns: + if pending.channel == channel and pending.drum_note_map: + return pending.drum_note_map + + for running in self._running_patterns.values(): + candidate = getattr(running, "_drum_note_map", None) + if running.channel == channel and candidate: + return typing.cast(typing.Dict[str, int], candidate) + + return None + + def _fire_fill(self, rule: _Transition, start_pulse: int) -> None: + """Build a transition fill as a one-shot pattern and schedule it.""" + + assert rule.fill is not None and rule.channel is not None + + drum_map = ( + rule.drum_note_map + if rule.drum_note_map is not None + else self._transition_drum_map(rule.channel) + ) + + pattern = subsequence.pattern.Pattern( + channel=rule.channel, + length=float(rule.fill.length), + device=self._resolve_device_id(rule.device), + ) + + harmony_view: typing.Optional[HarmonyView] = None + if not self._harmony_horizon.is_empty: + harmony_view = HarmonyView( + self._harmony_horizon, start_pulse / self._sequencer.pulses_per_beat + ) + + # The fill sounds in the outgoing section's final bar, so a degree- + # bearing fill resolves against THAT section's effective key/scale — + # previously it took the composition key, ignoring the section. + fill_section = self._form_state.get_section_info() if self._form_state else None + fill_key, fill_scale = self._effective_key_scale(fill_section) + + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + drum_note_map=drum_map, + section=fill_section, + bar=self._builder_bar, + conductor=self.conductor, + rng=self._stream(f"transition:{rule.before}:{start_pulse}") + or random.Random(), + tweaks={}, + default_grid=16, + data=self.data, + key=fill_key, + scale=fill_scale, + time_signature=self.time_signature, + harmony=harmony_view, + ) + + try: + builder.motif(rule.fill) + except Exception: + logger.exception( + "transition fill failed to build — the boundary plays without it" + ) + return + + self._schedule_one_shot(pattern, start_pulse) + + def _check_transitions(self, boundary_pulse: int, section_changed: bool) -> None: + """The form clock's boundary hook: fire fills, manage approach mutes. + + Called once per bar (lookahead-early, with the bar-line pulse). + Fill rules fire when the current bar is the section's last before a + matching boundary; mute rules close over the approach window + (rounded up to whole bars — muting is bar-granular) and reopen at + the boundary. Performer mutes are never touched. + """ + + if section_changed and self._transition_muted: + # The boundary arrived — restore only what we muted ourselves. + for name in self._transition_muted: + running = self._running_patterns.get(name) + if running is not None: + running._muted = False + self._transition_muted.clear() + + if not self._transitions or self._form_state is None: + return + + info = self._form_state.get_section_info() + + if info is None or info.next_section is None: + return + + bar_beats = float(self.time_signature[0]) + bars_remaining = info.bars - info.bar + + for rule in self._transitions: + if rule.before == "*": + if info.next_section == info.name: + continue # a repeat is not a boundary + elif info.next_section != rule.before: + continue + + if rule.fill is not None and bars_remaining == 1: + self._fire_fill( + rule, + boundary_pulse + + int(round(rule.beat * self._sequencer.pulses_per_beat)), + ) + + if rule.mute: + window_beats = rule.beats if rule.beats is not None else bar_beats + window_bars = max( + 1, int((window_beats + bar_beats - 1e-9) // bar_beats) + ) + + if bars_remaining <= window_bars: + for name in rule.mute: + running = self._running_patterns.get(name) + if running is None or name in self._transition_muted: + continue + if running._muted: + continue # the performer's mute — not ours to manage + running._muted = True + self._transition_muted.add(name) + + @staticmethod + def _resolve_length( + beats: typing.Optional[float], + bars: typing.Optional[float], + steps: typing.Optional[float], + step_duration: typing.Optional[float], + default: float = 4.0, + beats_per_bar: int = 4, + ) -> typing.Tuple[float, int]: + """ + Resolve the beat_length and default_grid from the duration parameters. + + Two modes: + + - **Duration mode** (no ``step_duration``): specify ``beats=`` or ``bars=``. + ``beats=4`` = 4 quarter notes; ``bars=2`` = 8 beats. + - **Step mode** (with ``step_duration``): specify ``steps=`` and ``step_duration=``. + ``steps=6, step_duration=dur.SIXTEENTH`` = 6 sixteenth notes = 1.5 beats. + + Constraints: + + - ``beats`` and ``bars`` are mutually exclusive. + - ``steps`` requires ``step_duration``; ``step_duration`` requires ``steps``. + - ``steps`` cannot be combined with ``beats`` or ``bars``. + + Returns: + (beat_length, default_grid) — beat_length in beats (quarter notes); + default_grid the number of grid steps (16th-notes in beat mode, or the + explicit ``steps`` value directly in step mode). + """ + + if beats is not None and bars is not None: + raise ValueError("Specify only one of beats= or bars=") + + if steps is not None and (beats is not None or bars is not None): + raise ValueError("steps= cannot be combined with beats= or bars=") + + if step_duration is not None and steps is None: + raise ValueError( + "step_duration= requires steps= (e.g. steps=6, step_duration=dur.SIXTEENTH)" + ) + + if steps is not None: + if step_duration is None: + raise ValueError( + "steps= requires step_duration= (e.g. step_duration=dur.SIXTEENTH)" + ) + return steps * step_duration, int(steps) + + if bars is not None: + raw = bars * beats_per_bar + elif beats is not None: + raw = beats + else: + raw = default + + return raw, round(raw / subsequence.constants.durations.SIXTEENTH) + + def pattern( + self, + channel: int, + beats: typing.Optional[float] = None, + bars: typing.Optional[float] = None, + steps: typing.Optional[float] = None, + step_duration: typing.Optional[float] = None, + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + cc_name_map: typing.Optional[typing.Dict[str, int]] = None, + nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, + reschedule_lookahead: float = 1, + voice_leading: bool = False, + device: subsequence.midi_utils.DeviceId = None, + mirrors: typing.Optional[ + typing.Iterable[subsequence.pattern.MirrorSpec] + ] = None, + min_energy: typing.Optional[float] = None, + ) -> typing.Callable: + """ + Register a function as a repeating MIDI pattern. + + The decorated function will be called once per cycle to 'rebuild' its + content. This allows for generative logic that evolves over time. + + Two ways to specify pattern length: + + - **Duration mode** (default): use ``beats=`` or ``bars=``. + The grid defaults to sixteenth-note resolution. + - **Step mode**: use ``steps=`` paired with ``step_duration=``. + The grid equals the step count, so ``p.hit_steps()`` indices map + directly to steps. + + Parameters: + channel: MIDI channel. By default uses 1-based numbering (1-16). + Set ``zero_indexed_channels=True`` on the ``Composition`` to use + 0-based numbering (0-15), matching the raw MIDI protocol, instead. + beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. + bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. + steps: Step count for step mode. Requires ``step_duration=``. + step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). + Requires ``steps=``. + drum_note_map: Optional mapping for drum instruments. + cc_name_map: Optional mapping of CC names to MIDI CC numbers. + Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. + nrpn_name_map: Optional mapping of NRPN parameter names (strings) to + 14-bit parameter numbers (0–16383). Enables string-based names + in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a + device-specific dictionary (e.g. Sequential Take 5's + ``Osc1FreqFine`` → 9). + reschedule_lookahead: Beats in advance to compute the next cycle. + voice_leading: If True, chords in this pattern will automatically + use inversions that minimize voice movement. + mirrors: Optional list of additional ``(device, channel)`` destinations + to duplicate every event from this pattern onto. Notes, CCs, pitch + bend, NRPN/RPN bursts, program changes, SysEx, and drone events are + all mirrored; OSC events are not (OSC is not bound to a MIDI port). + ``device`` is the integer index returned by ``midi_output()`` (0 = + primary). ``channel`` follows this composition's channel-numbering + convention. See also ``mirror()`` / ``unmirror()`` for live toggling. + min_energy: Automatic energy gating — the pattern is silent while + the current section's energy (``composition.energy()`` dict, + or the bound Section payload) is below this threshold. + Composes with ``mute()``: a performer mute always wins. + + Example: + ```python + @comp.pattern(channel=1, beats=4) + def chords (p): + p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) + + @comp.pattern(channel=1, bars=2) + def long_phrase (p): + ... + + @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) + def riff (p): + p.sequence(steps=[0, 1, 3, 5], pitches=60) + ``` + """ + + channel = self._resolve_channel(channel) + + beat_length, default_grid = self._resolve_length( + beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0] + ) + + # Resolve device string name to index if possible now; otherwise store + # the raw DeviceId and resolve it in _run() once all devices are open. + resolved_device: subsequence.midi_utils.DeviceId = device + + # Mirror-to-self check is only reliable when the primary device is a + # concrete integer at decoration time. ``None`` resolves to device 0 + # downstream, so we treat it as 0 here too. Strings are deferred to + # ``_run()`` and we skip the check for them. + primary: typing.Optional[typing.Tuple[int, int]] + if isinstance(resolved_device, str): + primary = None + else: + primary = (resolved_device if resolved_device is not None else 0, channel) + resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) + + def decorator(fn: typing.Callable) -> typing.Callable: + """ + Wrap the builder function and register it as a pending pattern. + During live sessions, hot-swap an existing pattern's builder instead. + """ + + # Record this declaration so the live-reload deletion diff knows the + # pattern is still present in the source (see _apply_source_async). + self._declared_names.add(fn.__name__) + + # Hot-swap: if we're live and a pattern with this name exists, replace its builder. + if self._is_live and fn.__name__ in self._running_patterns: + running = self._running_patterns[fn.__name__] + running._builder_fn = fn + running._wants_chord = _fn_has_parameter(fn, "chord") + logger.info(f"Hot-swapped pattern: {fn.__name__}") + return fn + + # Names key the seeded stream, mutes, tweaks, and reroll/lock — a + # duplicate means two scheduled copies sharing one stream with + # only one reachable by name. Warn loudly at registration. + if any( + existing.builder_fn.__name__ == fn.__name__ + for existing in self._pending_patterns + ): + logger.warning( + f"Duplicate pattern name '{fn.__name__}': both copies will be " + f"scheduled, they share one seeded stream, and only one is " + f"reachable by name — rename one of them." + ) + + pending = _PendingPattern( + builder_fn=fn, + channel=channel, # already resolved to 0-indexed + length=beat_length, + default_grid=default_grid, + drum_note_map=drum_note_map, + cc_name_map=cc_name_map, + nrpn_name_map=nrpn_name_map, + reschedule_lookahead=reschedule_lookahead, + voice_leading=voice_leading, + # For int/None: resolve immediately. For str: store 0 as + # placeholder; _resolve_pending_devices() fixes it in _run(). + device=0 + if (resolved_device is None or isinstance(resolved_device, str)) + else resolved_device, + raw_device=resolved_device, + mirrors=resolved_mirrors, + min_energy=min_energy, + ) + + self._pending_patterns.append(pending) + + return fn + + return decorator + + def layer( + self, + *builder_fns: typing.Callable, + channel: int, + beats: typing.Optional[float] = None, + bars: typing.Optional[float] = None, + steps: typing.Optional[float] = None, + step_duration: typing.Optional[float] = None, + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + cc_name_map: typing.Optional[typing.Dict[str, int]] = None, + nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, + reschedule_lookahead: float = 1, + voice_leading: bool = False, + device: subsequence.midi_utils.DeviceId = None, + mirrors: typing.Optional[ + typing.Iterable[subsequence.pattern.MirrorSpec] + ] = None, + ) -> None: + """ + Combine multiple functions into a single MIDI pattern. + + This is useful for composing complex patterns out of reusable + building blocks (e.g., a 'kick' function and a 'snare' function). + + See ``pattern()`` for the full description of ``beats``, ``bars``, + ``steps``, and ``step_duration``. + + Parameters: + builder_fns: One or more pattern builder functions. + channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). + beats: Duration in beats (quarter notes). + bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). + steps: Step count for step mode. Requires ``step_duration=``. + step_duration: Duration of one step in beats. Requires ``steps=``. + drum_note_map: Optional mapping for drum instruments. + cc_name_map: Optional mapping of CC names to MIDI CC numbers. + nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit + parameter numbers. + reschedule_lookahead: Beats in advance to compute the next cycle. + voice_leading: If True, chords use smooth voice leading. + mirrors: Optional list of additional ``(device, channel)`` destinations + to duplicate every event onto. See ``pattern()`` for details. + """ + + beat_length, default_grid = self._resolve_length( + beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0] + ) + + # Resolve channel up-front so the mirror-to-self check has the canonical + # primary form to compare against. + resolved_channel = self._resolve_channel(channel) + + # See pattern() for the same comment about None / str handling. + primary: typing.Optional[typing.Tuple[int, int]] + if isinstance(device, str): + primary = None + else: + primary = (device if device is not None else 0, resolved_channel) + resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) + + wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) + + if wants_chord: + + def merged_builder( + p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord + ) -> None: + for fn in builder_fns: + if _fn_has_parameter(fn, "chord"): + fn(p, chord) + else: + fn(p) + + else: + + def merged_builder(p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] + for fn in builder_fns: + fn(p) + + # Give the merged builder a stable, unique name derived from its + # components so multiple layer() calls don't all register under + # "merged_builder" and collide in _running_patterns (which made + # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't + # appear in a Python identifier, so this never clashes with a real + # pattern function's name. + base_name = ( + "+".join(fn.__name__ for fn in builder_fns) or "layer" + ) + f"@ch{resolved_channel}" + merged_name = base_name + suffix = 2 + + # Two layers with the same components (e.g. on different saves of a + # live file) must map to the same names pass-over-pass, while two + # DIFFERENT layers sharing components in one pass must not collide. + while merged_name in self._declared_names: + merged_name = f"{base_name}#{suffix}" + suffix += 1 + + merged_builder.__name__ = merged_name + + # Record the declaration for the live-reload deletion diff, and hot-swap + # in place when this layer is already running so a reload picks up edits + # to the component functions without losing the pattern's cycle count, + # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). + self._declared_names.add(merged_builder.__name__) + + if self._is_live and merged_builder.__name__ in self._running_patterns: + running = self._running_patterns[merged_builder.__name__] + running._builder_fn = merged_builder + running._wants_chord = wants_chord + logger.info(f"Hot-swapped layer: {merged_builder.__name__}") + return + + pending = _PendingPattern( + builder_fn=merged_builder, + channel=resolved_channel, # already resolved to 0-indexed above + length=beat_length, + default_grid=default_grid, + drum_note_map=drum_note_map, + cc_name_map=cc_name_map, + nrpn_name_map=nrpn_name_map, + reschedule_lookahead=reschedule_lookahead, + voice_leading=voice_leading, + mirrors=resolved_mirrors, + device=0 if (device is None or isinstance(device, str)) else device, + raw_device=device, + ) + + self._pending_patterns.append(pending) + + def chords( + self, + *, + channel: int, + progression: subsequence.progressions.ProgressionSource, + harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, + bars: typing.Optional[float] = None, + beats: typing.Optional[float] = None, + voicing: subsequence.progressions.VoicingSpec = (3, 4), + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, + detached: typing.Optional[float] = None, + root: int = 60, + key: typing.Optional[str] = None, + seed: typing.Optional[int] = None, + device: subsequence.midi_utils.DeviceId = None, + mirrors: typing.Optional[ + typing.Iterable[subsequence.pattern.MirrorSpec] + ] = None, + ) -> subsequence.progressions.Progression: + """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. + + The one-call form of ``p.progression()`` — it registers a pattern on + *channel* that plays *progression* across *bars* (or *beats*), each chord + lasting a length drawn from *harmonic_rhythm* (the musical term for how often + the chords change). It needs no ``composition.harmony()`` call and, with an + explicit chord list or a ``key=``, no composition key either — so a + drums-plus-one-chord-part sketch stays simple. + + The progression is realised once, up front, and the same timeline plays every + cycle (a stable phrase). That timeline is returned so you can see exactly what + was chosen — ``print(comp.chords(...))``. + + Parameters: + channel: MIDI channel for the chord part. + progression: A chord-graph style name to generate from, or an explicit list + of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). + harmonic_rhythm: How long each chord lasts — a number, a list of lengths, + or ``between(low, high, step=...)``. See ``p.progression()``. + bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the + composition's time signature. + voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). + velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. + detached: Beats of silence before each next chord (``duration = length - detached``). + root: MIDI root the voicings are centred on (e.g. 48 = C3). + key: Key for a generated progression; defaults to the composition key. + seed: Seed for the (otherwise fixed) realisation; defaults to the + composition seed, so the part is reproducible. + device: Optional output-device override. + mirrors: Optional additional ``(device, channel)`` destinations. + + Returns: + The realised :class:`~subsequence.progressions.Progression`. + """ + + beat_length, default_grid = self._resolve_length( + beats, bars, None, None, beats_per_bar=self.time_signature[0] + ) + resolved_channel = self._resolve_channel(channel) + resolved_key = key if key is not None else self.key + + rng = random.Random(seed if seed is not None else self._seed) + timeline = subsequence.progressions.realize( + source=progression, + harmonic_rhythm=harmonic_rhythm, + key=resolved_key, + length=beat_length, + rng=rng, + scale=self.scale or "ionian", + ) + + captured_root = root + captured_velocity = velocity + captured_detached = detached + captured_voicing = voicing + + def chords_builder(p: subsequence.pattern_builder.PatternBuilder) -> None: + """Replay the realised timeline as block chords each cycle (voicing per chord).""" + + for chord, start, length in timeline: + ring = ( + length - captured_detached + if (captured_detached and captured_detached < length) + else length + ) + voices = subsequence.progressions.resolve_voices( + captured_voicing, p.rng + ) + p.chord( + chord, + root=captured_root, + beat=start, + duration=ring, + count=voices, + velocity=captured_velocity, + ) + + # Unique, stable name so multiple chord parts don't collide in + # _running_patterns — including two parts on the SAME channel, which + # get a deterministic #2/#3 suffix in declaration order. + base_name = f"chords@ch{resolved_channel}" + chords_name = base_name + suffix = 2 + + while chords_name in self._declared_names: + chords_name = f"{base_name}#{suffix}" + suffix += 1 + + chords_builder.__name__ = chords_name + self._declared_names.add(chords_name) + + primary: typing.Optional[typing.Tuple[int, int]] + if isinstance(device, str): + primary = None + else: + primary = (device if device is not None else 0, resolved_channel) + resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) + + if self._is_live and chords_builder.__name__ in self._running_patterns: + running = self._running_patterns[chords_builder.__name__] + running._builder_fn = chords_builder + running._wants_chord = False + logger.info(f"Hot-swapped chords: {chords_builder.__name__}") + return timeline + + pending = _PendingPattern( + builder_fn=chords_builder, + channel=resolved_channel, + length=beat_length, + default_grid=default_grid, + drum_note_map=None, + reschedule_lookahead=1, + voice_leading=False, + mirrors=resolved_mirrors, + device=0 if (device is None or isinstance(device, str)) else device, + raw_device=device, + ) + self._pending_patterns.append(pending) + return timeline + + def phrase_part( + self, + *, + channel: int, + part: typing.Optional[str] = None, + root: int = 60, + bars: typing.Optional[float] = None, + beats: typing.Optional[float] = None, + velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, + fit: typing.Optional[float] = None, + device: subsequence.midi_utils.DeviceId = None, + mirrors: typing.Optional[ + typing.Iterable[subsequence.pattern.MirrorSpec] + ] = None, + ) -> None: + """Declare a part that plays each section's bound Motif/Phrase. + + The one-call consumer for :meth:`section_motifs` — it registers a + pattern on *channel* that walks whatever value is bound to the + current section for *part* (stateless position from the cycle + counter, via ``p.phrase()``). A section with no binding for the + part is **silent** for that part — bind material or don't; no + fallback guessing. + + Parameters: + channel: MIDI channel for the part. + part: The part label to read from the registry (``None`` = the + unlabelled binding). + root: Register anchor for degree resolution. + bars / beats: Cycle length of the part (defaults to 4 beats); + the phrase is sliced one cycle window at a time. + velocity: Optional override applied to every note. + fit: Passed through (active with the melody engine stage). + device: Optional output-device override. + mirrors: Optional additional ``(device, channel)`` destinations. + + Example:: + + composition.section_motifs("verse", verse_line, part="lead") + composition.section_motifs("chorus", chorus_line, part="lead") + composition.phrase_part(channel=4, part="lead", root=72, bars=2) + """ + + beat_length, default_grid = self._resolve_length( + beats, bars, None, None, beats_per_bar=self.time_signature[0] + ) + resolved_channel = self._resolve_channel(channel) + + captured_part = part + captured_root = root + captured_velocity = velocity + captured_fit = fit + + def phrase_builder(p: subsequence.pattern_builder.PatternBuilder) -> None: + """Walk the current section's bound value (silent when unbound).""" + + value = p.section_motif(captured_part) + + if value is None: + return # unbound section: silence for this part, by design + + p.phrase( + value, root=captured_root, velocity=captured_velocity, fit=captured_fit + ) + + # Unique, stable name so multiple phrase parts don't collide — + # including two parts on the SAME channel (deterministic #2/#3 + # suffixes in declaration order, the chords() convention). + base_name = ( + f"phrase@{captured_part}@ch{resolved_channel}" + if captured_part + else f"phrase@ch{resolved_channel}" + ) + phrase_name = base_name + suffix = 2 + + while phrase_name in self._declared_names: + phrase_name = f"{base_name}#{suffix}" + suffix += 1 + + phrase_builder.__name__ = phrase_name + self._declared_names.add(phrase_name) + + primary: typing.Optional[typing.Tuple[int, int]] + if isinstance(device, str): + primary = None + else: + primary = (device if device is not None else 0, resolved_channel) + resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) + + if self._is_live and phrase_builder.__name__ in self._running_patterns: + running = self._running_patterns[phrase_builder.__name__] + running._builder_fn = phrase_builder + running._wants_chord = False + logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") + return + + pending = _PendingPattern( + builder_fn=phrase_builder, + channel=resolved_channel, + length=beat_length, + default_grid=default_grid, + drum_note_map=None, + reschedule_lookahead=1, + voice_leading=False, + mirrors=resolved_mirrors, + device=0 if (device is None or isinstance(device, str)) else device, + raw_device=device, + ) + self._pending_patterns.append(pending) + + def trigger( + self, + fn: typing.Callable, + channel: int, + beats: typing.Optional[float] = None, + bars: typing.Optional[float] = None, + steps: typing.Optional[float] = None, + step_duration: typing.Optional[float] = None, + quantize: float = 0, + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + cc_name_map: typing.Optional[typing.Dict[str, int]] = None, + nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, + chord: bool = False, + device: subsequence.midi_utils.DeviceId = None, + mirrors: typing.Optional[ + typing.Iterable[subsequence.pattern.MirrorSpec] + ] = None, + ) -> None: + """ + Trigger a one-shot pattern immediately or on a quantized boundary. + + This is useful for real-time response to sensors, OSC messages, or other + external events. The builder function is called immediately with a fresh + PatternBuilder, and the generated events are injected into the queue at + the specified quantize boundary. + + The builder function has the same API as a ``@composition.pattern`` + decorated function and can use all PatternBuilder methods: ``p.note()``, + ``p.euclidean()``, ``p.arpeggio()``, and so on. + + See ``pattern()`` for the full description of ``beats``, ``bars``, + ``steps``, and ``step_duration``. Default is 1 beat. + + Parameters: + fn: The pattern builder function (same signature as ``@comp.pattern``). + channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). + beats: Duration in beats (quarter notes, default 1). + bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). + steps: Step count for step mode. Requires ``step_duration=``. + step_duration: Duration of one step in beats. Requires ``steps=``. + quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), + ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` + constants from ``subsequence.constants.durations``. + drum_note_map: Optional drum name mapping for this pattern. + cc_name_map: Optional mapping of CC names to MIDI CC numbers. + nrpn_name_map: Optional mapping of NRPN parameter names to + 14-bit parameter numbers. + chord: If ``True``, the builder function receives the current chord as + a second parameter (same as ``@composition.pattern``). + mirrors: Optional list of additional ``(device, channel)`` destinations + to fire this one-shot onto in parallel with the primary destination. + + Example: + ```python + # Immediate single note (channels are 1-16 by default) + composition.trigger( + lambda p: p.note(60, beat=0, velocity=100, duration=0.5), + channel=1 + ) + + # Quantized fill (next bar) — channel 10 is the GM drum channel + import subsequence.constants.durations as dur + composition.trigger( + lambda p: p.euclidean("snare", pulses=7, velocity=90), + channel=10, + drum_note_map=gm_drums.GM_DRUM_MAP, + quantize=dur.WHOLE + ) + + # With chord context — the builder receives the chord as a second + # argument when chord=True. + composition.trigger( + lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), + channel=1, + quantize=dur.QUARTER, + chord=True + ) + ``` + """ + + # Resolve channel numbering + resolved_channel = self._resolve_channel(channel) + + beat_length, default_grid = self._resolve_length( + beats, + bars, + steps, + step_duration, + default=1.0, + beats_per_bar=self.time_signature[0], + ) + + # Resolve device index — for trigger() this is always concrete by call time, + # so the mirror-to-self check has the full primary tuple available. + resolved_device_idx = self._resolve_device_id(device) + resolved_mirrors = self._resolve_mirrors( + mirrors, primary=(resolved_device_idx, resolved_channel) + ) + + # Create a temporary Pattern + pattern = subsequence.pattern.Pattern( + channel=resolved_channel, + length=beat_length, + device=resolved_device_idx, + mirrors=resolved_mirrors, + ) + + # Resolve the section context once: the one-shot inherits the section's + # effective key/scale (so a triggered degree resolves like everywhere + # else) and a harmony view at the current playhead (so ChordTone / + # Approach resolve too). + trigger_section = ( + self._form_state.get_section_info() if self._form_state else None + ) + trigger_key, trigger_scale = self._effective_key_scale(trigger_section) + + trigger_harmony: typing.Optional[HarmonyView] = None + if not self._harmony_horizon.is_empty: + trigger_harmony = HarmonyView( + self._harmony_horizon, + self._sequencer.pulse_count / self._sequencer.pulses_per_beat, + ) + + # Create a PatternBuilder + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 + drum_note_map=drum_note_map, + cc_name_map=cc_name_map, + nrpn_name_map=nrpn_name_map, + section=trigger_section, + bar=self._builder_bar, + conductor=self.conductor, + rng=random.Random(), # Fresh random state for each trigger + tweaks={}, + default_grid=default_grid, + data=self.data, + # A one-shot resolves key-relative content against the same + # effective key/scale as the section it fires into (previously + # omitted entirely — degrees raised even in a keyed composition). + key=trigger_key, + scale=trigger_scale, + time_signature=self.time_signature, + held_notes=self._sequencer._held_notes, + harmony=trigger_harmony, + energy=self._current_energy(trigger_section), + ) + + # Call the builder function + try: + current_chord = self.current_chord() if chord else None + + if current_chord is not None: + injected = _InjectedChord( + current_chord, None + ) # No voice leading for one-shots + fn(builder, injected) + + else: + fn(builder) + + except Exception: + logger.exception("Error in trigger builder — pattern will be silent") + return + + # Calculate the start pulse based on quantize + current_pulse = self._sequencer.pulse_count + pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE + + if quantize == 0: + # Immediate: use current pulse + start_pulse = current_pulse + + else: + # Quantize to the next multiple of (quantize * pulses_per_beat) + quantize_pulses = int(quantize * pulses_per_beat) + start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses + + self._schedule_one_shot(pattern, start_pulse) + + def _schedule_one_shot( + self, pattern: subsequence.pattern.Pattern, start_pulse: int + ) -> None: + """Schedule a one-shot pattern at an absolute pulse, thread-safely.""" + + try: + # Probe only: raises RuntimeError when not on the event loop. + asyncio.get_running_loop() + asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse)) + + except RuntimeError: + # Not on the event loop — hand the coroutine to the loop thread. + if self._sequencer._event_loop is not None: + asyncio.run_coroutine_threadsafe( + self._sequencer.schedule_pattern(pattern, start_pulse), + loop=self._sequencer._event_loop, + ) + else: + logger.warning( + "trigger() called before playback started; pattern ignored" + ) + + @property + def is_clock_following(self) -> bool: + """True if either the primary or any additional device is following external clock.""" + + return self._clock_follow or any(cf for _, _, cf in self._additional_inputs) + + def play(self) -> None: + """ + Start the composition. + + This call blocks until the program is interrupted (e.g., via Ctrl+C). + It initializes the MIDI hardware, launches the background sequencer, + and begins playback. + """ + + try: + asyncio.run(self._run()) + + except KeyboardInterrupt: + pass + + def render( + self, + bars: typing.Optional[int] = None, + filename: str = "render.mid", + max_minutes: typing.Optional[float] = 60.0, + ) -> None: + """Render the composition to a MIDI file without real-time playback. + + Runs the sequencer as fast as possible (no timing delays) and stops + when the first active limit is reached. The result is saved as a + standard MIDI file that can be imported into any DAW. + + All patterns, scheduled callbacks, and harmony logic run exactly as + they would during live playback — BPM transitions, generative fills, + and probabilistic gates all work in render mode. The only difference + is that time is simulated rather than wall-clock driven. + + Parameters: + bars: Number of bars to render, or ``None`` for no bar limit + (default ``None``). When both *bars* and *max_minutes* are + active, playback stops at whichever limit is reached first. + filename: Output MIDI filename (default ``"render.mid"``). + max_minutes: Safety cap on the length of rendered MIDI in minutes + (default ``60.0``). Pass ``None`` to disable the time + cap — you must then provide an explicit *bars* value. + + Raises: + ValueError: If both *bars* and *max_minutes* are ``None``, which + would produce an infinite render. + + Examples: + ```python + # Default: renders up to 60 minutes of MIDI content. + composition.render() + + # Render exactly 64 bars (time cap still active as backstop). + composition.render(bars=64, filename="demo.mid") + + # Render up to 5 minutes of an infinite generative composition. + composition.render(max_minutes=5, filename="five_min.mid") + + # Remove the time cap — must supply bars instead. + composition.render(bars=128, max_minutes=None, filename="long.mid") + ``` + """ + + if bars is None and max_minutes is None: + raise ValueError( + "render() requires at least one limit: provide bars=, max_minutes=, or both. " + "Passing both as None would produce an infinite render." + ) + + self._sequencer.recording = True + self._sequencer.record_filename = filename + self._sequencer.render_mode = True + self._sequencer.render_bars = bars if bars is not None else 0 + self._sequencer.render_max_seconds = ( + max_minutes * 60.0 if max_minutes is not None else None + ) + asyncio.run(self._run()) + + def _broadcast_osc_status(self, bar: int) -> None: + """ + Send the per-bar OSC status snapshot: bar number, current tempo, + and (when active) the current chord name and form section. + """ + + if self._osc_server: + self._osc_server.send("/bar", bar) + self._osc_server.send("/bpm", self._sequencer.current_bpm) + + sounding = self.current_chord() + if sounding is not None: + self._osc_server.send("/chord", sounding.name()) + + if self._form_state: + info = self._form_state.get_section_info() + if info: + self._osc_server.send("/section", info.name) + + async def _run(self) -> None: + """ + Async entry point that schedules all patterns and runs the sequencer. + """ + + # 1. Pre-calculate MIDI input indices and configure sequencer clock follow. + if self._input_device is not None: + self._sequencer.input_device_name = self._input_device + self._sequencer.clock_follow = self._clock_follow + self._sequencer.clock_device_idx = 0 + + if not self._clock_follow: + # Find first additional input that wants to be the clock master. + for idx, (_, _, cf) in enumerate(self._additional_inputs, start=1): + if cf: + self._sequencer.clock_follow = True + self._sequencer.clock_device_idx = idx + break + + # Populate input device name mapping early (before opening ports) so we can + # resolve CC mappings to integer device indices immediately. + if self._sequencer.input_device_name: + self._input_device_names[self._sequencer.input_device_name] = 0 + if self._input_device_alias is not None: + self._input_device_names[self._input_device_alias] = 0 + + for idx, (dev_name, alias, _) in enumerate(self._additional_inputs, start=1): + self._input_device_names[dev_name] = idx + if alias: + self._input_device_names[alias] = idx + + # 2. Pre-calculate output device names. + if self._sequencer.output_device_name: + self._output_device_names[self._sequencer.output_device_name] = 0 + # Primary device (index 0) is open by now (_init_midi_output ran in + # the Sequencer constructor), so its latency can be set safely here. + if self._output_latency_ms: + self._sequencer.set_device_latency(0, self._output_latency_ms) + + # 3. Resolve name-based INPUT device ids in cc_map/cc_forward early — the + # input-names map is fully populated above, and the callback thread needs + # integer indices as soon as ports open. OUTPUT names (cc_forward + # output_device=, pattern device=) resolve after the additional outputs + # are opened below; resolving them here matched against a map containing + # only the primary and silently routed everything to device 0. + for mapping in self._cc_mappings: + raw = mapping.get("input_device") + if isinstance(raw, str): + mapping["input_device"] = self._resolve_input_device_id(raw) + for fwd in self._cc_forwards: + raw_in = fwd.get("input_device") + if isinstance(raw_in, str): + fwd["input_device"] = self._resolve_input_device_id(raw_in) + + # 4. Share CC input mappings, forwards, and a reference to composition.data + # with the sequencer BEFORE opening the ports. This ensures that any initial + # messages in the OS buffer are correctly mapped as soon as the port opens. + self._sequencer.cc_mappings = self._cc_mappings + self._sequencer.cc_forwards = self._cc_forwards + self._sequencer._composition_data = self.data + + # Held-note input: create the tracker and resolve its channel/device + # filter so the callback thread can buffer matching note events. + if self._note_input is not None: + if self._input_device is None and not self._additional_inputs: + raise RuntimeError( + "note_input() requires a MIDI input — call composition.midi_input(device) first" + ) + raw_dev = self._note_input.get("input_device") + if isinstance(raw_dev, str): + raw_dev = self._resolve_input_device_id(raw_dev) + self._sequencer._note_input_channel = self._note_input["channel"] + self._sequencer._note_input_device = raw_dev + self._sequencer._held_notes = subsequence.held_notes.HeldNotes( + release_ms=self._note_input["release_ms"], + latch=self._note_input["latch"], + ) + + # 5. Open MIDI input ports early. Even without a deliberate sleep, opening + # them before pattern building minimizes the window for missed messages. + # Primary input + self._sequencer._open_midi_inputs() + + # Additional inputs + for idx, (dev_name, alias, cf) in enumerate(self._additional_inputs, start=1): + # Use the pre-calculated index + callback = self._sequencer._make_input_callback(idx) + open_name, port = subsequence.midi_utils.select_input_device( + dev_name, callback + ) + if open_name and port is not None: + self._sequencer.add_input_device(open_name, port) + else: + logger.warning(f"Could not open additional input device '{dev_name}'") + + # 6. Open additional MIDI output devices. + for out in self._additional_outputs: + open_name, port = subsequence.midi_utils.select_output_device(out.device) + if open_name and port is not None: + idx = self._sequencer.add_output_device(open_name, port, out.latency_ms) + self._output_device_names[open_name] = idx + if out.alias is not None: + self._output_device_names[out.alias] = idx + else: + logger.warning( + f"Could not open additional output device '{out.device}'" + ) + + # Warn if latency compensation adds noticeable whole-rig delay: the + # slowest device defines the alignment point, so every faster device is + # delayed up to that amount and live-input feel suffers. + self._warn_if_high_latency() + + # Resolve any name-based output device IDs on patterns that may have been added + # for additional output devices. + self._resolve_pending_devices() + + # Resolve cc_forward output-device names now that every output port and + # alias is registered (resolving earlier silently routed to device 0). + for fwd in self._cc_forwards: + raw_out = fwd.get("output_device") + if isinstance(raw_out, str): + fwd["output_device"] = self._resolve_device_id(raw_out) + + # Pass clock output flag (suppressed automatically when clock_follow=True). + self._sequencer.clock_output = ( + self._clock_output and not self.is_clock_following + ) + + # Create Ableton Link clock if comp.link() was called. + if self._link_quantum is not None: + self._sequencer._link_clock = subsequence.link_clock.LinkClock( + bpm=self.bpm, + quantum=self._link_quantum, + loop=asyncio.get_running_loop(), + ) + + # Deal play-time streams. Every stream is NAME-keyed (crc32 of + # "seed:name", see _stream_seed) rather than dealt from one master in + # registration order: adding or removing one consumer can never shift + # another's stream, and patterns added live derive identically in + # _build_pattern_from_pending. When no seed is set, components keep + # their own unseeded RNGs (existing behaviour). + if self._seed is not None: + harmony_stream = self._stream("play:harmony") + if self._harmonic_state is not None and harmony_stream is not None: + self._harmonic_state.rng = harmony_stream + + form_stream = self._stream("play:form") + if self._form_state is not None and form_stream is not None: + self._form_state._rng = form_stream + + # The clocks fire BEFORE pattern rebuilds at the same pulse, and their + # lookahead is RAISED to the maximum pattern lookahead (never patterns + # clamped down): when a pattern rebuilds for its next cycle, the form + # state and the harmony window already describe that cycle. + bar_beats = float(self.time_signature[0]) + + pattern_lookaheads = [ + pending.reschedule_lookahead for pending in self._pending_patterns + ] + pattern_lookaheads += [ + pattern.reschedule_lookahead for pattern in self._running_patterns.values() + ] + max_pattern_lookahead = max(pattern_lookaheads, default=1) + + clock_lookahead = max( + 1.0, float(self._harmony_reschedule_lookahead), float(max_pattern_lookahead) + ) + + if clock_lookahead > bar_beats: + logger.warning( + "A pattern's reschedule_lookahead (%.2g beats) exceeds the bar length (%.2g) — " + "the harmony/form clocks fire at most one bar ahead, so that pattern may " + "rebuild before the window covers its cycle start.", + clock_lookahead, + bar_beats, + ) + clock_lookahead = bar_beats + + # Minimum span >= maximum lookahead: the clock cannot prepare a chord + # boundary that arrives sooner than it fires. Harmonic motion faster + # than this floor stays available at the part level (p.progression), + # where placement is not clock-bound. + def _check_span_floor( + progression: typing.Optional[Progression], label: str + ) -> None: + if progression is None: + return + shortest = min(span.beats for span in progression.spans) + if shortest < clock_lookahead - 1e-9: + raise ValueError( + f"{label}: shortest chord span ({shortest:g} beats) is below the clock " + f"lookahead ({clock_lookahead:g} beats — the largest pattern lookahead). " + "Lengthen the span, lower the pattern lookaheads, or place fast harmony " + "at the part level with p.progression()." + ) + + _check_span_floor(self._bound_progression, "harmony(progression=)") + for section_name, section_progression in self._section_progressions.items(): + _check_span_floor(section_progression, f"section_chords({section_name!r})") + + # Key-relative section progressions resolve late, per occurrence — so + # verify they WILL resolve now, before playback, rather than surfacing + # a silent skip (or a dead clock) mid-render. For each occurrence's + # effective key+scale: a missing key, or a degree/scale that does not + # resolve, is raised here with an actionable message. + fs = self._form_state + + for section_name, section_progression in self._section_progressions.items(): + if section_progression.is_concrete: + continue + + # The (key, scale) contexts this section may be resolved against. + contexts: typing.List[ + typing.Tuple[typing.Optional[str], typing.Optional[str]] + ] = [] + if ( + fs is not None + and fs._sequence is not None + and any(s.name == section_name for s in fs._sequence) + ): + for section in fs._sequence: + if section.name != section_name: + continue + ctx = ( + section.key or self._form_key or self.key, + section.scale or self._form_scale or self.scale, + ) + if ctx not in contexts: + contexts.append(ctx) + else: + contexts.append( + (self._form_key or self.key, self._form_scale or self.scale) + ) + + for ctx_key, ctx_scale in contexts: + if ctx_key is None: + raise ValueError( + f"section_chords({section_name!r}) is key-relative (degrees/romans) but no key " + "resolves for it — set key= on the Composition, a form key (form(key=...)), or " + f"a Section.key on every {section_name!r} section." + ) + try: + section_progression.resolve(ctx_key, ctx_scale or "ionian") + except ValueError as error: + raise ValueError( + f"section_chords({section_name!r}) does not resolve against its effective key " + f"{ctx_key} {ctx_scale or 'ionian'}: {error}" + ) + + # min_energy with nothing feeding p.energy is a silent no-op — warn loudly. + energy_gated = [ + p.builder_fn.__name__ + for p in self._pending_patterns + if p.min_energy is not None + ] + + if energy_gated and not self._energy_map and not self._form_has_payload: + logger.warning( + f"min_energy is set on {', '.join(energy_gated)} but no energy source is " + "configured — p.energy is always 0.5 (call composition.energy() or bind a " + "Form whose Sections carry energy)" + ) + + # The form clock MUST be registered before the harmonic clock: same-pulse + # fixed callbacks fire in registration order (and all fixed callbacks fire + # before callback sequences), and on a section-boundary bar the harmonic + # clock reads the current section (via _get_section_progression) to decide + # whether to walk that section's chords. Registering harmony first would + # make it read the OLD section on every boundary, shifting section_chords() + # replays by one bar and bleeding them across sections. + if self._form_state is not None: + await schedule_form( + sequencer=self._sequencer, + form_state=self._form_state, + reschedule_lookahead=clock_lookahead, + on_bar=self._check_transitions, + # Re-read every bar so a mid-playback form() re-bind advances + # the NEW state instead of the abandoned object. + get_form_state=lambda: self._form_state, + ) + + self._harmony_horizon.reset() + self._harmonic_clock_started = False + + if ( + self._harmonic_state is not None + or self._bound_progression is not None + or self._section_progressions + ): + await self._start_harmonic_clock(bar_beats, clock_lookahead) + + # Bar counter - always active so p.bar is available to all builders. + def _advance_builder_bar(pulse: int) -> None: + self._builder_bar += 1 + + first_bar_pulse = int(self.time_signature[0] * self._sequencer.pulses_per_beat) + + await self._sequencer.schedule_callback_repeating( + callback=_advance_builder_bar, + interval_beats=self.time_signature[0], + start_pulse=first_bar_pulse, + # Same raised lookahead as the form/harmony clocks: a pattern + # rebuilding lookahead-early for its next cycle must read the bar + # that cycle starts in, not the previous one. + reschedule_lookahead=clock_lookahead, + ) + + # Run wait_for_initial=True scheduled functions and block until all complete. + # This ensures composition.data is populated before patterns build. + initial_tasks = [t for t in self._pending_scheduled if t.wait_for_initial] + + if initial_tasks: + names = ", ".join( + getattr(t.fn, "__name__", repr(t.fn)) for t in initial_tasks + ) + logger.info( + f"Waiting for initial scheduled {'function' if len(initial_tasks) == 1 else 'functions'} before start: {names}" + ) + + async def _run_initial(fn: typing.Callable) -> None: + accepts_ctx = _fn_has_parameter(fn, "p") + ctx = ScheduleContext(cycle=0) + + try: + if inspect.iscoroutinefunction(fn): + await (fn(ctx) if accepts_ctx else fn()) + else: + loop = asyncio.get_running_loop() + call = (lambda: fn(ctx)) if accepts_ctx else fn + await loop.run_in_executor(None, call) + except Exception as exc: + logger.warning( + f"Initial run of {getattr(fn, '__name__', repr(fn))!r} failed: {exc}" + ) + + await asyncio.gather(*[_run_initial(t.fn) for t in initial_tasks]) + + for pending_task in self._pending_scheduled: + accepts_ctx = _fn_has_parameter(pending_task.fn, "p") + + # A wait_for_initial task already ran once as cycle 0 (the blocking + # pre-roll above), so its repeating wrapper starts at cycle 1 — keeping + # ScheduleContext.cycle monotonic across the initial and repeating runs. + wrapped = _make_safe_callback( + pending_task.fn, + accepts_context=accepts_ctx, + start_cycle=1 if pending_task.wait_for_initial else 0, + ) + + # wait_for_initial=True implies defer — no point firing at pulse 0 + # after the blocking run just completed. defer=True skips the + # backshift fire so the first repeating call happens one full cycle + # later. + if pending_task.wait_for_initial or pending_task.defer: + start_pulse = int( + pending_task.cycle_beats * self._sequencer.pulses_per_beat + ) + else: + start_pulse = 0 + + await self._sequencer.schedule_callback_repeating( + callback=wrapped, + interval_beats=pending_task.cycle_beats, + start_pulse=start_pulse, + reschedule_lookahead=pending_task.reschedule_lookahead, + ) + + # Build Pattern objects from pending registrations. + patterns: typing.List[subsequence.pattern.Pattern] = [] + + for i, pending in enumerate(self._pending_patterns): + pattern = self._build_pattern_from_pending(pending) + patterns.append(pattern) + + await schedule_patterns( + sequencer=self._sequencer, patterns=patterns, start_pulse=0 + ) + + # Populate the running patterns dict for live hot-swap and mute/unmute. + for i, pending in enumerate(self._pending_patterns): + name = pending.builder_fn.__name__ + self._running_patterns[name] = patterns[i] + + # Everything pending is running now; drop the declarations so a later + # live reload cannot graduate stale copies. + self._pending_patterns = [] + + if self._display is not None and not self._sequencer.render_mode: + self._display.start() + self._sequencer.on_event("bar", self._display.update) + self._sequencer.on_event("beat", self._display.update) + + if self._live_server is not None: + await self._live_server.start() + + if self._osc_server is not None: + await self._osc_server.start() + self._sequencer.osc_server = self._osc_server + self._sequencer.on_event("bar", self._broadcast_osc_status) + + # Start keystroke listener if hotkeys are enabled and not in render mode. + if self._hotkeys_enabled and not self._sequencer.render_mode: + self._keystroke_listener = subsequence.keystroke.KeystrokeListener() + self._keystroke_listener.start() + + if self._keystroke_listener.active: + # Listener started successfully — register the bar handler + # and show all bindings so the user knows what's available. + self._sequencer.on_event("bar", self._process_hotkeys) + self._list_hotkeys() + # If not active, KeystrokeListener.start() already logged a warning. + + if self._web_ui_enabled and not self._sequencer.render_mode: + self._web_ui_server = subsequence.web_ui.WebUI( + self, http_host=self._web_ui_http_host, ws_host=self._web_ui_ws_host + ) + self._web_ui_server.start() + + try: + await run_until_stopped(self._sequencer) + finally: + # Tear down every service even if run_until_stopped (or an earlier + # stop) raised, and guard each individually, so one failure can't + # strand the rest — most importantly the keystroke listener's + # terminal restore. + if self._web_ui_server is not None: + try: + self._web_ui_server.stop() + except Exception: + logger.exception("Error stopping web UI") + + if self._live_server is not None: + try: + await self._live_server.stop() + except Exception: + logger.exception("Error stopping live server") + + if self._live_reloader is not None: + try: + self._live_reloader.stop() + except Exception: + logger.exception("Error stopping live reloader") + + if self._osc_server is not None: + try: + await self._osc_server.stop() + except Exception: + logger.exception("Error stopping OSC server") + self._sequencer.osc_server = None + + if self._display is not None: + try: + self._display.stop() + except Exception: + logger.exception("Error stopping display") + + if self._keystroke_listener is not None: + try: + self._keystroke_listener.stop() + except Exception: + logger.exception("Error stopping keystroke listener") + self._keystroke_listener = None + + def _build_pattern_from_pending( + self, pending: _PendingPattern, start_pulse: int = 0 + ) -> subsequence.pattern.Pattern: + """ + Create a Pattern from a pending registration using a temporary subclass. + + The pattern's play stream is dealt here, keyed by NAME (crc32 of + "seed:name" plus any reroll nonce), so registration order is + irrelevant and a pattern added live gets exactly the stream it would + have had at startup. ``start_pulse`` anchors the first cycle on the + beat axis so the initial build reads the harmony window at the right + place (the sequencer keeps the anchor current on every reschedule). + """ + + composition_ref = self + rng = self._stream(pending.builder_fn.__name__) + + class _DecoratorPattern(subsequence.pattern.Pattern): + """ + Pattern subclass that delegates to a builder function on each reschedule. + """ + + def __init__( + self, + pending: _PendingPattern, + pattern_rng: typing.Optional[random.Random] = None, + ) -> None: + """ + Initialize the decorator pattern from pending registration details. + """ + + super().__init__( + channel=pending.channel, + length=pending.length, + reschedule_lookahead=pending.reschedule_lookahead, + device=pending.device, + mirrors=pending.mirrors, + ) + + self._builder_fn = pending.builder_fn + self._drum_note_map = pending.drum_note_map + self._cc_name_map = pending.cc_name_map + self._nrpn_name_map = pending.nrpn_name_map + self._default_grid: int = pending.default_grid + self._wants_chord = _fn_has_parameter(pending.builder_fn, "chord") + self._cycle_count = 0 + self._rng = pattern_rng + self._muted = False + self._min_energy = pending.min_energy + self._energy_gated = False + self._voice_leading_state: typing.Optional[ + subsequence.voicings.VoiceLeadingState + ] = ( + subsequence.voicings.VoiceLeadingState() + if pending.voice_leading + else None + ) + self._tweaks: typing.Dict[str, typing.Any] = {} + + # Anchor of the cycle being built, on the absolute pulse axis. + # The sequencer updates this on every reschedule; the initial + # value is the pattern's first scheduled start. + self._cycle_start_pulse = start_pulse + + self._rebuild() + + def _rebuild(self) -> None: + """ + Clear steps and call the builder function to repopulate. + """ + + self.steps = {} + self.cc_events = [] + self.osc_events = [] + self.raw_note_events = [] + current_cycle = self._cycle_count + self._cycle_count += 1 + + # lock(): re-deal the stream from its effective seed every + # rebuild so a locked pattern realizes identically each cycle. + # Checked here (engine-side) so it survives live reload. + if self._builder_fn.__name__ in composition_ref._locked_names: + locked_seed = composition_ref._stream_seed( + self._builder_fn.__name__ + ) + if locked_seed is not None: + self._rng = random.Random(locked_seed) + + if self._muted: + return + + section_info = ( + composition_ref._form_state.get_section_info() + if composition_ref._form_state + else None + ) + energy = composition_ref._current_energy(section_info) + effective_key, effective_scale = composition_ref._effective_key_scale( + section_info + ) + + # Automatic energy gating: below the threshold the pattern is + # silent this cycle (composing with _muted — a performer mute + # always wins). Gate flips log once. + if self._min_energy is not None: + gated = energy < self._min_energy + + if gated != self._energy_gated: + state_word = "closed" if gated else "open" + logger.info( + f"Pattern '{self._builder_fn.__name__}': energy gate {state_word} " + f"(energy {energy:.2f}, min_energy {self._min_energy:g})" + ) + self._energy_gated = gated + + if gated: + return + + # The harmony view for this cycle, anchored at its start beat — + # under variable harmonic rhythm the window, not the engine's + # mutating singleton, is the source of truth. + harmony_view: typing.Optional[HarmonyView] = None + + if not composition_ref._harmony_horizon.is_empty: + origin_beat = ( + self._cycle_start_pulse + / composition_ref._sequencer.pulses_per_beat + ) + harmony_view = HarmonyView( + composition_ref._harmony_horizon, origin_beat + ) + + builder = subsequence.pattern_builder.PatternBuilder( + pattern=self, + cycle=current_cycle, + drum_note_map=self._drum_note_map, + cc_name_map=self._cc_name_map, + nrpn_name_map=self._nrpn_name_map, + section=section_info, + bar=composition_ref._builder_bar, + conductor=composition_ref.conductor, + rng=self._rng, + tweaks=self._tweaks, + default_grid=self._default_grid, + data=composition_ref.data, + # The effective key/scale re-anchors key-relative content + # (degrees, romans, generated material) to the section / + # form / composition tier in force — mode travels too. + key=effective_key, + scale=effective_scale, + time_signature=composition_ref.time_signature, + held_notes=composition_ref._sequencer._held_notes, + harmony=harmony_view, + section_motifs=composition_ref._section_motifs, + energy=energy, + ) + + try: + if self._wants_chord: + # The two-parameter convention: the injected chord is + # the cycle-start snapshot from the window (falling + # back to the engine before the clock has run). + chord = ( + harmony_view.chord + if harmony_view is not None + else ( + composition_ref._harmonic_state.get_current_chord() + if composition_ref._harmonic_state is not None + else None + ) + ) + + if chord is not None: + injected = _InjectedChord( + chord, + self._voice_leading_state, + next_chord=harmony_view.next_chord + if harmony_view is not None + else None, + beats_remaining=harmony_view.until_change + if harmony_view is not None + else None, + ) + self._builder_fn(builder, injected) + else: + self._builder_fn(builder) + + else: + self._builder_fn(builder) + + except Exception: + # Discard whatever the builder placed before it raised — + # otherwise a half-built pattern plays and the log lies. + self.steps = {} + self.cc_events = [] + self.osc_events = [] + self.raw_note_events = [] + logger.exception( + "Error in pattern builder '%s' (cycle %d) - pattern will be silent this cycle", + self._builder_fn.__name__, + current_cycle, + ) + + # Auto-apply global tuning if set and not already applied by the builder. + if ( + composition_ref._tuning is not None + and not builder._tuning_applied + and not ( + composition_ref._tuning_exclude_drums and self._drum_note_map + ) + ): + import subsequence.tuning as _tuning_mod + + _tuning_mod.apply_tuning_to_pattern( + self, + composition_ref._tuning, + bend_range=composition_ref._tuning_bend_range, + channels=composition_ref._tuning_channels, + reference_note=composition_ref._tuning_reference_note, + ) + + def on_reschedule(self) -> None: + """ + Rebuild the pattern from the builder function before the next cycle. + """ + + self._rebuild() + + return _DecoratorPattern(pending, rng) diff --git a/subsequence/conductor.py b/subsequence/conductor.py index b5efeab..4906df0 100644 --- a/subsequence/conductor.py +++ b/subsequence/conductor.py @@ -17,222 +17,243 @@ class Signal: - - """ - Abstract base class for a time-varying signal. - """ - - def value_at (self, beat: float) -> float: + """ + Abstract base class for a time-varying signal. + """ - """ - Return the signal value at the given beat time. Subclasses must override. - """ + def value_at(self, beat: float) -> float: + """ + Return the signal value at the given beat time. Subclasses must override. + """ - raise NotImplementedError + raise NotImplementedError class LFO(Signal): - - """ - A Low-Frequency Oscillator for generating periodic modulation signals. - - LFOs are used to create cyclical changes (like a 'swell' or 'vibrato') - over many bars. - """ - - def __init__ (self, shape: str = "sine", cycle_beats: float = 16.0, min_val: float = 0.0, max_val: float = 1.0, phase: float = 0.0) -> None: - - """ - Initialize an LFO. - - Parameters: - shape: The waveform shape ("sine", "triangle", "saw", "square"). - cycle_beats: How many beats for one full cycle (e.g., 16.0 = 4 bars). - min_val: The bottom of the LFO range (default 0.0). - max_val: The top of the LFO range (default 1.0). - phase: Phase offset (0.0 to 1.0). - """ - - if cycle_beats <= 0: - raise ValueError("cycle_beats must be positive") - - if shape not in _VALID_LFO_SHAPES: - raise ValueError( - f"Unknown LFO shape {shape!r}. " - f"Valid shapes: {sorted(_VALID_LFO_SHAPES)}" - ) - - self.shape = shape - self.cycle_beats = cycle_beats - self.min_val = min_val - self.max_val = max_val - self.phase = phase - - def value_at (self, beat: float) -> float: - - """ - Compute the signal value at a given beat time. - """ - - progress = (beat / self.cycle_beats + self.phase) % 1.0 - val = 0.0 - - if self.shape == "sine": - # Map -1..1 to 0..1 - raw = math.sin(progress * 2 * math.pi) - val = (raw + 1) / 2 - - elif self.shape == "triangle": - # 0 -> 0.5 -> 1 -> 0.5 -> 0 - if progress < 0.5: - val = progress * 2 - else: - val = 2 - (progress * 2) - - elif self.shape == "saw": - val = progress - - else: # "square" - val = 1.0 if progress < 0.5 else 0.0 - - return self.min_val + (val * (self.max_val - self.min_val)) + """ + A Low-Frequency Oscillator for generating periodic modulation signals. + + LFOs are used to create cyclical changes (like a 'swell' or 'vibrato') + over many bars. + """ + + def __init__( + self, + shape: str = "sine", + cycle_beats: float = 16.0, + min_val: float = 0.0, + max_val: float = 1.0, + phase: float = 0.0, + ) -> None: + """ + Initialize an LFO. + + Parameters: + shape: The waveform shape ("sine", "triangle", "saw", "square"). + cycle_beats: How many beats for one full cycle (e.g., 16.0 = 4 bars). + min_val: The bottom of the LFO range (default 0.0). + max_val: The top of the LFO range (default 1.0). + phase: Phase offset (0.0 to 1.0). + """ + + if cycle_beats <= 0: + raise ValueError("cycle_beats must be positive") + + if shape not in _VALID_LFO_SHAPES: + raise ValueError( + f"Unknown LFO shape {shape!r}. " + f"Valid shapes: {sorted(_VALID_LFO_SHAPES)}" + ) + + self.shape = shape + self.cycle_beats = cycle_beats + self.min_val = min_val + self.max_val = max_val + self.phase = phase + + def value_at(self, beat: float) -> float: + """ + Compute the signal value at a given beat time. + """ + + progress = (beat / self.cycle_beats + self.phase) % 1.0 + val = 0.0 + + if self.shape == "sine": + # Map -1..1 to 0..1 + raw = math.sin(progress * 2 * math.pi) + val = (raw + 1) / 2 + + elif self.shape == "triangle": + # 0 -> 0.5 -> 1 -> 0.5 -> 0 + if progress < 0.5: + val = progress * 2 + else: + val = 2 - (progress * 2) + + elif self.shape == "saw": + val = progress + + else: # "square" + val = 1.0 if progress < 0.5 else 0.0 + + return self.min_val + (val * (self.max_val - self.min_val)) class Line(Signal): - - """ - A ramp signal that moves from one value to another over time. - """ - - def __init__ (self, start_val: float, end_val: float, duration_beats: float, start_beat: float = 0.0, loop: bool = False, shape: typing.Union[str, subsequence.easing.EasingFn] = "linear") -> None: - - """ - Initialize a ramp signal. - - Parameters: - start_val: Initial value. - end_val: Target value. - duration_beats: How long it takes to reach the target (in beats). - start_beat: Global beat time when the ramp should start (default 0). - loop: Whether to jump back to start_val and repeat (default False). - shape: Easing curve — a name string (e.g. ``"ease_in_out"``) or any - callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. - See :mod:`subsequence.easing` for available shapes. - """ - - if duration_beats <= 0: - raise ValueError("duration_beats must be positive") - - self.start_val = start_val - self.end_val = end_val - self.duration_beats = duration_beats - self.start_beat = start_beat - self.loop = loop - self._easing_fn = subsequence.easing.get_easing(shape) - - def value_at (self, beat: float) -> float: - - """ - Compute the ramp value at a given beat time. - """ - - elapsed = beat - self.start_beat - - if elapsed < 0: - return self.start_val - - if self.loop: - elapsed %= self.duration_beats - elif elapsed >= self.duration_beats: - return self.end_val - - progress = elapsed / self.duration_beats - eased = self._easing_fn(progress) - return self.start_val + (eased * (self.end_val - self.start_val)) + """ + A ramp signal that moves from one value to another over time. + """ + + def __init__( + self, + start_val: float, + end_val: float, + duration_beats: float, + start_beat: float = 0.0, + loop: bool = False, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + ) -> None: + """ + Initialize a ramp signal. + + Parameters: + start_val: Initial value. + end_val: Target value. + duration_beats: How long it takes to reach the target (in beats). + start_beat: Global beat time when the ramp should start (default 0). + loop: Whether to jump back to start_val and repeat (default False). + shape: Easing curve — a name string (e.g. ``"ease_in_out"``) or any + callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. + See :mod:`subsequence.easing` for available shapes. + """ + + if duration_beats <= 0: + raise ValueError("duration_beats must be positive") + + self.start_val = start_val + self.end_val = end_val + self.duration_beats = duration_beats + self.start_beat = start_beat + self.loop = loop + self._easing_fn = subsequence.easing.get_easing(shape) + + def value_at(self, beat: float) -> float: + """ + Compute the ramp value at a given beat time. + """ + + elapsed = beat - self.start_beat + + if elapsed < 0: + return self.start_val + + if self.loop: + elapsed %= self.duration_beats + elif elapsed >= self.duration_beats: + return self.end_val + + progress = elapsed / self.duration_beats + eased = self._easing_fn(progress) + return self.start_val + (eased * (self.end_val - self.start_val)) class Conductor: - - """ - A registry for global automation signals. - - The ``Conductor`` allows you to define time-varying signals (like LFOs or - ramps) that are available to all pattern builders. This is ideal for - modulating parameters (like velocity or filter cutoff) over long - compositional timeframes. - """ - - def __init__ (self) -> None: - - """ - Create an empty conductor; register signals with lfo() or line(). - """ - - self._signals: typing.Dict[str, Signal] = {} - self._warned_missing: typing.Set[str] = set() - - @property - def signal_names (self) -> typing.Tuple[str, ...]: - """Names of all registered signals, in sorted order.""" - return tuple(sorted(self._signals)) - - def lfo (self, name: str, shape: str = "sine", cycle_beats: float = 16.0, min_val: float = 0.0, max_val: float = 1.0, phase: float = 0.0) -> None: - - """ - Register a named LFO signal. - - Example: - ```python - comp.conductor.lfo("swell", shape="sine", cycle_beats=32) - ``` - """ - - self._signals[name] = LFO(shape, cycle_beats, min_val, max_val, phase) - - def line (self, name: str, start_val: float, end_val: float, duration_beats: float, start_beat: float = 0.0, loop: bool = False, shape: typing.Union[str, subsequence.easing.EasingFn] = "linear") -> None: - - """ - Register a named ramp signal. - - Parameters: - name: Signal name, used to retrieve the value via ``p.signal(name)``. - start_val: Value at the start of the ramp. - end_val: Value at the end of the ramp. - duration_beats: Duration of the ramp in beats. - start_beat: Beat time at which the ramp begins (default 0). - loop: If True, the ramp restarts from start_val after each cycle. - shape: Easing curve name or callable. Defaults to ``"linear"``. - Use ``"ease_in_out"`` for smooth crossfades, ``"exponential"`` - for filter sweeps. See :mod:`subsequence.easing`. - - Example: - ```python - comp.conductor.line("fadein", start_val=0, end_val=1, duration_beats=64) - comp.conductor.line("filter", start_val=0, end_val=1, duration_beats=32, shape="ease_in_out") - ``` - """ - - self._signals[name] = Line(start_val, end_val, duration_beats, start_beat, loop, shape) - - def get (self, name: str, beat: float) -> float: - - """ - Retrieve the value of a signal at a specific beat time. - - Most patterns should use ``p.signal("name")`` instead, which - calls this method with the current bar time automatically. - Use ``get()`` directly when you need a beat time other than - the current bar. - """ - - if name not in self._signals: - if name not in self._warned_missing: - warnings.warn( - f"Conductor signal {name!r} not found; returning 0.0. " - "Register it with conductor.lfo() or conductor.line() first.", - stacklevel=2, - ) - self._warned_missing.add(name) - return 0.0 - - return self._signals[name].value_at(beat) + """ + A registry for global automation signals. + + The ``Conductor`` allows you to define time-varying signals (like LFOs or + ramps) that are available to all pattern builders. This is ideal for + modulating parameters (like velocity or filter cutoff) over long + compositional timeframes. + """ + + def __init__(self) -> None: + """ + Create an empty conductor; register signals with lfo() or line(). + """ + + self._signals: typing.Dict[str, Signal] = {} + self._warned_missing: typing.Set[str] = set() + + @property + def signal_names(self) -> typing.Tuple[str, ...]: + """Names of all registered signals, in sorted order.""" + return tuple(sorted(self._signals)) + + def lfo( + self, + name: str, + shape: str = "sine", + cycle_beats: float = 16.0, + min_val: float = 0.0, + max_val: float = 1.0, + phase: float = 0.0, + ) -> None: + """ + Register a named LFO signal. + + Example: + ```python + comp.conductor.lfo("swell", shape="sine", cycle_beats=32) + ``` + """ + + self._signals[name] = LFO(shape, cycle_beats, min_val, max_val, phase) + + def line( + self, + name: str, + start_val: float, + end_val: float, + duration_beats: float, + start_beat: float = 0.0, + loop: bool = False, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + ) -> None: + """ + Register a named ramp signal. + + Parameters: + name: Signal name, used to retrieve the value via ``p.signal(name)``. + start_val: Value at the start of the ramp. + end_val: Value at the end of the ramp. + duration_beats: Duration of the ramp in beats. + start_beat: Beat time at which the ramp begins (default 0). + loop: If True, the ramp restarts from start_val after each cycle. + shape: Easing curve name or callable. Defaults to ``"linear"``. + Use ``"ease_in_out"`` for smooth crossfades, ``"exponential"`` + for filter sweeps. See :mod:`subsequence.easing`. + + Example: + ```python + comp.conductor.line("fadein", start_val=0, end_val=1, duration_beats=64) + comp.conductor.line("filter", start_val=0, end_val=1, duration_beats=32, shape="ease_in_out") + ``` + """ + + self._signals[name] = Line( + start_val, end_val, duration_beats, start_beat, loop, shape + ) + + def get(self, name: str, beat: float) -> float: + """ + Retrieve the value of a signal at a specific beat time. + + Most patterns should use ``p.signal("name")`` instead, which + calls this method with the current bar time automatically. + Use ``get()`` directly when you need a beat time other than + the current bar. + """ + + if name not in self._signals: + if name not in self._warned_missing: + warnings.warn( + f"Conductor signal {name!r} not found; returning 0.0. " + "Register it with conductor.lfo() or conductor.line() first.", + stacklevel=2, + ) + self._warned_missing.add(name) + return 0.0 + + return self._signals[name].value_at(beat) diff --git a/subsequence/constants/instruments/gm_cc.py b/subsequence/constants/instruments/gm_cc.py index 99d7272..eea7df7 100644 --- a/subsequence/constants/instruments/gm_cc.py +++ b/subsequence/constants/instruments/gm_cc.py @@ -7,22 +7,22 @@ 1. **As constants** - reference CC numbers directly when sending MIDI CC messages:: - import subsequence.constants.instruments.gm_cc as gm_cc + import subsequence.constants.instruments.gm_cc as gm_cc - @composition.pattern(...) - def sweep (p): - p.cc(gm_cc.FILTER_CUTOFF, 127) + @composition.pattern(...) + def sweep (p): + p.cc(gm_cc.FILTER_CUTOFF, 127) 2. **As a cc_name_map** - pass ``GM_CC_MAP`` to the ``cc_name_map`` parameter of ``@composition.pattern()`` and use human-readable names like ``"filter_cutoff"`` or ``"sustain_pedal"`` in your CC calls:: - import subsequence.constants.instruments.gm_cc as gm_cc + import subsequence.constants.instruments.gm_cc as gm_cc - @composition.pattern(channel=1, beats=4, cc_name_map=gm_cc.GM_CC_MAP) - def synth (p): - p.cc("filter_cutoff", 100) - p.cc_ramp("expression", 0, 127, shape="ease_in") + @composition.pattern(channel=1, beats=4, cc_name_map=gm_cc.GM_CC_MAP) + def synth (p): + p.cc("filter_cutoff", 100) + p.cc_ramp("expression", 0, 127, shape="ease_in") Canonical source: `pymididefs `_. """ @@ -41,6 +41,6 @@ def synth (p): # GM_CC_MAP extends the canonical CC_MAP with the backward-compat alias. GM_CC_MAP: typing.Dict[str, int] = { - **CC_MAP, - "foot_pedal_lsb": FOOT_PEDAL_LSB, + **CC_MAP, + "foot_pedal_lsb": FOOT_PEDAL_LSB, } diff --git a/subsequence/constants/instruments/gm_drums.py b/subsequence/constants/instruments/gm_drums.py index 1aca724..193b928 100644 --- a/subsequence/constants/instruments/gm_drums.py +++ b/subsequence/constants/instruments/gm_drums.py @@ -48,6 +48,6 @@ def drums (p): # assignment deliberately specialises the spec map re-exported by ``import *`` # above (hence the no-redef suppression). GM_DRUM_MAP: typing.Dict[str, int] = { # type: ignore[no-redef] - **pymididefs.drums.GM_DRUM_MAP, - **pymididefs.drums.GM_DRUM_PRIMARY_ALIASES, + **pymididefs.drums.GM_DRUM_MAP, + **pymididefs.drums.GM_DRUM_PRIMARY_ALIASES, } diff --git a/subsequence/constants/instruments/gm_instruments.py b/subsequence/constants/instruments/gm_instruments.py index 0aea270..4149f73 100644 --- a/subsequence/constants/instruments/gm_instruments.py +++ b/subsequence/constants/instruments/gm_instruments.py @@ -26,7 +26,7 @@ def strings (p): # Re-export everything from pymididefs.gm - all instrument constants and lookup tables. from pymididefs.gm import * # noqa: F401,F403 from pymididefs.gm import ( # noqa: F401 - explicit re-exports for type checkers - GM_FAMILIES, - GM_INSTRUMENT_MAP, - GM_INSTRUMENT_NAMES, + GM_FAMILIES, + GM_INSTRUMENT_MAP, + GM_INSTRUMENT_NAMES, ) diff --git a/subsequence/constants/instruments/roland_tr8s.py b/subsequence/constants/instruments/roland_tr8s.py index 9c5a53e..d366876 100644 --- a/subsequence/constants/instruments/roland_tr8s.py +++ b/subsequence/constants/instruments/roland_tr8s.py @@ -61,17 +61,17 @@ def drums (p): # Drum note constants # ═══════════════════════════════════════════════════════════════════════ -BD = 36 # Bass Drum -SD = 38 # Snare Drum -LT = 43 # Low Tom -MT = 47 # Mid Tom -HT = 50 # High Tom -RS = 37 # Rim Shot -HC = 39 # Hand Clap -CH = 42 # Closed Hi-Hat -OH = 46 # Open Hi-Hat -CC = 49 # Crash Cymbal -RC = 51 # Ride Cymbal +BD = 36 # Bass Drum +SD = 38 # Snare Drum +LT = 43 # Low Tom +MT = 47 # Mid Tom +HT = 50 # High Tom +RS = 37 # Rim Shot +HC = 39 # Hand Clap +CH = 42 # Closed Hi-Hat +OH = 46 # Open Hi-Hat +CC = 49 # Crash Cymbal +RC = 51 # Ride Cymbal # Alternate note numbers (configurable on UTILITY:MIDI:Inst Note) BD_ALT = 35 @@ -91,17 +91,17 @@ def drums (p): # CC constants — global controls # ═══════════════════════════════════════════════════════════════════════ -SHUFFLE = 9 # Shuffle amount -EXTERNAL_IN_LEVEL = 12 # External input level -AUTO_FILL_IN = 14 # Auto fill in on/off -MASTER_FX_ON = 15 # Master FX on/off -DELAY_LEVEL = 16 # Delay send level -DELAY_TIME = 17 # Delay time -DELAY_FEEDBACK = 18 # Delay feedback -MASTER_FX_CTRL = 19 # Master FX control knob +SHUFFLE = 9 # Shuffle amount +EXTERNAL_IN_LEVEL = 12 # External input level +AUTO_FILL_IN = 14 # Auto fill in on/off +MASTER_FX_ON = 15 # Master FX on/off +DELAY_LEVEL = 16 # Delay send level +DELAY_TIME = 17 # Delay time +DELAY_FEEDBACK = 18 # Delay feedback +MASTER_FX_CTRL = 19 # Master FX control knob AUTO_FILL_IN_MANUAL = 70 # Auto fill in manual trigger -ACCENT = 71 # Accent level -REVERB_LEVEL = 91 # Reverb send level +ACCENT = 71 # Accent level +REVERB_LEVEL = 91 # Reverb send level # ═══════════════════════════════════════════════════════════════════════ @@ -109,70 +109,70 @@ def drums (p): # ═══════════════════════════════════════════════════════════════════════ # Bass Drum -BD_TUNE = 20 +BD_TUNE = 20 BD_DECAY = 23 BD_LEVEL = 24 -BD_CTRL = 96 +BD_CTRL = 96 # Snare Drum -SD_TUNE = 25 +SD_TUNE = 25 SD_DECAY = 28 SD_LEVEL = 29 -SD_CTRL = 97 +SD_CTRL = 97 # Low Tom -LT_TUNE = 46 +LT_TUNE = 46 LT_DECAY = 47 LT_LEVEL = 48 -LT_CTRL = 102 +LT_CTRL = 102 # Mid Tom -MT_TUNE = 49 +MT_TUNE = 49 MT_DECAY = 50 MT_LEVEL = 51 -MT_CTRL = 103 +MT_CTRL = 103 # High Tom -HT_TUNE = 52 +HT_TUNE = 52 HT_DECAY = 53 HT_LEVEL = 54 -HT_CTRL = 104 +HT_CTRL = 104 # Rim Shot -RS_TUNE = 55 +RS_TUNE = 55 RS_DECAY = 56 RS_LEVEL = 57 -RS_CTRL = 105 +RS_CTRL = 105 # Hand Clap -HC_TUNE = 58 +HC_TUNE = 58 HC_DECAY = 59 HC_LEVEL = 60 -HC_CTRL = 106 +HC_CTRL = 106 # Closed Hi-Hat -CH_TUNE = 61 +CH_TUNE = 61 CH_DECAY = 62 CH_LEVEL = 63 -CH_CTRL = 107 +CH_CTRL = 107 # Open Hi-Hat -OH_TUNE = 80 +OH_TUNE = 80 OH_DECAY = 81 OH_LEVEL = 82 -OH_CTRL = 108 +OH_CTRL = 108 # Crash Cymbal -CC_TUNE = 83 +CC_TUNE = 83 CC_DECAY = 84 CC_LEVEL = 85 -CC_CTRL = 109 +CC_CTRL = 109 # Ride Cymbal -RC_TUNE = 86 +RC_TUNE = 86 RC_DECAY = 87 RC_LEVEL = 88 -RC_CTRL = 110 +RC_CTRL = 110 # ═══════════════════════════════════════════════════════════════════════ @@ -180,57 +180,54 @@ def drums (p): # ═══════════════════════════════════════════════════════════════════════ ROLAND_TR8S_DRUM_MAP: typing.Dict[str, int] = { - # Native TR-8S track names - "bd": BD, - "sd": SD, - "lt": LT, - "mt": MT, - "ht": HT, - "rs": RS, - "hc": HC, - "ch": CH, - "oh": OH, - "cc": CC, - "rc": RC, - - # General MIDI aliases — *faithful correspondences only*, i.e. GM names for - # the voices the TR-8S genuinely has. Canonical names come from - # ``pymididefs.drums`` (``GM_DRUM_MAP``). This lets GM-named patterns play - # on the TR-8S and lets it take part in symbolic mirroring (each device - # re-resolves a shared drum name through its own map). Voices the TR-8S - # lacks (cowbell, tambourine, congas, splash/Chinese cymbals, guiro, …) are - # deliberately NOT aliased — no creative approximations onto unrelated voices. - # - # Unnumbered "primary" aliases (the GM_DRUM_PRIMARY_ALIASES convention from - # pymididefs.drums): the bare name resolves to the same voice as the _1 - # variant. The TR-8S has a real kick / snare / crash / ride, so all four apply. - "kick": BD, - "snare": SD, - "crash": CC, - "ride": RC, - - "kick_1": BD, - "kick_2": BD, - "snare_1": SD, - "snare_2": SD, - "side_stick": RS, # GM 37 == TR-8S RS 37 - "hand_clap": HC, # GM 39 == TR-8S HC 39 - "hi_hat_closed": CH, # GM 42 == TR-8S CH 42 - "hi_hat_pedal": CH, # foot-closed hat -> the closed hi-hat voice - "hi_hat_open": OH, # GM 46 == TR-8S OH 46 - "crash_1": CC, # GM 49 == TR-8S CC 49 - "crash_2": CC, - "ride_1": RC, # GM 51 == TR-8S RC 51 - "ride_2": RC, - - # GM's six toms onto the TR-8S's three, grouped by register (each TR-8S tom - # anchored on an exact note match: LT 43, MT 47, HT 50). - "low_floor_tom": LT, - "high_floor_tom": LT, - "low_tom": MT, - "low_mid_tom": MT, - "high_mid_tom": HT, - "high_tom": HT, + # Native TR-8S track names + "bd": BD, + "sd": SD, + "lt": LT, + "mt": MT, + "ht": HT, + "rs": RS, + "hc": HC, + "ch": CH, + "oh": OH, + "cc": CC, + "rc": RC, + # General MIDI aliases — *faithful correspondences only*, i.e. GM names for + # the voices the TR-8S genuinely has. Canonical names come from + # ``pymididefs.drums`` (``GM_DRUM_MAP``). This lets GM-named patterns play + # on the TR-8S and lets it take part in symbolic mirroring (each device + # re-resolves a shared drum name through its own map). Voices the TR-8S + # lacks (cowbell, tambourine, congas, splash/Chinese cymbals, guiro, …) are + # deliberately NOT aliased — no creative approximations onto unrelated voices. + # + # Unnumbered "primary" aliases (the GM_DRUM_PRIMARY_ALIASES convention from + # pymididefs.drums): the bare name resolves to the same voice as the _1 + # variant. The TR-8S has a real kick / snare / crash / ride, so all four apply. + "kick": BD, + "snare": SD, + "crash": CC, + "ride": RC, + "kick_1": BD, + "kick_2": BD, + "snare_1": SD, + "snare_2": SD, + "side_stick": RS, # GM 37 == TR-8S RS 37 + "hand_clap": HC, # GM 39 == TR-8S HC 39 + "hi_hat_closed": CH, # GM 42 == TR-8S CH 42 + "hi_hat_pedal": CH, # foot-closed hat -> the closed hi-hat voice + "hi_hat_open": OH, # GM 46 == TR-8S OH 46 + "crash_1": CC, # GM 49 == TR-8S CC 49 + "crash_2": CC, + "ride_1": RC, # GM 51 == TR-8S RC 51 + "ride_2": RC, + # GM's six toms onto the TR-8S's three, grouped by register (each TR-8S tom + # anchored on an exact note match: LT 43, MT 47, HT 50). + "low_floor_tom": LT, + "high_floor_tom": LT, + "low_tom": MT, + "low_mid_tom": MT, + "high_mid_tom": HT, + "high_tom": HT, } @@ -239,82 +236,71 @@ def drums (p): # ═══════════════════════════════════════════════════════════════════════ ROLAND_TR8S_CC_MAP: typing.Dict[str, int] = { - # Global controls - "shuffle": SHUFFLE, - "external_in_level": EXTERNAL_IN_LEVEL, - "auto_fill_in": AUTO_FILL_IN, - "master_fx_on": MASTER_FX_ON, - "delay_level": DELAY_LEVEL, - "delay_time": DELAY_TIME, - "delay_feedback": DELAY_FEEDBACK, - "master_fx_ctrl": MASTER_FX_CTRL, - "auto_fill_in_manual": AUTO_FILL_IN_MANUAL, - "accent": ACCENT, - "reverb_level": REVERB_LEVEL, - - # Bass Drum - "bd_tune": BD_TUNE, - "bd_decay": BD_DECAY, - "bd_level": BD_LEVEL, - "bd_ctrl": BD_CTRL, - - # Snare Drum - "sd_tune": SD_TUNE, - "sd_decay": SD_DECAY, - "sd_level": SD_LEVEL, - "sd_ctrl": SD_CTRL, - - # Low Tom - "lt_tune": LT_TUNE, - "lt_decay": LT_DECAY, - "lt_level": LT_LEVEL, - "lt_ctrl": LT_CTRL, - - # Mid Tom - "mt_tune": MT_TUNE, - "mt_decay": MT_DECAY, - "mt_level": MT_LEVEL, - "mt_ctrl": MT_CTRL, - - # High Tom - "ht_tune": HT_TUNE, - "ht_decay": HT_DECAY, - "ht_level": HT_LEVEL, - "ht_ctrl": HT_CTRL, - - # Rim Shot - "rs_tune": RS_TUNE, - "rs_decay": RS_DECAY, - "rs_level": RS_LEVEL, - "rs_ctrl": RS_CTRL, - - # Hand Clap - "hc_tune": HC_TUNE, - "hc_decay": HC_DECAY, - "hc_level": HC_LEVEL, - "hc_ctrl": HC_CTRL, - - # Closed Hi-Hat - "ch_tune": CH_TUNE, - "ch_decay": CH_DECAY, - "ch_level": CH_LEVEL, - "ch_ctrl": CH_CTRL, - - # Open Hi-Hat - "oh_tune": OH_TUNE, - "oh_decay": OH_DECAY, - "oh_level": OH_LEVEL, - "oh_ctrl": OH_CTRL, - - # Crash Cymbal - "cc_tune": CC_TUNE, - "cc_decay": CC_DECAY, - "cc_level": CC_LEVEL, - "cc_ctrl": CC_CTRL, - - # Ride Cymbal - "rc_tune": RC_TUNE, - "rc_decay": RC_DECAY, - "rc_level": RC_LEVEL, - "rc_ctrl": RC_CTRL, + # Global controls + "shuffle": SHUFFLE, + "external_in_level": EXTERNAL_IN_LEVEL, + "auto_fill_in": AUTO_FILL_IN, + "master_fx_on": MASTER_FX_ON, + "delay_level": DELAY_LEVEL, + "delay_time": DELAY_TIME, + "delay_feedback": DELAY_FEEDBACK, + "master_fx_ctrl": MASTER_FX_CTRL, + "auto_fill_in_manual": AUTO_FILL_IN_MANUAL, + "accent": ACCENT, + "reverb_level": REVERB_LEVEL, + # Bass Drum + "bd_tune": BD_TUNE, + "bd_decay": BD_DECAY, + "bd_level": BD_LEVEL, + "bd_ctrl": BD_CTRL, + # Snare Drum + "sd_tune": SD_TUNE, + "sd_decay": SD_DECAY, + "sd_level": SD_LEVEL, + "sd_ctrl": SD_CTRL, + # Low Tom + "lt_tune": LT_TUNE, + "lt_decay": LT_DECAY, + "lt_level": LT_LEVEL, + "lt_ctrl": LT_CTRL, + # Mid Tom + "mt_tune": MT_TUNE, + "mt_decay": MT_DECAY, + "mt_level": MT_LEVEL, + "mt_ctrl": MT_CTRL, + # High Tom + "ht_tune": HT_TUNE, + "ht_decay": HT_DECAY, + "ht_level": HT_LEVEL, + "ht_ctrl": HT_CTRL, + # Rim Shot + "rs_tune": RS_TUNE, + "rs_decay": RS_DECAY, + "rs_level": RS_LEVEL, + "rs_ctrl": RS_CTRL, + # Hand Clap + "hc_tune": HC_TUNE, + "hc_decay": HC_DECAY, + "hc_level": HC_LEVEL, + "hc_ctrl": HC_CTRL, + # Closed Hi-Hat + "ch_tune": CH_TUNE, + "ch_decay": CH_DECAY, + "ch_level": CH_LEVEL, + "ch_ctrl": CH_CTRL, + # Open Hi-Hat + "oh_tune": OH_TUNE, + "oh_decay": OH_DECAY, + "oh_level": OH_LEVEL, + "oh_ctrl": OH_CTRL, + # Crash Cymbal + "cc_tune": CC_TUNE, + "cc_decay": CC_DECAY, + "cc_level": CC_LEVEL, + "cc_ctrl": CC_CTRL, + # Ride Cymbal + "rc_tune": RC_TUNE, + "rc_decay": RC_DECAY, + "rc_level": RC_LEVEL, + "rc_ctrl": RC_CTRL, } diff --git a/subsequence/constants/instruments/vermona_drm1_drums.py b/subsequence/constants/instruments/vermona_drm1_drums.py index df74e17..bc36ec0 100644 --- a/subsequence/constants/instruments/vermona_drm1_drums.py +++ b/subsequence/constants/instruments/vermona_drm1_drums.py @@ -40,48 +40,47 @@ def drums (p): # ─── Individual note constants ─────────────────────────────────────── -KICK = 36 # C2 -DRUM_1 = 45 # A2 -DRUM_2 = 50 # D3 -MULTI = 56 # G#3 -SNARE = 38 # D2 -HIHAT_1_CLOSED = 44 # G#2 -HIHAT_1_OPEN = 46 # A#2 (Cymbal) -HIHAT_2_CLOSED = 49 # C#3 -HIHAT_2_OPEN = 51 # D#3 (Cymbal) -CLAP = 39 # D#2 +KICK = 36 # C2 +DRUM_1 = 45 # A2 +DRUM_2 = 50 # D3 +MULTI = 56 # G#3 +SNARE = 38 # D2 +HIHAT_1_CLOSED = 44 # G#2 +HIHAT_1_OPEN = 46 # A#2 (Cymbal) +HIHAT_2_CLOSED = 49 # C#3 +HIHAT_2_OPEN = 51 # D#3 (Cymbal) +CLAP = 39 # D#2 # ─── Complete drum note map ────────────────────────────────────────── VERMONA_DRM1_DRUM_MAP: typing.Dict[str, int] = { - # Native DRM1 mapping - "kick": KICK, - "drum_1": DRUM_1, - "drum_2": DRUM_2, - "multi": MULTI, - "snare": SNARE, - "hihat_1_closed": HIHAT_1_CLOSED, - "hihat_1_open": HIHAT_1_OPEN, - "hihat_2_closed": HIHAT_2_CLOSED, - "hihat_2_open": HIHAT_2_OPEN, - "clap": CLAP, - - # General MIDI aliases — *faithful correspondences only*, i.e. GM names for - # the voices the DRM1 genuinely has. Canonical names come from - # ``pymididefs.drums`` (``GM_DRUM_MAP``); this is the shared vocabulary used - # by symbolic mirroring (each device re-resolves a drum name through its own - # map). GM names for instruments the DRM1 lacks — toms, ride/crash cymbals, - # shakers, and latin/aux percussion — are deliberately NOT aliased: a - # "creative approximation" onto an unrelated voice (cowbell -> multi, - # cymbal -> hi-hat) was an over-reach. Use the native ``drum_1`` / - # ``drum_2`` / ``multi`` names for those instead. - "kick_1": KICK, - "kick_2": KICK, - "snare_1": SNARE, - "snare_2": SNARE, - "hand_clap": CLAP, # GM 39 == DRM1 CLAP 39 - "hi_hat_closed": HIHAT_1_CLOSED, - "hi_hat_pedal": HIHAT_1_CLOSED, # foot-closed hat -> the closed hi-hat voice - "hi_hat_open": HIHAT_1_OPEN, + # Native DRM1 mapping + "kick": KICK, + "drum_1": DRUM_1, + "drum_2": DRUM_2, + "multi": MULTI, + "snare": SNARE, + "hihat_1_closed": HIHAT_1_CLOSED, + "hihat_1_open": HIHAT_1_OPEN, + "hihat_2_closed": HIHAT_2_CLOSED, + "hihat_2_open": HIHAT_2_OPEN, + "clap": CLAP, + # General MIDI aliases — *faithful correspondences only*, i.e. GM names for + # the voices the DRM1 genuinely has. Canonical names come from + # ``pymididefs.drums`` (``GM_DRUM_MAP``); this is the shared vocabulary used + # by symbolic mirroring (each device re-resolves a drum name through its own + # map). GM names for instruments the DRM1 lacks — toms, ride/crash cymbals, + # shakers, and latin/aux percussion — are deliberately NOT aliased: a + # "creative approximation" onto an unrelated voice (cowbell -> multi, + # cymbal -> hi-hat) was an over-reach. Use the native ``drum_1`` / + # ``drum_2`` / ``multi`` names for those instead. + "kick_1": KICK, + "kick_2": KICK, + "snare_1": SNARE, + "snare_2": SNARE, + "hand_clap": CLAP, # GM 39 == DRM1 CLAP 39 + "hi_hat_closed": HIHAT_1_CLOSED, + "hi_hat_pedal": HIHAT_1_CLOSED, # foot-closed hat -> the closed hi-hat voice + "hi_hat_open": HIHAT_1_OPEN, } diff --git a/subsequence/constants/midi_notes.py b/subsequence/constants/midi_notes.py index 58561c9..dbf2cc1 100644 --- a/subsequence/constants/midi_notes.py +++ b/subsequence/constants/midi_notes.py @@ -21,9 +21,9 @@ # lookup tables, and conversion functions. from pymididefs.notes import * # noqa: F401,F403 from pymididefs.notes import ( # noqa: F401 — explicit re-exports for type checkers - NOTE_CLASSES, - NOTE_NAMES, - SEMITONE_MAP, - name_to_note, - note_to_name, + NOTE_CLASSES, + NOTE_NAMES, + SEMITONE_MAP, + name_to_note, + note_to_name, ) diff --git a/subsequence/constants/velocity.py b/subsequence/constants/velocity.py index 2586088..e25fb51 100644 --- a/subsequence/constants/velocity.py +++ b/subsequence/constants/velocity.py @@ -5,13 +5,15 @@ """ # Primary defaults -DEFAULT_VELOCITY = 100 # Most notes, hits, arpeggios -DEFAULT_CHORD_VELOCITY = 90 # Chords and harmonic content (softer) +DEFAULT_VELOCITY = 100 # Most notes, hits, arpeggios +DEFAULT_CHORD_VELOCITY = 90 # Chords and harmonic content (softer) # Generative / texture defaults — named so these values don't scatter as raw literals -DEFAULT_GENERATIVE_VELOCITY = 80 # Generative melodic lines (lsystem, de_bruijn, evolve, branch, lorenz, …) -DEFAULT_CA_VELOCITY = 60 # Cellular automata (cellular_1d / cellular_2d) -GHOST_FILL_VELOCITY = 35 # Deliberately soft ghost-note layer +DEFAULT_GENERATIVE_VELOCITY = ( + 80 # Generative melodic lines (lsystem, de_bruijn, evolve, branch, lorenz, …) +) +DEFAULT_CA_VELOCITY = 60 # Cellular automata (cellular_1d / cellular_2d) +GHOST_FILL_VELOCITY = 35 # Deliberately soft ghost-note layer # Velocity shaping boundaries VELOCITY_SHAPE_LOW = 64 diff --git a/subsequence/display.py b/subsequence/display.py index 1fa8cbd..97d836a 100644 --- a/subsequence/display.py +++ b/subsequence/display.py @@ -16,13 +16,13 @@ The status line updates every beat and looks like:: - 125.00 BPM Key: E Bar: 17.1 [chorus 1/8] Chord: Em7 + 125.00 BPM Key: E Bar: 17.1 [chorus 1/8] Chord: Em7 The grid (when enabled) updates every bar and looks like:: - kick_2 |█ · · · █ · · · █ · · · █ · · ·| - snare_1 |· · · · ▓ · · · · · · · ▓ · · ·| - bass |▓ · · ▓ · · ▓ · ▓ · · · ▓ · · ·| + kick_2 |█ · · · █ · · · █ · · · █ · · ·| + snare_1 |· · · · ▓ · · · · · · · ▓ · · ·| + bass |▓ · · ▓ · · ▓ · ▓ · · · ▓ · · ·| """ import logging @@ -34,7 +34,7 @@ import subsequence.constants if typing.TYPE_CHECKING: - from subsequence.composition import Composition + from subsequence.composition import Composition _NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] @@ -46,572 +46,562 @@ class GridDisplay: + """Multi-line ASCII grid visualisation of running pattern steps. + + Renders one block per pattern showing which grid steps have notes and at + what velocity. Drum patterns (those with a ``drum_note_map``) show one + row per drum sound; pitched patterns show a single summary row. + + Not used directly — instantiated by ``Display`` when ``grid=True``. + """ + + def __init__(self, composition: "Composition", scale: float = 1.0) -> None: + """Store composition reference for reading pattern state. + + Parameters: + composition: The ``Composition`` instance to read running + patterns from. + scale: Horizontal zoom factor. Snapped to the nearest + integer ``cols_per_step`` so that all on-grid markers + are uniformly spaced. Default ``1.0`` = one visual + column per grid step (current behaviour). + """ + + self._composition = composition + self._scale = scale + self._lines: typing.List[str] = [] + + @property + def line_count(self) -> int: + """Number of terminal lines the grid currently occupies.""" + + return len(self._lines) + + # ------------------------------------------------------------------ + # Static helpers + # ------------------------------------------------------------------ + + @staticmethod + def _velocity_char(velocity: typing.Union[int, float]) -> str: + """Map a MIDI velocity (0-127) to a single ANSI block character. + + Returns: + ``">"`` for sustain (note still sounding), + ``"░"`` for velocity > 0 to < 31.75 (0 - < 25%), + ``"▒"`` for velocity >= 31.75 to < 63.5 (25% to < 50%), + ``"▓"`` for velocity >= 63.5 to < 95.25 (50% to < 75%), + ``"█"`` for velocity >= 95.25 (75% to 100%). + """ + + if velocity == _SUSTAIN: + return ">" + if velocity == 0: + return "·" + + pct = velocity / 127.0 + if pct < 0.25: + return "░" + if pct < 0.50: + return "▒" + if pct < 0.75: + return "▓" + return "█" + + @staticmethod + def _cell_char(velocity: typing.Union[int, float], is_on_grid: bool) -> str: + """Return the display character for a grid cell. + + Non-zero velocities (attacks and sustain) always show their + velocity glyph. Empty on-grid positions show ``"·"``, empty + between-grid positions show ``" "`` (space). + """ + + if velocity != 0: + return GridDisplay._velocity_char(velocity) + return "·" if is_on_grid else " " + + @staticmethod + def _midi_note_name(pitch: int) -> str: + """Convert a MIDI note number to a human-readable name. + + Examples: 60 → ``"C4"``, 42 → ``"F#2"``, 36 → ``"C2"``. + """ + + octave = (pitch // 12) - 1 + note = _NOTE_NAMES[pitch % 12] + return f"{note}{octave}" + + # ------------------------------------------------------------------ + # Grid building + # ------------------------------------------------------------------ + + def build(self) -> None: + """Rebuild grid lines from the current state of all running patterns.""" + + lines: typing.List[str] = [] + term_width = shutil.get_terminal_size(fallback=(80, 24)).columns + + if term_width < _MIN_TERMINAL_WIDTH: + self._lines = [] + return + + for name, pattern in self._composition.running_patterns.items(): + grid_size = min(getattr(pattern, "_default_grid", 16), _MAX_GRID_COLUMNS) + muted = getattr(pattern, "_muted", False) + drum_map: typing.Optional[typing.Dict[str, int]] = getattr( + pattern, "_drum_note_map", None + ) + + # Snap to integer cols_per_step for uniform marker spacing. + cols_per_step = max(1, round(self._scale)) + visual_cols = grid_size * cols_per_step + display_cols = self._fit_columns(visual_cols, term_width) + + # On-grid columns: exact multiples of cols_per_step. + on_grid = frozenset( + i * cols_per_step + for i in range(grid_size) + if i * cols_per_step < display_cols + ) + + if muted: + lines.extend(self._render_muted(name, display_cols, on_grid)) + elif drum_map: + lines.extend( + self._render_drum_pattern( + name, pattern, drum_map, visual_cols, display_cols, on_grid + ) + ) + else: + lines.extend( + self._render_pitched_pattern( + name, pattern, visual_cols, display_cols, on_grid + ) + ) + + self._lines = lines + + # ------------------------------------------------------------------ + # Rendering helpers + # ------------------------------------------------------------------ + + def _render_muted( + self, name: str, display_cols: int, on_grid: typing.FrozenSet[int] + ) -> typing.List[str]: + """Render a muted pattern as a single row of dashes.""" + + cells = " ".join("-" if col in on_grid else " " for col in range(display_cols)) + label = f"({name})"[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) + return [f"{label}|{cells}|"] + + def _render_drum_pattern( + self, + name: str, + pattern: typing.Any, + drum_map: typing.Dict[str, int], + visual_cols: int, + display_cols: int, + on_grid: typing.FrozenSet[int], + ) -> typing.List[str]: + """Render a drum pattern with one row per distinct drum sound.""" + + lines: typing.List[str] = [] + + # Build reverse map: {midi_note: drum_name}. + reverse_map: typing.Dict[int, str] = {} + for drum_name, midi_note in drum_map.items(): + if midi_note not in reverse_map: + reverse_map[midi_note] = drum_name + + # Discover which pitches are present in the pattern. + velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) + + if not velocity_grid: + return lines + + # Sort rows by MIDI pitch (lowest first — kick before hi-hat). + + for pitch in sorted(velocity_grid): + label_text = reverse_map.get(pitch, self._midi_note_name(pitch)) + label = label_text[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) + cells = " ".join( + self._cell_char(v, col in on_grid) + for col, v in enumerate(velocity_grid[pitch][:display_cols]) + ) + lines.append(f"{label}|{cells}|") + + return lines + + def _render_pitched_pattern( + self, + name: str, + pattern: typing.Any, + visual_cols: int, + display_cols: int, + on_grid: typing.FrozenSet[int], + ) -> typing.List[str]: + """Render a pitched pattern as a single summary row.""" + + # Collapse all pitches into a single row using max velocity per slot. + velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) + + summary = [0] * display_cols + for pitch_velocities in velocity_grid.values(): + for i, vel in enumerate(pitch_velocities[:display_cols]): + if vel > summary[i] or (vel == _SUSTAIN and summary[i] == 0): + summary[i] = vel + + label = name[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) + cells = " ".join( + self._cell_char(v, col in on_grid) for col, v in enumerate(summary) + ) + return [f"{label}|{cells}|"] + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_velocity_grid( + self, + pattern: typing.Any, + grid_size: int, + display_cols: int, + ) -> typing.Dict[int, typing.List[int]]: + """Scan pattern steps and build a {pitch: [velocity_per_slot]} dict. + + Each pitch gets a list of length *display_cols*. At each grid slot + the highest velocity from any note at that position is stored. + """ + + total_pulses = int(pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - """Multi-line ASCII grid visualisation of running pattern steps. + if total_pulses <= 0 or grid_size <= 0: + return {} - Renders one block per pattern showing which grid steps have notes and at - what velocity. Drum patterns (those with a ``drum_note_map``) show one - row per drum sound; pitched patterns show a single summary row. + pulses_per_slot = total_pulses / grid_size - Not used directly — instantiated by ``Display`` when ``grid=True``. - """ + velocity_grid: typing.Dict[int, typing.List[int]] = {} - def __init__ (self, composition: "Composition", scale: float = 1.0) -> None: + for pulse, step in pattern.steps.items(): + # Map pulse → grid slot. + slot = int(pulse / pulses_per_slot) - """Store composition reference for reading pattern state. + if slot < 0 or slot >= display_cols: + continue - Parameters: - composition: The ``Composition`` instance to read running - patterns from. - scale: Horizontal zoom factor. Snapped to the nearest - integer ``cols_per_step`` so that all on-grid markers - are uniformly spaced. Default ``1.0`` = one visual - column per grid step (current behaviour). - """ + for note in step.notes: + if note.pitch not in velocity_grid: + velocity_grid[note.pitch] = [0] * display_cols - self._composition = composition - self._scale = scale - self._lines: typing.List[str] = [] + if note.velocity > velocity_grid[note.pitch][slot]: + velocity_grid[note.pitch][slot] = note.velocity - @property - def line_count (self) -> int: + # Fill sustain markers for slots where the note is + # still sounding. Short notes (drums, staccato) never + # enter this loop. + end_pulse = pulse + note.duration + for s in range(slot + 1, display_cols): + if s * pulses_per_slot >= end_pulse: + break + if velocity_grid[note.pitch][s] == 0: + velocity_grid[note.pitch][s] = _SUSTAIN - """Number of terminal lines the grid currently occupies.""" + return velocity_grid - return len(self._lines) + @staticmethod + def _fit_columns(grid_size: int, term_width: int) -> int: + """Determine how many grid columns fit in the terminal. - # ------------------------------------------------------------------ - # Static helpers - # ------------------------------------------------------------------ + Each column occupies 2 characters (char + space), plus the label + prefix and pipe delimiters. + """ - @staticmethod - def _velocity_char (velocity: typing.Union[int, float]) -> str: + # label (LABEL_WIDTH) + "|" + cells + "|" + # Each cell is "X " (2 chars) but last cell has no trailing space + # inside the pipes: "X . X ." is grid_size * 2 - 1 chars. + overhead = _LABEL_WIDTH + 2 # label + pipes + available = term_width - overhead - """Map a MIDI velocity (0-127) to a single ANSI block character. + if available <= 0: + return 0 - Returns: - ``">"`` for sustain (note still sounding), - ``"░"`` for velocity > 0 to < 31.75 (0 - < 25%), - ``"▒"`` for velocity >= 31.75 to < 63.5 (25% to < 50%), - ``"▓"`` for velocity >= 63.5 to < 95.25 (50% to < 75%), - ``"█"`` for velocity >= 95.25 (75% to 100%). - """ + # Each column needs 2 chars (char + space), except the last needs 1. + max_cols = (available + 1) // 2 - if velocity == _SUSTAIN: - return ">" - if velocity == 0: - return "·" - - pct = velocity / 127.0 - if pct < 0.25: - return "░" - if pct < 0.50: - return "▒" - if pct < 0.75: - return "▓" - return "█" + return min(grid_size, max_cols) - @staticmethod - def _cell_char (velocity: typing.Union[int, float], is_on_grid: bool) -> str: - """Return the display character for a grid cell. +class DisplayLogHandler(logging.Handler): + """Logging handler that clears and redraws the status line around log output. - Non-zero velocities (attacks and sustain) always show their - velocity glyph. Empty on-grid positions show ``"·"``, empty - between-grid positions show ``" "`` (space). - """ + Installed by ``Display.start()`` and removed by ``Display.stop()``. Ensures + log messages do not overwrite or corrupt the persistent status line. + """ - if velocity != 0: - return GridDisplay._velocity_char(velocity) - return "·" if is_on_grid else " " + def __init__(self, display: "Display") -> None: + """Store reference to the display for clear/redraw calls.""" - @staticmethod - def _midi_note_name (pitch: int) -> str: + super().__init__() + self._display = display - """Convert a MIDI note number to a human-readable name. + def emit(self, record: logging.LogRecord) -> None: + """Clear the status line, write the log message, then redraw.""" - Examples: 60 → ``"C4"``, 42 → ``"F#2"``, 36 → ``"C2"``. - """ + try: + # emit() runs on whatever thread logged; hold the display's render + # lock across the whole clear → write → redraw sequence so a + # concurrent event-loop draw() cannot interleave ANSI sequences. + with self._display._render_lock: + self._display.clear_line() - octave = (pitch // 12) - 1 - note = _NOTE_NAMES[pitch % 12] - return f"{note}{octave}" + msg = self.format(record) + sys.stderr.write(msg + "\n") + sys.stderr.flush() - # ------------------------------------------------------------------ - # Grid building - # ------------------------------------------------------------------ + self._display.draw() - def build (self) -> None: - - """Rebuild grid lines from the current state of all running patterns.""" - - lines: typing.List[str] = [] - term_width = shutil.get_terminal_size(fallback=(80, 24)).columns - - if term_width < _MIN_TERMINAL_WIDTH: - self._lines = [] - return - - for name, pattern in self._composition.running_patterns.items(): - - grid_size = min(getattr(pattern, "_default_grid", 16), _MAX_GRID_COLUMNS) - muted = getattr(pattern, "_muted", False) - drum_map: typing.Optional[typing.Dict[str, int]] = getattr(pattern, "_drum_note_map", None) - - # Snap to integer cols_per_step for uniform marker spacing. - cols_per_step = max(1, round(self._scale)) - visual_cols = grid_size * cols_per_step - display_cols = self._fit_columns(visual_cols, term_width) - - # On-grid columns: exact multiples of cols_per_step. - on_grid = frozenset( - i * cols_per_step - for i in range(grid_size) - if i * cols_per_step < display_cols - ) - - if muted: - lines.extend(self._render_muted(name, display_cols, on_grid)) - elif drum_map: - lines.extend(self._render_drum_pattern(name, pattern, drum_map, visual_cols, display_cols, on_grid)) - else: - lines.extend(self._render_pitched_pattern(name, pattern, visual_cols, display_cols, on_grid)) - - self._lines = lines - - # ------------------------------------------------------------------ - # Rendering helpers - # ------------------------------------------------------------------ - - def _render_muted (self, name: str, display_cols: int, on_grid: typing.FrozenSet[int]) -> typing.List[str]: - - """Render a muted pattern as a single row of dashes.""" - - cells = " ".join("-" if col in on_grid else " " for col in range(display_cols)) - label = f"({name})"[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) - return [f"{label}|{cells}|"] - - def _render_drum_pattern ( - self, - name: str, - pattern: typing.Any, - drum_map: typing.Dict[str, int], - visual_cols: int, - display_cols: int, - on_grid: typing.FrozenSet[int], - ) -> typing.List[str]: - - """Render a drum pattern with one row per distinct drum sound.""" - - lines: typing.List[str] = [] - - # Build reverse map: {midi_note: drum_name}. - reverse_map: typing.Dict[int, str] = {} - for drum_name, midi_note in drum_map.items(): - if midi_note not in reverse_map: - reverse_map[midi_note] = drum_name - - # Discover which pitches are present in the pattern. - velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) - - if not velocity_grid: - return lines - - # Sort rows by MIDI pitch (lowest first — kick before hi-hat). - - for pitch in sorted(velocity_grid): - label_text = reverse_map.get(pitch, self._midi_note_name(pitch)) - label = label_text[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) - cells = " ".join( - self._cell_char(v, col in on_grid) - for col, v in enumerate(velocity_grid[pitch][:display_cols]) - ) - lines.append(f"{label}|{cells}|") - - return lines - - def _render_pitched_pattern ( - self, - name: str, - pattern: typing.Any, - visual_cols: int, - display_cols: int, - on_grid: typing.FrozenSet[int], - ) -> typing.List[str]: - - """Render a pitched pattern as a single summary row.""" - - # Collapse all pitches into a single row using max velocity per slot. - velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) - - summary = [0] * display_cols - for pitch_velocities in velocity_grid.values(): - for i, vel in enumerate(pitch_velocities[:display_cols]): - if vel > summary[i] or (vel == _SUSTAIN and summary[i] == 0): - summary[i] = vel - - label = name[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) - cells = " ".join( - self._cell_char(v, col in on_grid) - for col, v in enumerate(summary) - ) - return [f"{label}|{cells}|"] - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _build_velocity_grid ( - self, - pattern: typing.Any, - grid_size: int, - display_cols: int, - ) -> typing.Dict[int, typing.List[int]]: - - """Scan pattern steps and build a {pitch: [velocity_per_slot]} dict. - - Each pitch gets a list of length *display_cols*. At each grid slot - the highest velocity from any note at that position is stored. - """ - - total_pulses = int(pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - - if total_pulses <= 0 or grid_size <= 0: - return {} - - pulses_per_slot = total_pulses / grid_size - - velocity_grid: typing.Dict[int, typing.List[int]] = {} - - for pulse, step in pattern.steps.items(): - # Map pulse → grid slot. - slot = int(pulse / pulses_per_slot) - - if slot < 0 or slot >= display_cols: - continue - - for note in step.notes: - if note.pitch not in velocity_grid: - velocity_grid[note.pitch] = [0] * display_cols - - if note.velocity > velocity_grid[note.pitch][slot]: - velocity_grid[note.pitch][slot] = note.velocity - - # Fill sustain markers for slots where the note is - # still sounding. Short notes (drums, staccato) never - # enter this loop. - end_pulse = pulse + note.duration - for s in range(slot + 1, display_cols): - if s * pulses_per_slot >= end_pulse: - break - if velocity_grid[note.pitch][s] == 0: - velocity_grid[note.pitch][s] = _SUSTAIN - - return velocity_grid - - @staticmethod - def _fit_columns (grid_size: int, term_width: int) -> int: - - """Determine how many grid columns fit in the terminal. - - Each column occupies 2 characters (char + space), plus the label - prefix and pipe delimiters. - """ - - # label (LABEL_WIDTH) + "|" + cells + "|" - # Each cell is "X " (2 chars) but last cell has no trailing space - # inside the pipes: "X . X ." is grid_size * 2 - 1 chars. - overhead = _LABEL_WIDTH + 2 # label + pipes - available = term_width - overhead - - if available <= 0: - return 0 - - # Each column needs 2 chars (char + space), except the last needs 1. - max_cols = (available + 1) // 2 - - return min(grid_size, max_cols) - - -class DisplayLogHandler (logging.Handler): - - """Logging handler that clears and redraws the status line around log output. - - Installed by ``Display.start()`` and removed by ``Display.stop()``. Ensures - log messages do not overwrite or corrupt the persistent status line. - """ - - def __init__ (self, display: "Display") -> None: - - """Store reference to the display for clear/redraw calls.""" - - super().__init__() - self._display = display - - def emit (self, record: logging.LogRecord) -> None: - - """Clear the status line, write the log message, then redraw.""" - - try: - # emit() runs on whatever thread logged; hold the display's render - # lock across the whole clear → write → redraw sequence so a - # concurrent event-loop draw() cannot interleave ANSI sequences. - with self._display._render_lock: - - self._display.clear_line() - - msg = self.format(record) - sys.stderr.write(msg + "\n") - sys.stderr.flush() - - self._display.draw() - - except Exception: - self.handleError(record) + except Exception: + self.handleError(record) class Display: - - """Live-updating terminal dashboard showing composition state. - - Reads bar, section, chord, BPM, and key from the ``Composition`` and renders - a persistent region to stderr. When ``grid=True`` an ASCII pattern grid is - rendered above the status line. A custom ``DisplayLogHandler`` ensures log - messages scroll cleanly above the dashboard. - - Example: - ```python - composition.display(grid=True) - composition.play() - ``` - """ - - def __init__ (self, composition: "Composition", grid: bool = False, grid_scale: float = 1.0) -> None: - - """Store composition reference for reading playback state. - - Parameters: - composition: The ``Composition`` instance to read state from. - grid: When True, render an ASCII grid of running patterns - above the status line. - grid_scale: Horizontal zoom factor for the grid (default - ``1.0``). Snapped internally to the nearest integer - ``cols_per_step`` for uniform marker spacing. - """ - - self._composition = composition - self._active: bool = False - self._handler: typing.Optional[DisplayLogHandler] = None - self._saved_handlers: typing.List[logging.Handler] = [] - self._last_line: str = "" - self._last_bar: typing.Optional[int] = None - self._cached_section: typing.Any = None - self._grid: typing.Optional[GridDisplay] = GridDisplay(composition, scale=grid_scale) if grid else None - self._last_grid_bar: typing.Optional[int] = None - self._drawn_line_count: int = 0 - # Serialises terminal writes: update()/draw() run on the event loop, - # but DisplayLogHandler.emit() runs on whatever thread logged (e.g. - # the web UI's HTTP worker) — unsynchronised, an emit mid-draw would - # interleave ANSI sequences and garble the dashboard. RLock because - # emit() holds it across its clear_line() → write → draw() sequence. - self._render_lock = threading.RLock() - - def start (self) -> None: - - """Install the log handler and activate the display. - - Saves existing root logger handlers and replaces them with a - ``DisplayLogHandler`` that clears/redraws the status line around - each log message. Original handlers are restored by ``stop()``. - """ - - if self._active: - return - - self._active = True - - root_logger = logging.getLogger() - - # Save existing handlers so we can restore them on stop. - self._saved_handlers = list(root_logger.handlers) - - # Build the replacement handler, inheriting the formatter from the - # first existing handler (if any) for consistent log formatting. - self._handler = DisplayLogHandler(self) - - if self._saved_handlers and self._saved_handlers[0].formatter: - self._handler.setFormatter(self._saved_handlers[0].formatter) - else: - self._handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s")) - - root_logger.handlers.clear() - root_logger.addHandler(self._handler) - - def stop (self) -> None: - - """Clear the status line and restore original log handlers.""" - - if not self._active: - return - - self.clear_line() - self._active = False - - root_logger = logging.getLogger() - root_logger.handlers.clear() - - for handler in self._saved_handlers: - root_logger.addHandler(handler) - - self._saved_handlers = [] - self._handler = None - - def update (self, _: int = 0) -> None: - - """Rebuild and redraw the dashboard; called on ``"bar"`` and ``"beat"`` events. - - The integer argument (bar or beat number) is ignored — state is read directly from - the composition. - - Note: "bar" and "beat" events are emitted as ``asyncio.create_task`` at the start - of each pulse, but the tasks only execute *after* ``_advance_pulse()`` completes - (which includes sending MIDI via ``_process_pulse()``). The display therefore - always trails the audio slightly — this is inherent to the architecture and cannot - be avoided without restructuring the sequencer loop. - """ - - if not self._active: - return - - self._last_line = self._format_status() - - # Rebuild grid data only when the bar counter changes. - if self._grid is not None: - current_bar = self._composition.sequencer.current_bar - if current_bar != self._last_grid_bar: - self._last_grid_bar = current_bar - self._grid.build() - - self.draw() - - def draw (self) -> None: - - """Write the current dashboard to the terminal.""" - - if not self._active or not self._last_line: - return - - with self._render_lock: - - grid_lines = self._grid._lines if self._grid is not None else [] - total = len(grid_lines) + 1 # grid lines + status line - - if grid_lines: - total += 1 # separator line - - # Move cursor up to overwrite the previously drawn region. - # Cursor sits on the last line (status) with no trailing newline, - # so we move up (total - 1) to reach the first line. - if self._drawn_line_count > 1: - sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") - - if grid_lines: - sep = "-" * len(grid_lines[0]) - sys.stderr.write(f"\r\033[K{sep}\n") - for line in grid_lines: - sys.stderr.write(f"\r\033[K{line}\n") - - # Status line (no trailing newline — cursor stays on this line). - sys.stderr.write(f"\r\033[K{self._last_line}") - # Clear to end of screen in case the grid shrank since the last draw - # (e.g. a pattern disappeared), which would otherwise leave the old - # status line stranded one line below the new one. - sys.stderr.write("\033[J") - sys.stderr.flush() - - self._drawn_line_count = total - - def clear_line (self) -> None: - - """Erase the entire dashboard region from the terminal.""" - - if not self._active: - return - - with self._render_lock: - - if self._drawn_line_count > 1: - # Cursor is on the last line (no trailing newline). - # Move up (total - 1) to reach the first line. - sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") - - # Clear each line. - for _ in range(self._drawn_line_count): - sys.stderr.write("\r\033[K\n") - - # Move cursor back up to the starting position. - sys.stderr.write(f"\033[{self._drawn_line_count}A") - else: - sys.stderr.write("\r\033[K") - - sys.stderr.flush() - self._drawn_line_count = 0 - - def _format_status (self) -> str: - - """Build the status string from current composition state.""" - - parts: typing.List[str] = [] - comp = self._composition - - parts.append(f"{comp.sequencer.current_bpm:.2f} BPM") - - if comp.key: - parts.append(f"Key: {comp.key}") - - bar = max(0, comp.sequencer.current_bar) + 1 - beat = max(0, comp.sequencer.current_beat) + 1 - parts.append(f"Bar: {bar}.{beat}") - - # Section info (only when form is configured). - # Cache refreshes only when the bar counter changes, keeping - # the section display in sync with the bar display even though - # the form state advances one beat early (due to lookahead). - if comp.form_state is not None: - current_bar = comp.sequencer.current_bar - - if current_bar != self._last_bar: - self._last_bar = current_bar - self._cached_section = comp.form_state.get_section_info() - - section = self._cached_section - - if section: - section_str = f"[{section.name} {section.bar + 1}/{section.bars}" - if section.next_section: - section_str += f" \u2192 {section.next_section}" - section_str += "]" - parts.append(section_str) - else: - parts.append("[form finished]") - - # Current chord (only when harmony is configured). Reads the harmony - # window at the playhead, so it tracks sub-bar harmonic rhythm and - # covers progression-only mode (no engine) — the display refreshes - # per beat, which bounds how stale it can be. - chord = comp.current_chord() - if chord is not None: - parts.append(f"Chord: {chord.name()}") - - # Conductor signals (when any are registered). builder_bar is the - # lookahead bar - deliberately the same time base the pattern builders - # read, so the status line shows the values shaping what you are about - # to hear (the section line above shows playing-bar time instead). - conductor = comp.conductor - if conductor.signal_names: - beat = comp.builder_bar * comp.sequencer.time_signature[0] - for name in conductor.signal_names: - value = conductor.get(name, beat) - parts.append(f"{name.title()}: {value:.2f}") - - return " ".join(parts) + """Live-updating terminal dashboard showing composition state. + + Reads bar, section, chord, BPM, and key from the ``Composition`` and renders + a persistent region to stderr. When ``grid=True`` an ASCII pattern grid is + rendered above the status line. A custom ``DisplayLogHandler`` ensures log + messages scroll cleanly above the dashboard. + + Example: + ```python + composition.display(grid=True) + composition.play() + ``` + """ + + def __init__( + self, composition: "Composition", grid: bool = False, grid_scale: float = 1.0 + ) -> None: + """Store composition reference for reading playback state. + + Parameters: + composition: The ``Composition`` instance to read state from. + grid: When True, render an ASCII grid of running patterns + above the status line. + grid_scale: Horizontal zoom factor for the grid (default + ``1.0``). Snapped internally to the nearest integer + ``cols_per_step`` for uniform marker spacing. + """ + + self._composition = composition + self._active: bool = False + self._handler: typing.Optional[DisplayLogHandler] = None + self._saved_handlers: typing.List[logging.Handler] = [] + self._last_line: str = "" + self._last_bar: typing.Optional[int] = None + self._cached_section: typing.Any = None + self._grid: typing.Optional[GridDisplay] = ( + GridDisplay(composition, scale=grid_scale) if grid else None + ) + self._last_grid_bar: typing.Optional[int] = None + self._drawn_line_count: int = 0 + # Serialises terminal writes: update()/draw() run on the event loop, + # but DisplayLogHandler.emit() runs on whatever thread logged (e.g. + # the web UI's HTTP worker) — unsynchronised, an emit mid-draw would + # interleave ANSI sequences and garble the dashboard. RLock because + # emit() holds it across its clear_line() → write → draw() sequence. + self._render_lock = threading.RLock() + + def start(self) -> None: + """Install the log handler and activate the display. + + Saves existing root logger handlers and replaces them with a + ``DisplayLogHandler`` that clears/redraws the status line around + each log message. Original handlers are restored by ``stop()``. + """ + + if self._active: + return + + self._active = True + + root_logger = logging.getLogger() + + # Save existing handlers so we can restore them on stop. + self._saved_handlers = list(root_logger.handlers) + + # Build the replacement handler, inheriting the formatter from the + # first existing handler (if any) for consistent log formatting. + self._handler = DisplayLogHandler(self) + + if self._saved_handlers and self._saved_handlers[0].formatter: + self._handler.setFormatter(self._saved_handlers[0].formatter) + else: + self._handler.setFormatter( + logging.Formatter("%(levelname)s:%(name)s:%(message)s") + ) + + root_logger.handlers.clear() + root_logger.addHandler(self._handler) + + def stop(self) -> None: + """Clear the status line and restore original log handlers.""" + + if not self._active: + return + + self.clear_line() + self._active = False + + root_logger = logging.getLogger() + root_logger.handlers.clear() + + for handler in self._saved_handlers: + root_logger.addHandler(handler) + + self._saved_handlers = [] + self._handler = None + + def update(self, _: int = 0) -> None: + """Rebuild and redraw the dashboard; called on ``"bar"`` and ``"beat"`` events. + + The integer argument (bar or beat number) is ignored — state is read directly from + the composition. + + Note: "bar" and "beat" events are emitted as ``asyncio.create_task`` at the start + of each pulse, but the tasks only execute *after* ``_advance_pulse()`` completes + (which includes sending MIDI via ``_process_pulse()``). The display therefore + always trails the audio slightly — this is inherent to the architecture and cannot + be avoided without restructuring the sequencer loop. + """ + + if not self._active: + return + + self._last_line = self._format_status() + + # Rebuild grid data only when the bar counter changes. + if self._grid is not None: + current_bar = self._composition.sequencer.current_bar + if current_bar != self._last_grid_bar: + self._last_grid_bar = current_bar + self._grid.build() + + self.draw() + + def draw(self) -> None: + """Write the current dashboard to the terminal.""" + + if not self._active or not self._last_line: + return + + with self._render_lock: + grid_lines = self._grid._lines if self._grid is not None else [] + total = len(grid_lines) + 1 # grid lines + status line + + if grid_lines: + total += 1 # separator line + + # Move cursor up to overwrite the previously drawn region. + # Cursor sits on the last line (status) with no trailing newline, + # so we move up (total - 1) to reach the first line. + if self._drawn_line_count > 1: + sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") + + if grid_lines: + sep = "-" * len(grid_lines[0]) + sys.stderr.write(f"\r\033[K{sep}\n") + for line in grid_lines: + sys.stderr.write(f"\r\033[K{line}\n") + + # Status line (no trailing newline — cursor stays on this line). + sys.stderr.write(f"\r\033[K{self._last_line}") + # Clear to end of screen in case the grid shrank since the last draw + # (e.g. a pattern disappeared), which would otherwise leave the old + # status line stranded one line below the new one. + sys.stderr.write("\033[J") + sys.stderr.flush() + + self._drawn_line_count = total + + def clear_line(self) -> None: + """Erase the entire dashboard region from the terminal.""" + + if not self._active: + return + + with self._render_lock: + if self._drawn_line_count > 1: + # Cursor is on the last line (no trailing newline). + # Move up (total - 1) to reach the first line. + sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") + + # Clear each line. + for _ in range(self._drawn_line_count): + sys.stderr.write("\r\033[K\n") + + # Move cursor back up to the starting position. + sys.stderr.write(f"\033[{self._drawn_line_count}A") + else: + sys.stderr.write("\r\033[K") + + sys.stderr.flush() + self._drawn_line_count = 0 + + def _format_status(self) -> str: + """Build the status string from current composition state.""" + + parts: typing.List[str] = [] + comp = self._composition + + parts.append(f"{comp.sequencer.current_bpm:.2f} BPM") + + if comp.key: + parts.append(f"Key: {comp.key}") + + bar = max(0, comp.sequencer.current_bar) + 1 + beat = max(0, comp.sequencer.current_beat) + 1 + parts.append(f"Bar: {bar}.{beat}") + + # Section info (only when form is configured). + # Cache refreshes only when the bar counter changes, keeping + # the section display in sync with the bar display even though + # the form state advances one beat early (due to lookahead). + if comp.form_state is not None: + current_bar = comp.sequencer.current_bar + + if current_bar != self._last_bar: + self._last_bar = current_bar + self._cached_section = comp.form_state.get_section_info() + + section = self._cached_section + + if section: + section_str = f"[{section.name} {section.bar + 1}/{section.bars}" + if section.next_section: + section_str += f" \u2192 {section.next_section}" + section_str += "]" + parts.append(section_str) + else: + parts.append("[form finished]") + + # Current chord (only when harmony is configured). Reads the harmony + # window at the playhead, so it tracks sub-bar harmonic rhythm and + # covers progression-only mode (no engine) — the display refreshes + # per beat, which bounds how stale it can be. + chord = comp.current_chord() + if chord is not None: + parts.append(f"Chord: {chord.name()}") + + # Conductor signals (when any are registered). builder_bar is the + # lookahead bar - deliberately the same time base the pattern builders + # read, so the status line shows the values shaping what you are about + # to hear (the section line above shows playing-bar time instead). + conductor = comp.conductor + if conductor.signal_names: + beat = comp.builder_bar * comp.sequencer.time_signature[0] + for name in conductor.signal_names: + value = conductor.get(name, beat) + parts.append(f"{name.title()}: {value:.2f}") + + return " ".join(parts) diff --git a/subsequence/easing.py b/subsequence/easing.py index d740131..f975921 100644 --- a/subsequence/easing.py +++ b/subsequence/easing.py @@ -7,22 +7,22 @@ Pass a name string or a plain callable to any ``shape`` parameter: - composition.conductor.line("filter", 0, 1, 64, shape="ease_in_out") - composition.target_bpm(140, bars=8, shape="s_curve") - p.cc_ramp(74, 0, 127, shape="exponential") + composition.conductor.line("filter", 0, 1, 64, shape="ease_in_out") + composition.target_bpm(140, bars=8, shape="s_curve") + p.cc_ramp(74, 0, 127, shape="exponential") - # Custom callable — receives and returns a float in [0, 1]: - p.cc_ramp(74, 0, 127, shape=lambda t: t ** 0.5) + # Custom callable — receives and returns a float in [0, 1]: + p.cc_ramp(74, 0, 127, shape=lambda t: t ** 0.5) Available shapes: - "linear" Constant rate (default). - "ease_in" Slow start, accelerates — fade-ins, building tension. - "ease_out" Fast start, decelerates — fade-outs, natural decay. - "ease_in_out" Smooth S-curve (Hermite smoothstep) — BPM changes, crossfades. - "exponential" Very slow start, rapid end (cubic) — filter sweeps. - "logarithmic" Rapid start, very gradual end (cubic) — volume fades. - "s_curve" Smoother S-curve (Perlin smootherstep) — long, gentle transitions. + "linear" Constant rate (default). + "ease_in" Slow start, accelerates — fade-ins, building tension. + "ease_out" Fast start, decelerates — fade-outs, natural decay. + "ease_in_out" Smooth S-curve (Hermite smoothstep) — BPM changes, crossfades. + "exponential" Very slow start, rapid end (cubic) — filter sweeps. + "logarithmic" Rapid start, very gradual end (cubic) — volume fades. + "s_curve" Smoother S-curve (Perlin smootherstep) — long, gentle transitions. All functions satisfy f(0) = 0 and f(1) = 1 and are monotonically non-decreasing. Input outside [0, 1] is not defined. @@ -36,53 +36,53 @@ # ─── Easing functions ───────────────────────────────────────────────────────── -def linear (t: float) -> float: - """No transformation — constant rate of change.""" - return t +def linear(t: float) -> float: + """No transformation — constant rate of change.""" + return t -def ease_in (t: float) -> float: - """Quadratic ease-in: slow start, accelerates toward the end.""" - return t * t +def ease_in(t: float) -> float: + """Quadratic ease-in: slow start, accelerates toward the end.""" + return t * t -def ease_out (t: float) -> float: - """Quadratic ease-out: fast start, decelerates toward the end.""" - return 1.0 - (1.0 - t) * (1.0 - t) +def ease_out(t: float) -> float: + """Quadratic ease-out: fast start, decelerates toward the end.""" + return 1.0 - (1.0 - t) * (1.0 - t) -def ease_in_out (t: float) -> float: - """Hermite smoothstep S-curve: smooth start and end, faster in the middle.""" - return t * t * (3.0 - 2.0 * t) +def ease_in_out(t: float) -> float: + """Hermite smoothstep S-curve: smooth start and end, faster in the middle.""" + return t * t * (3.0 - 2.0 * t) -def exponential (t: float) -> float: - """Cubic ease-in: very slow start with rapid acceleration. +def exponential(t: float) -> float: + """Cubic ease-in: very slow start with rapid acceleration. - Approximates a perceptually linear response for audio parameters like - filter cutoff, where the human ear's logarithmic sensitivity means a - slow early ramp sounds more even. - """ - return t * t * t + Approximates a perceptually linear response for audio parameters like + filter cutoff, where the human ear's logarithmic sensitivity means a + slow early ramp sounds more even. + """ + return t * t * t -def logarithmic (t: float) -> float: - """Cubic ease-out: rapid initial change that tapers to a gradual end. +def logarithmic(t: float) -> float: + """Cubic ease-out: rapid initial change that tapers to a gradual end. - Useful for decay shapes and volume fades where most of the audible change - happens early and the tail fades imperceptibly. - """ - return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t) + Useful for decay shapes and volume fades where most of the audible change + happens early and the tail fades imperceptibly. + """ + return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t) -def s_curve (t: float) -> float: - """Perlin smootherstep: a smoother S-curve than ease_in_out. +def s_curve(t: float) -> float: + """Perlin smootherstep: a smoother S-curve than ease_in_out. - Has zero first *and* second derivatives at t=0 and t=1, eliminating the - subtle acceleration jerk at the boundaries. Best for long, slow - transitions where the smoothness is perceptible. - """ - return t * t * t * (t * (t * 6.0 - 15.0) + 10.0) + Has zero first *and* second derivatives at t=0 and t=1, eliminating the + subtle acceleration jerk at the boundaries. Best for long, slow + transitions where the smoothness is perceptible. + """ + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0) # ─── Registry and lookup ────────────────────────────────────────────────────── @@ -90,258 +90,254 @@ def s_curve (t: float) -> float: EasingFn = typing.Callable[[float], float] EASING_FUNCTIONS: typing.Dict[str, EasingFn] = { - "linear": linear, - "ease_in": ease_in, - "ease_out": ease_out, - "ease_in_out": ease_in_out, - "exponential": exponential, - "logarithmic": logarithmic, - "s_curve": s_curve, + "linear": linear, + "ease_in": ease_in, + "ease_out": ease_out, + "ease_in_out": ease_in_out, + "exponential": exponential, + "logarithmic": logarithmic, + "s_curve": s_curve, } -def get_easing (shape: typing.Union[str, EasingFn]) -> EasingFn: - """Return the easing function for *shape*. - - *shape* may be a name string (see :data:`EASING_FUNCTIONS`) or any - callable that maps a float in [0, 1] to a float in [0, 1]. - - Raises :class:`ValueError` for unknown string names. - """ - if callable(shape): - return shape - if shape not in EASING_FUNCTIONS: - available = ", ".join(f'"{k}"' for k in sorted(EASING_FUNCTIONS)) - raise ValueError( - f"Unknown easing shape {shape!r}. Available shapes: {available}" - ) - return EASING_FUNCTIONS[shape] - -def map_value ( - value: float, - in_min: float = 0.0, - in_max: float = 1.0, - out_min: float = 0.0, - out_max: float = 1.0, - shape: typing.Union[str, EasingFn] = "linear", - clamp: bool = True +def get_easing(shape: typing.Union[str, EasingFn]) -> EasingFn: + """Return the easing function for *shape*. + + *shape* may be a name string (see :data:`EASING_FUNCTIONS`) or any + callable that maps a float in [0, 1] to a float in [0, 1]. + + Raises :class:`ValueError` for unknown string names. + """ + if callable(shape): + return shape + if shape not in EASING_FUNCTIONS: + available = ", ".join(f'"{k}"' for k in sorted(EASING_FUNCTIONS)) + raise ValueError( + f"Unknown easing shape {shape!r}. Available shapes: {available}" + ) + return EASING_FUNCTIONS[shape] + + +def map_value( + value: float, + in_min: float = 0.0, + in_max: float = 1.0, + out_min: float = 0.0, + out_max: float = 1.0, + shape: typing.Union[str, EasingFn] = "linear", + clamp: bool = True, ) -> float: - """Map a value from an input range to an output range, with optional easing. + """Map a value from an input range to an output range, with optional easing. - Linearly maps *value* from the range ``[in_min, in_max]`` into a normalised - progress ratio [0.0, 1.0], applies the designated easing curve, and - interpolates that eased ratio into the output range ``[out_min, out_max]``. + Linearly maps *value* from the range ``[in_min, in_max]`` into a normalised + progress ratio [0.0, 1.0], applies the designated easing curve, and + interpolates that eased ratio into the output range ``[out_min, out_max]``. - This is particularly useful for musically scaling raw generative outputs - (which usually fall between 0.0 and 1.0) into MIDI ranges like pitch or - velocity, while automatically applying musical volume or tension curves. + This is particularly useful for musically scaling raw generative outputs + (which usually fall between 0.0 and 1.0) into MIDI ranges like pitch or + velocity, while automatically applying musical volume or tension curves. - Parameters: - value: The raw input to scale. - in_min: The lower bound of the input's expected range. - in_max: The upper bound of the input's expected range. - out_min: The lower bound of the mapped output range. - out_max: The upper bound of the mapped output range. - shape: The easing curve to apply to the mapped ratio before - outputting (e.g. \"linear\", \"ease_in_out\"). See - :func:`get_easing` for all available shapes. - clamp: If True (the default), values outside the input range - will be clamped so they never exceed the output bounds. - Essential for ensuring MIDI values don't break valid ranges. + Parameters: + value: The raw input to scale. + in_min: The lower bound of the input's expected range. + in_max: The upper bound of the input's expected range. + out_min: The lower bound of the mapped output range. + out_max: The upper bound of the mapped output range. + shape: The easing curve to apply to the mapped ratio before + outputting (e.g. \"linear\", \"ease_in_out\"). See + :func:`get_easing` for all available shapes. + clamp: If True (the default), values outside the input range + will be clamped so they never exceed the output bounds. + Essential for ensuring MIDI values don't break valid ranges. - Returns: - The mapped and eased value as a float. - """ + Returns: + The mapped and eased value as a float. + """ - if in_min == in_max: - return out_min + if in_min == in_max: + return out_min - # 1. Normalise to [0.0, 1.0] - t = (value - in_min) / (in_max - in_min) + # 1. Normalise to [0.0, 1.0] + t = (value - in_min) / (in_max - in_min) - # 2. Clamp - if clamp: - t = max(0.0, min(1.0, t)) + # 2. Clamp + if clamp: + t = max(0.0, min(1.0, t)) - # 3. Apply easing curve - eased_t = get_easing(shape)(t) + # 3. Apply easing curve + eased_t = get_easing(shape)(t) - # 4. Map to output range - return out_min + (out_max - out_min) * eased_t + # 4. Map to output range + return out_min + (out_max - out_min) * eased_t # ─── Batch helpers ──────────────────────────────────────────────────────────── -def ramp ( - n: int, - low: float = 0.0, - high: float = 1.0, - shape: typing.Union[str, EasingFn] = "linear", +def ramp( + n: int, + low: float = 0.0, + high: float = 1.0, + shape: typing.Union[str, EasingFn] = "linear", ) -> typing.List[float]: + """Return a list of *n* values eased from *low* to *high*. - """Return a list of *n* values eased from *low* to *high*. + This is the batch equivalent of :func:`map_value` for the common case + of generating a fixed-length sequence that sweeps from one value to + another. Useful for velocity ramps, density envelopes, filter sweeps, + or any per-step value that should follow a curve. - This is the batch equivalent of :func:`map_value` for the common case - of generating a fixed-length sequence that sweeps from one value to - another. Useful for velocity ramps, density envelopes, filter sweeps, - or any per-step value that should follow a curve. + Parameters: + n: Number of values to generate. + low: The value at the first step (t = 0). + high: The value at the last step (t = 1). + shape: Easing curve name or callable (see :data:`EASING_FUNCTIONS`). - Parameters: - n: Number of values to generate. - low: The value at the first step (t = 0). - high: The value at the last step (t = 1). - shape: Easing curve name or callable (see :data:`EASING_FUNCTIONS`). + Returns: + ``List[float]`` of length *n*. For MIDI velocity use, cast to int: + ``[int(v) for v in easing.ramp(p.grid, 50, 100, "ease_in_out")]`` - Returns: - ``List[float]`` of length *n*. For MIDI velocity use, cast to int: - ``[int(v) for v in easing.ramp(p.grid, 50, 100, "ease_in_out")]`` + Example:: - Example:: + # Snare roll that swells into a hit + velocities = [int(v) for v in easing.ramp(p.grid, 30, 100, "ease_in")] + p.sequence(steps=range(16), pitches="snare_1", velocities=velocities) - # Snare roll that swells into a hit - velocities = [int(v) for v in easing.ramp(p.grid, 30, 100, "ease_in")] - p.sequence(steps=range(16), pitches="snare_1", velocities=velocities) + # Ghost fill velocity envelope passed directly (float values accepted) + p.ghost_fill("snare_1", 1, velocity=easing.ramp(p.grid, 20, 80, "ease_out"), + bias="sixteenths", no_overlap=True) + """ - # Ghost fill velocity envelope passed directly (float values accepted) - p.ghost_fill("snare_1", 1, velocity=easing.ramp(p.grid, 20, 80, "ease_out"), - bias="sixteenths", no_overlap=True) - """ - - fn = get_easing(shape) - if n == 1: - return [float(low)] - return [low + (high - low) * fn(i / (n - 1)) for i in range(n)] + fn = get_easing(shape) + if n == 1: + return [float(low)] + return [low + (high - low) * fn(i / (n - 1)) for i in range(n)] # ─── Stateful interpolation ─────────────────────────────────────────────────── class EasedValue: - - """Smoothly interpolates between discrete data updates. - - When external data arrives in snapshots — API polls, sensor readings, - OSC messages — jumping instantly to each new value often sounds jarring. - ``EasedValue`` remembers the previous value and provides a smooth, - eased interpolation to the new one over a normalised progress window. - - A typical use-case is a ``composition.schedule()`` function that writes - to ``composition.data`` every *N* bars, paired with a pattern that reads - the smoothed value on every rebuild: - - Example:: - - # Module level: create one per data field you want to smooth. - iss_lat = subsequence.easing.EasedValue(initial=0.5) - - # Scheduled task (fires every 16 bars): - def fetch_data (p): - new_lat = get_latest_latitude() # 0.0–1.0 - iss_lat.update(new_lat) - - # Pattern (rebuilds every bar). 16-bar cycle matches the schedule. - @composition.pattern(channel=0, length=4) - def drums (p): - progress = (p.cycle % 16) / 16 # 0 → 1 over one fetch cycle - velocity = int(100 * iss_lat.get(progress)) - p.hit_steps("kick_1", range(16), velocity=velocity) - - Args: - initial: Optional starting value. If provided, the first call to - :meth:`update` will ease from this initial value. If omitted, - the first call to :meth:`update` will instantly set both the - *previous* and *current* values to the new target, preventing - an unintended transition from a default value. - """ - - def __init__ (self, initial: typing.Optional[float] = None) -> None: - - # We keep the internal float fields strictly non-Optional (defaulting to 0.0) - # to guarantee mypy safety and branch-free math in get() and delta. The - # _has_updated flag abstracts the "first-update" logic away safely. - val = initial if initial is not None else 0.0 - self._prev: float = val - self._current: float = val - self._has_updated: bool = initial is not None - - def update (self, value: float) -> None: - - """Accept a new target value. - - The current value becomes the new *previous* baseline, and - *value* becomes the target that :meth:`get` interpolates toward. - If no ``initial`` value was provided at construction, the very first - call to this method sets both *previous* and *current* to *value*. - - Args: - value: The new target, typically a normalised float in [0, 1] - (though any numeric range is accepted as long as consumers - interpret it consistently). - """ - - if not self._has_updated: - self._prev = value - self._current = value - self._has_updated = True - else: - self._prev = self._current - self._current = value - - def get ( - self, - progress: float, - shape: typing.Union[str, EasingFn] = "ease_in_out", - ) -> float: - - """Return the interpolated value at *progress* through the transition. - - Args: - progress: How far through the current transition, in [0, 1]. - ``0.0`` returns the previous value; ``1.0`` returns the - current target. Typically computed as - ``(p.cycle % N) / N`` where *N* is the number of pattern - cycles per data-fetch cycle. - shape: Easing shape name (see :data:`EASING_FUNCTIONS`) or a - callable ``f(t) -> t`` in [0, 1]. Defaults to - ``"ease_in_out"`` (Hermite smoothstep). - - Returns: - The interpolated float between the previous and current value. - """ - - eased = get_easing(shape)(progress) - return self._prev + (self._current - self._prev) * eased - - @property - def current (self) -> float: - """The most recently set target value (after the last :meth:`update`).""" - return self._current - - @property - def previous (self) -> float: - """The value that was current before the last :meth:`update`.""" - return self._prev - - @property - def delta (self) -> float: - """Signed change between the previous and current value. - - Positive means the value rose on the last :meth:`update`; negative - means it fell; zero means it was unchanged. The magnitude reflects - the size of the jump. - - This property is constant across all pattern rebuilds within one fetch - cycle, making it straightforward to branch on direction without - worrying about sample-level fluctuations: - - Example:: - - # Choose arpeggio direction based on which way the value is moving. - direction = "up" if iss_lat.delta >= 0 else "down" - p.arpeggio(pitches, spacing=0.25, direction=direction) - - # Scale an effect by how large the change was. - urgency = abs(iss_lat.delta) # 0.0 = stable, larger = big jump - """ - return self._current - self._prev + """Smoothly interpolates between discrete data updates. + + When external data arrives in snapshots — API polls, sensor readings, + OSC messages — jumping instantly to each new value often sounds jarring. + ``EasedValue`` remembers the previous value and provides a smooth, + eased interpolation to the new one over a normalised progress window. + + A typical use-case is a ``composition.schedule()`` function that writes + to ``composition.data`` every *N* bars, paired with a pattern that reads + the smoothed value on every rebuild: + + Example:: + + # Module level: create one per data field you want to smooth. + iss_lat = subsequence.easing.EasedValue(initial=0.5) + + # Scheduled task (fires every 16 bars): + def fetch_data (p): + new_lat = get_latest_latitude() # 0.0–1.0 + iss_lat.update(new_lat) + + # Pattern (rebuilds every bar). 16-bar cycle matches the schedule. + @composition.pattern(channel=0, length=4) + def drums (p): + progress = (p.cycle % 16) / 16 # 0 → 1 over one fetch cycle + velocity = int(100 * iss_lat.get(progress)) + p.hit_steps("kick_1", range(16), velocity=velocity) + + Args: + initial: Optional starting value. If provided, the first call to + :meth:`update` will ease from this initial value. If omitted, + the first call to :meth:`update` will instantly set both the + *previous* and *current* values to the new target, preventing + an unintended transition from a default value. + """ + + def __init__(self, initial: typing.Optional[float] = None) -> None: + # We keep the internal float fields strictly non-Optional (defaulting to 0.0) + # to guarantee mypy safety and branch-free math in get() and delta. The + # _has_updated flag abstracts the "first-update" logic away safely. + val = initial if initial is not None else 0.0 + self._prev: float = val + self._current: float = val + self._has_updated: bool = initial is not None + + def update(self, value: float) -> None: + """Accept a new target value. + + The current value becomes the new *previous* baseline, and + *value* becomes the target that :meth:`get` interpolates toward. + If no ``initial`` value was provided at construction, the very first + call to this method sets both *previous* and *current* to *value*. + + Args: + value: The new target, typically a normalised float in [0, 1] + (though any numeric range is accepted as long as consumers + interpret it consistently). + """ + + if not self._has_updated: + self._prev = value + self._current = value + self._has_updated = True + else: + self._prev = self._current + self._current = value + + def get( + self, + progress: float, + shape: typing.Union[str, EasingFn] = "ease_in_out", + ) -> float: + """Return the interpolated value at *progress* through the transition. + + Args: + progress: How far through the current transition, in [0, 1]. + ``0.0`` returns the previous value; ``1.0`` returns the + current target. Typically computed as + ``(p.cycle % N) / N`` where *N* is the number of pattern + cycles per data-fetch cycle. + shape: Easing shape name (see :data:`EASING_FUNCTIONS`) or a + callable ``f(t) -> t`` in [0, 1]. Defaults to + ``"ease_in_out"`` (Hermite smoothstep). + + Returns: + The interpolated float between the previous and current value. + """ + + eased = get_easing(shape)(progress) + return self._prev + (self._current - self._prev) * eased + + @property + def current(self) -> float: + """The most recently set target value (after the last :meth:`update`).""" + return self._current + + @property + def previous(self) -> float: + """The value that was current before the last :meth:`update`.""" + return self._prev + + @property + def delta(self) -> float: + """Signed change between the previous and current value. + + Positive means the value rose on the last :meth:`update`; negative + means it fell; zero means it was unchanged. The magnitude reflects + the size of the jump. + + This property is constant across all pattern rebuilds within one fetch + cycle, making it straightforward to branch on direction without + worrying about sample-level fluctuations: + + Example:: + + # Choose arpeggio direction based on which way the value is moving. + direction = "up" if iss_lat.delta >= 0 else "down" + p.arpeggio(pitches, spacing=0.25, direction=direction) + + # Scale an effect by how large the change was. + urgency = abs(iss_lat.delta) # 0.0 = stable, larger = big jump + """ + return self._current - self._prev diff --git a/subsequence/event_emitter.py b/subsequence/event_emitter.py index 45a5c0f..0f36732 100644 --- a/subsequence/event_emitter.py +++ b/subsequence/event_emitter.py @@ -10,100 +10,101 @@ class EventEmitter: - - """ - A simple event emitter supporting sync and async callbacks. - """ - - def __init__ (self) -> None: - - """ - Initialize an empty event registry. - """ - - self._listeners: typing.Dict[str, typing.List[CallbackType]] = {} - - - def on (self, event_name: str, callback: CallbackType) -> None: - - """ - Register a callback for an event name. - """ - - if event_name not in self._listeners: - self._listeners[event_name] = [] - - self._listeners[event_name].append(callback) - - def off (self, event_name: str, callback: CallbackType) -> None: - - """ - Unregister a previously registered callback. - - Raises ``ValueError`` if the callback is not registered for the event. - """ - - if event_name not in self._listeners or callback not in self._listeners[event_name]: - raise ValueError(f"Callback not registered for event {event_name!r}") - - self._listeners[event_name].remove(callback) - - - def emit_sync (self, event_name: str, *args: typing.Any, **kwargs: typing.Any) -> None: - - """ - Emit an event and call non-async listeners immediately. - """ - - if event_name not in self._listeners: - return - - for callback in self._listeners[event_name]: - - if inspect.iscoroutinefunction(callback): - raise ValueError("Async callback encountered in emit_sync") - - result = callback(*args, **kwargs) - - # Catch async-callable objects too (async __call__ fails the - # iscoroutinefunction check but still returns an awaitable). - if inspect.isawaitable(result): - typing.cast(typing.Coroutine, result).close() - raise ValueError("Async callback encountered in emit_sync") - - - async def emit_async (self, event_name: str, *args: typing.Any, **kwargs: typing.Any) -> None: - - """ - Emit an event, awaiting async listeners. - - One raising listener never silences the others: sync exceptions are - logged and the remaining listeners still run, and async listeners are - gathered with their exceptions logged individually. - """ - - if event_name not in self._listeners: - return - - tasks: typing.List[typing.Awaitable[typing.Any]] = [] - - for callback in self._listeners[event_name]: - - # Calling first and checking the RESULT handles both coroutine - # functions and async-callable objects (async __call__), which - # iscoroutinefunction misses. - try: - result = callback(*args, **kwargs) - except Exception: - logger.exception("Listener for %r raised - continuing with remaining listeners", event_name) - continue - - if inspect.isawaitable(result): - tasks.append(result) - - if tasks: - results = await asyncio.gather(*tasks, return_exceptions=True) - - for outcome in results: - if isinstance(outcome, BaseException): - logger.error("Async listener for %r raised", event_name, exc_info=outcome) + """ + A simple event emitter supporting sync and async callbacks. + """ + + def __init__(self) -> None: + """ + Initialize an empty event registry. + """ + + self._listeners: typing.Dict[str, typing.List[CallbackType]] = {} + + def on(self, event_name: str, callback: CallbackType) -> None: + """ + Register a callback for an event name. + """ + + if event_name not in self._listeners: + self._listeners[event_name] = [] + + self._listeners[event_name].append(callback) + + def off(self, event_name: str, callback: CallbackType) -> None: + """ + Unregister a previously registered callback. + + Raises ``ValueError`` if the callback is not registered for the event. + """ + + if ( + event_name not in self._listeners + or callback not in self._listeners[event_name] + ): + raise ValueError(f"Callback not registered for event {event_name!r}") + + self._listeners[event_name].remove(callback) + + def emit_sync( + self, event_name: str, *args: typing.Any, **kwargs: typing.Any + ) -> None: + """ + Emit an event and call non-async listeners immediately. + """ + + if event_name not in self._listeners: + return + + for callback in self._listeners[event_name]: + if inspect.iscoroutinefunction(callback): + raise ValueError("Async callback encountered in emit_sync") + + result = callback(*args, **kwargs) + + # Catch async-callable objects too (async __call__ fails the + # iscoroutinefunction check but still returns an awaitable). + if inspect.isawaitable(result): + typing.cast(typing.Coroutine, result).close() + raise ValueError("Async callback encountered in emit_sync") + + async def emit_async( + self, event_name: str, *args: typing.Any, **kwargs: typing.Any + ) -> None: + """ + Emit an event, awaiting async listeners. + + One raising listener never silences the others: sync exceptions are + logged and the remaining listeners still run, and async listeners are + gathered with their exceptions logged individually. + """ + + if event_name not in self._listeners: + return + + tasks: typing.List[typing.Awaitable[typing.Any]] = [] + + for callback in self._listeners[event_name]: + # Calling first and checking the RESULT handles both coroutine + # functions and async-callable objects (async __call__), which + # iscoroutinefunction misses. + try: + result = callback(*args, **kwargs) + except Exception: + logger.exception( + "Listener for %r raised - continuing with remaining listeners", + event_name, + ) + continue + + if inspect.isawaitable(result): + tasks.append(result) + + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + + for outcome in results: + if isinstance(outcome, BaseException): + logger.error( + "Async listener for %r raised", event_name, exc_info=outcome + ) diff --git a/subsequence/form_state.py b/subsequence/form_state.py index 3d2a4ea..3b1650f 100644 --- a/subsequence/form_state.py +++ b/subsequence/form_state.py @@ -33,633 +33,647 @@ @dataclasses.dataclass class SectionInfo: - - """ - An immutable snapshot of the current section in the compositional form. - - Patterns read ``p.section`` to make context-aware decisions, such as increasing - intensity as a section progresses or playing variation only in certain blocks. - - Attributes: - name: The string name of the section (e.g., "verse"). - bar: The current bar index within this section (0-indexed). - bars: Total number of bars in this section. - index: The global index of this section in the form's timeline. - next_section: The name of the upcoming section (or ``None`` if the - form will end after this section). This is pre-decided when - the current section begins, so patterns can plan lead-ins. - A performer or code can override it with ``composition.form_next()``. - energy: The section's energy payload (0.5 unless a bound - :class:`~subsequence.forms.Form` says otherwise; the - ``composition.energy()`` dict overrides it at read time). - key: The section's key override, or ``None`` (a higher tier — form - key, then composition key — supplies it). - scale: The section's scale/mode override, or ``None`` (falls back - through the form scale to the composition scale). - - Example: - ```python - @composition.pattern(channel=9) - def drums (p): - # Always play a basic kick - p.hit_steps("kick", [0, 8]) - - # Only add snare and hats during the "chorus" - if p.section and p.section.name == "chorus": - p.hit_steps("snare", [4, 12]) - - # Use .progress (0.0 to 1.0) to build a riser - vel = int(60 + 40 * p.section.progress) - p.hit_steps("hh", list(range(16)), velocity=vel) - - # Plan a lead-in on the last bar before a different section - if p.section and p.section.ending: - p.hit_steps("snare", [0, 2, 4, 6, 8, 10, 12, 14], velocity=100) - ``` - """ - - name: str - bar: int - bars: int - index: int - next_section: typing.Optional[str] = None - energy: float = 0.5 - key: typing.Optional[str] = None - scale: typing.Optional[str] = None - - @property - def progress (self) -> float: - - """Return how far through this section we are (0.0 to ~1.0).""" - - if self.bars <= 0: - return 0.0 - - return self.bar / self.bars - - @property - def first_bar (self) -> bool: - - """Return True if this is the first bar of the section.""" - - return self.bar == 0 - - @property - def last_bar (self) -> bool: - - """Return True if this is the last bar of the section.""" - - return self.bar == self.bars - 1 - - @property - def ending (self) -> bool: - - """True on the last bar before a DIFFERENT section. - - A repeat (verse → verse) is not an ending, and neither is the - form's end — ``ending`` marks the bars where transition material - (fills, mutes) belongs. - """ - - return ( - self.last_bar - and self.next_section is not None - and self.next_section != self.name - ) + """ + An immutable snapshot of the current section in the compositional form. + + Patterns read ``p.section`` to make context-aware decisions, such as increasing + intensity as a section progresses or playing variation only in certain blocks. + + Attributes: + name: The string name of the section (e.g., "verse"). + bar: The current bar index within this section (0-indexed). + bars: Total number of bars in this section. + index: The global index of this section in the form's timeline. + next_section: The name of the upcoming section (or ``None`` if the + form will end after this section). This is pre-decided when + the current section begins, so patterns can plan lead-ins. + A performer or code can override it with ``composition.form_next()``. + energy: The section's energy payload (0.5 unless a bound + :class:`~subsequence.forms.Form` says otherwise; the + ``composition.energy()`` dict overrides it at read time). + key: The section's key override, or ``None`` (a higher tier — form + key, then composition key — supplies it). + scale: The section's scale/mode override, or ``None`` (falls back + through the form scale to the composition scale). + + Example: + ```python + @composition.pattern(channel=9) + def drums (p): + # Always play a basic kick + p.hit_steps("kick", [0, 8]) + + # Only add snare and hats during the "chorus" + if p.section and p.section.name == "chorus": + p.hit_steps("snare", [4, 12]) + + # Use .progress (0.0 to 1.0) to build a riser + vel = int(60 + 40 * p.section.progress) + p.hit_steps("hh", list(range(16)), velocity=vel) + + # Plan a lead-in on the last bar before a different section + if p.section and p.section.ending: + p.hit_steps("snare", [0, 2, 4, 6, 8, 10, 12, 14], velocity=100) + ``` + """ + + name: str + bar: int + bars: int + index: int + next_section: typing.Optional[str] = None + energy: float = 0.5 + key: typing.Optional[str] = None + scale: typing.Optional[str] = None + + @property + def progress(self) -> float: + """Return how far through this section we are (0.0 to ~1.0).""" + + if self.bars <= 0: + return 0.0 + + return self.bar / self.bars + + @property + def first_bar(self) -> bool: + """Return True if this is the first bar of the section.""" + + return self.bar == 0 + + @property + def last_bar(self) -> bool: + """Return True if this is the last bar of the section.""" + + return self.bar == self.bars - 1 + + @property + def ending(self) -> bool: + """True on the last bar before a DIFFERENT section. + + A repeat (verse → verse) is not an ending, and neither is the + form's end — ``ending`` marks the bars where transition material + (fills, mutes) belongs. + """ + + return ( + self.last_bar + and self.next_section is not None + and self.next_section != self.name + ) class FormState: - - """Track compositional form as a sequence of named sections with bar durations.""" - - def __init__ ( - self, - sections: typing.Union[ - "subsequence.forms.Form", - typing.List[typing.Any], - typing.Iterator[typing.Tuple[str, int]], - typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] - ], - loop: bool = False, - start: typing.Optional[str] = None, - rng: typing.Optional[random.Random] = None, - at_end: str = "stop", - ) -> None: - - """ - Initialize from a Form, list, iterator, or dict of weighted section transitions. - - Parameters: - sections: Form definition. A :class:`~subsequence.forms.Form` - value, a list of Sections / ``(name, bars)`` tuples, an - iterator yielding ``(name, bars)`` tuples, or a dictionary - defining a weighted directed graph for generative progression. - loop: Sugar for ``at_end="loop"`` (sequence mode). - start: Name of the starting section when using a graph dict. If omitted, - it defaults to the first key in the dictionary. - rng: Optional seeded ``random.Random`` for deterministic graph decisions. - at_end: What happens when a sequence/generator form runs out — - ``"stop"`` (the form finishes; default), ``"hold"`` (the - final section repeats until navigated away from), or - ``"loop"`` (start over). Graphs end via their terminal - sections, so a graph only accepts ``"stop"``. - """ - - if at_end not in _AT_END_CHOICES: - choices = ", ".join(sorted(_AT_END_CHOICES)) - raise ValueError(f"at_end must be one of {choices}, got {at_end!r}") - - if loop: - if at_end not in ("stop", "loop"): - raise ValueError(f"loop=True conflicts with at_end={at_end!r} — pass one or the other") - at_end = "loop" - - self._at_end: str = at_end - self._current: typing.Optional[subsequence.forms.Section] = None - self._bar_in_section: int = 0 - self._section_index: int = 0 - self._total_bars: int = 0 - self._finished: bool = False - - # Graph mode state (only set when sections is a dict). - self._graph: typing.Optional[subsequence.weighted_graph.WeightedGraph] = None - self._section_bars: typing.Optional[typing.Dict[str, int]] = None - self._rng: random.Random = rng or random.Random() - self._iterator: typing.Optional[typing.Iterator[typing.Tuple[str, int]]] = None - - # Sequence mode state (list or Form): the whole timeline is known, - # which is what makes jump_to()/queue_next() navigable. - self._sequence: typing.Optional[typing.List[subsequence.forms.Section]] = None - self._position: int = 0 - self._queued_position: typing.Optional[int] = None - - # Terminal sections (graph mode only): sections with None transitions. - self._terminal_sections: typing.Set[str] = set() - - # Next-section lookahead: pre-decided when entering a section so - # patterns can read p.section.next_section for lead-ins. - # Overridable at any time via queue_next(). - self._next_section_name: typing.Optional[str] = None - - # Iterator peek buffer (generator mode only). - self._peeked: typing.Optional[subsequence.forms.Section] = None - self._peek_exhausted: bool = False - - if isinstance(sections, dict): - # Graph mode: build a WeightedGraph from the dict. - if at_end != "stop": - raise ValueError( - f"at_end={at_end!r} applies to sequence forms — a graph form ends " - "via its terminal sections (give a section None transitions)" - ) - - self._graph = subsequence.weighted_graph.WeightedGraph() - self._section_bars = {} - - for name, (bars, transitions) in sections.items(): - if bars < 1: - raise ValueError(f"Section '{name}' must last at least 1 bar, got {bars}") - - self._section_bars[name] = bars - if transitions is None: - self._terminal_sections.add(name) - else: - for target, weight in transitions: - # Validate targets against the whole dict now — a typo - # would otherwise crash with a bare KeyError at the - # first section boundary DURING playback, every bar. - if target not in sections: - known = ", ".join(sorted(sections)) - raise ValueError( - f"Section '{name}' transitions to unknown section " - f"'{target}'. Known sections: {known}" - ) - self._graph.add_transition(name, target, weight) - - start_name = start if start is not None else next(iter(sections)) - - if start_name not in self._section_bars: - raise ValueError(f"Start section '{start_name}' not found in form definition") - - self._current = subsequence.forms.Section(name = start_name, bars = self._section_bars[start_name]) - self._pick_next() - - elif isinstance(sections, (subsequence.forms.Form, list)): - # Sequence mode: the timeline is a known, navigable list. - elements = sections.sections if isinstance(sections, subsequence.forms.Form) else sections - self._sequence = [subsequence.forms._coerce_section(element) for element in elements] - - if self._sequence: - self._current = self._sequence[0] - else: - self._finished = True - - self._pick_next() - - else: - # Generator/iterator mode: use directly. - if at_end == "loop": - raise ValueError( - 'at_end="loop" cannot replay a generator form — pass a list ' - '(or Form) if the form should cycle' - ) - - self._iterator = sections - - try: - self._current = subsequence.forms._coerce_section(next(self._iterator)) - except StopIteration: - self._finished = True - - self._peek_iterator() - - def _pick_next (self) -> None: - - """Pre-decide the next section so patterns can read it as a lookahead. - - In graph mode, calls ``choose_next()`` on the weighted graph. - In sequence mode, reads the timeline (respecting ``at_end``). - In generator mode, delegates to ``_peek_iterator()``. - """ - - if self._graph is not None: - assert self._current is not None - current_name = self._current.name - - if current_name in self._terminal_sections: - self._next_section_name = None - else: - self._next_section_name = self._graph.choose_next(current_name, self._rng) - - elif self._sequence is not None: - if self._queued_position is not None: - self._next_section_name = self._sequence[self._queued_position].name - else: - upcoming = self._sequence_next_position() - self._next_section_name = self._sequence[upcoming].name if upcoming is not None else ( - self._current.name if self._at_end == "hold" and self._current is not None else None - ) - - else: - self._peek_iterator() - - def _sequence_next_position (self) -> typing.Optional[int]: - - """The natural next position in sequence mode, or None at the end. - - ``at_end="loop"`` wraps; ``"hold"`` and ``"stop"`` both return - None here — hold's repeat is decided at the boundary in - :meth:`advance` (the position does not move). - """ - - assert self._sequence is not None - - # An empty form has no position to move to — even under "loop". - if not self._sequence: - return None - - following = self._position + 1 - - if following < len(self._sequence): - return following - - if self._at_end == "loop": - return 0 - - return None - - def _peek_iterator (self) -> None: - - """Peek the next element from the iterator into a one-element buffer.""" - - if self._peek_exhausted or self._iterator is None: - self._next_section_name = ( - self._current.name - if self._at_end == "hold" and self._current is not None and self._peek_exhausted - else None - ) - return - - try: - self._peeked = subsequence.forms._coerce_section(next(self._iterator)) - self._next_section_name = self._peeked.name - except StopIteration: - self._peeked = None - self._peek_exhausted = True - self._next_section_name = ( - self._current.name if self._at_end == "hold" and self._current is not None else None - ) - - def _find_occurrence (self, section_name: str, what: str) -> int: - - """Find the next occurrence of a name in the sequence, searching forward and wrapping.""" - - assert self._sequence is not None - - count = len(self._sequence) - - for step in range(1, count + 1): - candidate = (self._position + step) % count - if self._sequence[candidate].name == section_name: - return candidate - - known = ", ".join(sorted({section.name for section in self._sequence})) - raise ValueError( - f"{what}: section '{section_name}' not found in form. " - f"Known sections: {known}" - ) - - def queue_next (self, section_name: str) -> None: - - """Queue a section to play after the current one ends. - - Overrides the automatically pre-decided next section. The queued - section takes effect at the natural section boundary — the current - section plays to completion first. In sequence mode the form - continues from the queued occurrence onward. - - Queuing after the form has finished revives it: the queued section - starts at the next bar and the form continues from there. - - Available in graph and sequence (list/Form) modes; a generator form - cannot be navigated. - - Args: - section_name: The section to queue. - - Raises: - ValueError: If the form is a generator, or the name is unknown. - """ - - if self._sequence is not None: - self._queued_position = self._find_occurrence(section_name, "queue_next") - self._next_section_name = section_name - logger.info(f"Form: next → {section_name}") - return - - if self._section_bars is None: - raise ValueError( - "queue_next() needs a navigable form (a graph dict, a list, or a Form value) — " - "a generator form cannot be navigated" - ) - - if section_name not in self._section_bars: - known = ", ".join(sorted(self._section_bars)) - raise ValueError( - f"Section '{section_name}' not found in form. " - f"Known sections: {known}" - ) - - self._next_section_name = section_name - logger.info(f"Form: next → {section_name}") - - def advance (self) -> bool: - - """Advance one bar, transitioning to the next section when needed, returning True if section changed.""" - - if self._finished: - - # A queued section revives a finished form at the next bar — - # queue_next() after the end is a command to play again, not a - # call to be silently ignored (sequence mode used to trip the - # not-finished invariant; graph mode ignored it). - if self._sequence is not None and self._queued_position is not None: - self._position = self._queued_position - self._queued_position = None - self._current = self._sequence[self._position] - elif self._graph is not None and self._next_section_name is not None: - assert self._section_bars is not None - self._current = subsequence.forms.Section( - name = self._next_section_name, - bars = self._section_bars[self._next_section_name], - ) - else: - return False - - self._finished = False - self._section_index += 1 - self._bar_in_section = 0 - self._pick_next() - return True - - self._bar_in_section += 1 - self._total_bars += 1 - - assert self._current is not None, "Form state invariant: current should not be None when not finished" - - if self._bar_in_section >= self._current.bars: - - if self._graph is not None: - # Graph mode: consume the pre-decided (or queued) next section. - if self._next_section_name is None: - # Terminal section — form ends. - self._finished = True - self._current = None - return True - - assert self._section_bars is not None - next_name = self._next_section_name - self._current = subsequence.forms.Section(name = next_name, bars = self._section_bars[next_name]) - self._section_index += 1 - self._bar_in_section = 0 - self._pick_next() - return True - - elif self._sequence is not None: - # Sequence mode: a queued jump wins; otherwise the timeline - # (with at_end deciding what happens past the last section). - if self._queued_position is not None: - self._position = self._queued_position - self._queued_position = None - else: - following = self._sequence_next_position() - - if following is None: - if self._at_end == "hold": - # The final section repeats (a re-entry: the index - # bumps so bound material restarts correctly). - self._section_index += 1 - self._bar_in_section = 0 - self._pick_next() - return True - - self._finished = True - self._current = None - return True - - self._position = following - - self._current = self._sequence[self._position] - self._section_index += 1 - self._bar_in_section = 0 - self._pick_next() - return True - - else: - # Generator mode: consume from the peek buffer. - if self._peeked is not None: - self._current = self._peeked - self._peeked = None - self._section_index += 1 - self._bar_in_section = 0 - self._peek_iterator() - return True - elif self._at_end == "hold": - self._section_index += 1 - self._bar_in_section = 0 - return True - else: - self._finished = True - self._current = None - return True - - return False - - def get_section_info (self) -> typing.Optional[SectionInfo]: - - """Return current section info, or None if the form is exhausted.""" - - if self._finished or self._current is None: - return None - - return SectionInfo( - name = self._current.name, - bar = self._bar_in_section, - bars = self._current.bars, - index = self._section_index, - next_section = self._next_section_name, - energy = self._current.energy, - key = self._current.key, - scale = self._current.scale, - ) - - def section_info_at_bar (self, bar: int) -> typing.Optional[SectionInfo]: - - """Return the section covering a 1-based GLOBAL bar, or ``None``. - - Available for **sequence** forms only (lists and ``Form`` values — the - whole timeline is known, so a bar maps to a section by accumulating - ``Section.bars``). Graph and generator forms have no fixed layout - ahead of the playhead, so they return ``None`` (callers fall back to - the playhead section). A looping form wraps; a finite form past its - end returns ``None``. - - Used to key a relative ``pin_chord`` to the section that *owns* the - pinned bar rather than the section at the playhead — they differ when - the harmonic clock's lookahead projects a pin into a later, possibly - differently-keyed, section. This is a layout lookup (linear from bar - 1); live ``form_jump`` is not reflected, which is acceptable for - pre-set pins (the playhead path stays authoritative for live moves). - """ - - if self._sequence is None or bar < 1: - return None - - total = sum(section.bars for section in self._sequence) - - if total <= 0: - return None - - index0 = bar - 1 - - if index0 >= total: - if self._at_end == "loop": - index0 = index0 % total - else: - return None - - cursor = 0 - - for position, section in enumerate(self._sequence): - if cursor <= index0 < cursor + section.bars: - # Mirror the playhead path (_sequence_next_position/_pick_next) - # at the end of the timeline: a looping form's last section - # leads back to the first, a holding form's repeats itself — - # so .ending and next_section agree between the two sources. - if position + 1 < len(self._sequence): - following: typing.Optional[str] = self._sequence[position + 1].name - elif self._at_end == "loop": - following = self._sequence[0].name - elif self._at_end == "hold": - following = section.name - else: - following = None - - return SectionInfo( - name = section.name, - bar = index0 - cursor, - bars = section.bars, - index = position, - next_section = following, - energy = section.energy, - key = section.key, - scale = section.scale, - ) - cursor += section.bars - - return None - - @property - def total_bars (self) -> int: - - """Return the global bar count since the form started.""" - - return self._total_bars - - def jump_to (self, section_name: str) -> None: - - """Force the form to a named section immediately. - - Available in **graph mode** (dict forms) and **sequence mode** - (list/Form forms — the jump lands on the next occurrence of the - name, searching forward and wrapping, and the form continues from - there). A generator form cannot be navigated. - - The section restarts from bar 0. The musical effect is not heard - until the *next pattern rebuild cycle*, because already-queued MIDI - notes are unaffected. This is the same natural quantization that - applies to all ``composition.data`` writes and - ``composition.tweak()`` calls. - - Args: - section_name: Name of the section to jump to. Must exist in the - form definition passed to ``composition.form()``. - - Raises: - ValueError: If the form is a generator, or the name is unknown. - - Example:: - - composition.form_jump("chorus") # via Composition helper - """ - - if self._sequence is not None: - self._position = self._find_occurrence(section_name, "jump_to") - self._current = self._sequence[self._position] - self._bar_in_section = 0 - self._section_index += 1 - self._finished = False - self._queued_position = None - self._pick_next() - logger.info(f"Form: jump → {section_name}") - return - - if self._section_bars is None: - raise ValueError( - "jump_to() needs a navigable form (a graph dict, a list, or a Form value) — " - "a generator form cannot be navigated" - ) - - if section_name not in self._section_bars: - known = ", ".join(sorted(self._section_bars)) - raise ValueError( - f"Section '{section_name}' not found in form. " - f"Known sections: {known}" - ) - - self._current = subsequence.forms.Section(name = section_name, bars = self._section_bars[section_name]) - self._bar_in_section = 0 - self._section_index += 1 - self._finished = False - self._pick_next() - logger.info(f"Form: jump → {section_name}") + """Track compositional form as a sequence of named sections with bar durations.""" + + def __init__( + self, + sections: typing.Union[ + "subsequence.forms.Form", + typing.List[typing.Any], + typing.Iterator[typing.Tuple[str, int]], + typing.Dict[ + str, + typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]], + ], + ], + loop: bool = False, + start: typing.Optional[str] = None, + rng: typing.Optional[random.Random] = None, + at_end: str = "stop", + ) -> None: + """ + Initialize from a Form, list, iterator, or dict of weighted section transitions. + + Parameters: + sections: Form definition. A :class:`~subsequence.forms.Form` + value, a list of Sections / ``(name, bars)`` tuples, an + iterator yielding ``(name, bars)`` tuples, or a dictionary + defining a weighted directed graph for generative progression. + loop: Sugar for ``at_end="loop"`` (sequence mode). + start: Name of the starting section when using a graph dict. If omitted, + it defaults to the first key in the dictionary. + rng: Optional seeded ``random.Random`` for deterministic graph decisions. + at_end: What happens when a sequence/generator form runs out — + ``"stop"`` (the form finishes; default), ``"hold"`` (the + final section repeats until navigated away from), or + ``"loop"`` (start over). Graphs end via their terminal + sections, so a graph only accepts ``"stop"``. + """ + + if at_end not in _AT_END_CHOICES: + choices = ", ".join(sorted(_AT_END_CHOICES)) + raise ValueError(f"at_end must be one of {choices}, got {at_end!r}") + + if loop: + if at_end not in ("stop", "loop"): + raise ValueError( + f"loop=True conflicts with at_end={at_end!r} — pass one or the other" + ) + at_end = "loop" + + self._at_end: str = at_end + self._current: typing.Optional[subsequence.forms.Section] = None + self._bar_in_section: int = 0 + self._section_index: int = 0 + self._total_bars: int = 0 + self._finished: bool = False + + # Graph mode state (only set when sections is a dict). + self._graph: typing.Optional[subsequence.weighted_graph.WeightedGraph] = None + self._section_bars: typing.Optional[typing.Dict[str, int]] = None + self._rng: random.Random = rng or random.Random() + self._iterator: typing.Optional[typing.Iterator[typing.Tuple[str, int]]] = None + + # Sequence mode state (list or Form): the whole timeline is known, + # which is what makes jump_to()/queue_next() navigable. + self._sequence: typing.Optional[typing.List[subsequence.forms.Section]] = None + self._position: int = 0 + self._queued_position: typing.Optional[int] = None + + # Terminal sections (graph mode only): sections with None transitions. + self._terminal_sections: typing.Set[str] = set() + + # Next-section lookahead: pre-decided when entering a section so + # patterns can read p.section.next_section for lead-ins. + # Overridable at any time via queue_next(). + self._next_section_name: typing.Optional[str] = None + + # Iterator peek buffer (generator mode only). + self._peeked: typing.Optional[subsequence.forms.Section] = None + self._peek_exhausted: bool = False + + if isinstance(sections, dict): + # Graph mode: build a WeightedGraph from the dict. + if at_end != "stop": + raise ValueError( + f"at_end={at_end!r} applies to sequence forms — a graph form ends " + "via its terminal sections (give a section None transitions)" + ) + + self._graph = subsequence.weighted_graph.WeightedGraph() + self._section_bars = {} + + for name, (bars, transitions) in sections.items(): + if bars < 1: + raise ValueError( + f"Section '{name}' must last at least 1 bar, got {bars}" + ) + + self._section_bars[name] = bars + if transitions is None: + self._terminal_sections.add(name) + else: + for target, weight in transitions: + # Validate targets against the whole dict now — a typo + # would otherwise crash with a bare KeyError at the + # first section boundary DURING playback, every bar. + if target not in sections: + known = ", ".join(sorted(sections)) + raise ValueError( + f"Section '{name}' transitions to unknown section " + f"'{target}'. Known sections: {known}" + ) + self._graph.add_transition(name, target, weight) + + start_name = start if start is not None else next(iter(sections)) + + if start_name not in self._section_bars: + raise ValueError( + f"Start section '{start_name}' not found in form definition" + ) + + self._current = subsequence.forms.Section( + name=start_name, bars=self._section_bars[start_name] + ) + self._pick_next() + + elif isinstance(sections, (subsequence.forms.Form, list)): + # Sequence mode: the timeline is a known, navigable list. + elements = ( + sections.sections + if isinstance(sections, subsequence.forms.Form) + else sections + ) + self._sequence = [ + subsequence.forms._coerce_section(element) for element in elements + ] + + if self._sequence: + self._current = self._sequence[0] + else: + self._finished = True + + self._pick_next() + + else: + # Generator/iterator mode: use directly. + if at_end == "loop": + raise ValueError( + 'at_end="loop" cannot replay a generator form — pass a list ' + "(or Form) if the form should cycle" + ) + + self._iterator = sections + + try: + self._current = subsequence.forms._coerce_section(next(self._iterator)) + except StopIteration: + self._finished = True + + self._peek_iterator() + + def _pick_next(self) -> None: + """Pre-decide the next section so patterns can read it as a lookahead. + + In graph mode, calls ``choose_next()`` on the weighted graph. + In sequence mode, reads the timeline (respecting ``at_end``). + In generator mode, delegates to ``_peek_iterator()``. + """ + + if self._graph is not None: + assert self._current is not None + current_name = self._current.name + + if current_name in self._terminal_sections: + self._next_section_name = None + else: + self._next_section_name = self._graph.choose_next( + current_name, self._rng + ) + + elif self._sequence is not None: + if self._queued_position is not None: + self._next_section_name = self._sequence[self._queued_position].name + else: + upcoming = self._sequence_next_position() + self._next_section_name = ( + self._sequence[upcoming].name + if upcoming is not None + else ( + self._current.name + if self._at_end == "hold" and self._current is not None + else None + ) + ) + + else: + self._peek_iterator() + + def _sequence_next_position(self) -> typing.Optional[int]: + """The natural next position in sequence mode, or None at the end. + + ``at_end="loop"`` wraps; ``"hold"`` and ``"stop"`` both return + None here — hold's repeat is decided at the boundary in + :meth:`advance` (the position does not move). + """ + + assert self._sequence is not None + + # An empty form has no position to move to — even under "loop". + if not self._sequence: + return None + + following = self._position + 1 + + if following < len(self._sequence): + return following + + if self._at_end == "loop": + return 0 + + return None + + def _peek_iterator(self) -> None: + """Peek the next element from the iterator into a one-element buffer.""" + + if self._peek_exhausted or self._iterator is None: + self._next_section_name = ( + self._current.name + if self._at_end == "hold" + and self._current is not None + and self._peek_exhausted + else None + ) + return + + try: + self._peeked = subsequence.forms._coerce_section(next(self._iterator)) + self._next_section_name = self._peeked.name + except StopIteration: + self._peeked = None + self._peek_exhausted = True + self._next_section_name = ( + self._current.name + if self._at_end == "hold" and self._current is not None + else None + ) + + def _find_occurrence(self, section_name: str, what: str) -> int: + """Find the next occurrence of a name in the sequence, searching forward and wrapping.""" + + assert self._sequence is not None + + count = len(self._sequence) + + for step in range(1, count + 1): + candidate = (self._position + step) % count + if self._sequence[candidate].name == section_name: + return candidate + + known = ", ".join(sorted({section.name for section in self._sequence})) + raise ValueError( + f"{what}: section '{section_name}' not found in form. " + f"Known sections: {known}" + ) + + def queue_next(self, section_name: str) -> None: + """Queue a section to play after the current one ends. + + Overrides the automatically pre-decided next section. The queued + section takes effect at the natural section boundary — the current + section plays to completion first. In sequence mode the form + continues from the queued occurrence onward. + + Queuing after the form has finished revives it: the queued section + starts at the next bar and the form continues from there. + + Available in graph and sequence (list/Form) modes; a generator form + cannot be navigated. + + Args: + section_name: The section to queue. + + Raises: + ValueError: If the form is a generator, or the name is unknown. + """ + + if self._sequence is not None: + self._queued_position = self._find_occurrence(section_name, "queue_next") + self._next_section_name = section_name + logger.info(f"Form: next → {section_name}") + return + + if self._section_bars is None: + raise ValueError( + "queue_next() needs a navigable form (a graph dict, a list, or a Form value) — " + "a generator form cannot be navigated" + ) + + if section_name not in self._section_bars: + known = ", ".join(sorted(self._section_bars)) + raise ValueError( + f"Section '{section_name}' not found in form. Known sections: {known}" + ) + + self._next_section_name = section_name + logger.info(f"Form: next → {section_name}") + + def advance(self) -> bool: + """Advance one bar, transitioning to the next section when needed, returning True if section changed.""" + + if self._finished: + # A queued section revives a finished form at the next bar — + # queue_next() after the end is a command to play again, not a + # call to be silently ignored (sequence mode used to trip the + # not-finished invariant; graph mode ignored it). + if self._sequence is not None and self._queued_position is not None: + self._position = self._queued_position + self._queued_position = None + self._current = self._sequence[self._position] + elif self._graph is not None and self._next_section_name is not None: + assert self._section_bars is not None + self._current = subsequence.forms.Section( + name=self._next_section_name, + bars=self._section_bars[self._next_section_name], + ) + else: + return False + + self._finished = False + self._section_index += 1 + self._bar_in_section = 0 + self._pick_next() + return True + + self._bar_in_section += 1 + self._total_bars += 1 + + assert self._current is not None, ( + "Form state invariant: current should not be None when not finished" + ) + + if self._bar_in_section >= self._current.bars: + if self._graph is not None: + # Graph mode: consume the pre-decided (or queued) next section. + if self._next_section_name is None: + # Terminal section — form ends. + self._finished = True + self._current = None + return True + + assert self._section_bars is not None + next_name = self._next_section_name + self._current = subsequence.forms.Section( + name=next_name, bars=self._section_bars[next_name] + ) + self._section_index += 1 + self._bar_in_section = 0 + self._pick_next() + return True + + elif self._sequence is not None: + # Sequence mode: a queued jump wins; otherwise the timeline + # (with at_end deciding what happens past the last section). + if self._queued_position is not None: + self._position = self._queued_position + self._queued_position = None + else: + following = self._sequence_next_position() + + if following is None: + if self._at_end == "hold": + # The final section repeats (a re-entry: the index + # bumps so bound material restarts correctly). + self._section_index += 1 + self._bar_in_section = 0 + self._pick_next() + return True + + self._finished = True + self._current = None + return True + + self._position = following + + self._current = self._sequence[self._position] + self._section_index += 1 + self._bar_in_section = 0 + self._pick_next() + return True + + else: + # Generator mode: consume from the peek buffer. + if self._peeked is not None: + self._current = self._peeked + self._peeked = None + self._section_index += 1 + self._bar_in_section = 0 + self._peek_iterator() + return True + elif self._at_end == "hold": + self._section_index += 1 + self._bar_in_section = 0 + return True + else: + self._finished = True + self._current = None + return True + + return False + + def get_section_info(self) -> typing.Optional[SectionInfo]: + """Return current section info, or None if the form is exhausted.""" + + if self._finished or self._current is None: + return None + + return SectionInfo( + name=self._current.name, + bar=self._bar_in_section, + bars=self._current.bars, + index=self._section_index, + next_section=self._next_section_name, + energy=self._current.energy, + key=self._current.key, + scale=self._current.scale, + ) + + def section_info_at_bar(self, bar: int) -> typing.Optional[SectionInfo]: + """Return the section covering a 1-based GLOBAL bar, or ``None``. + + Available for **sequence** forms only (lists and ``Form`` values — the + whole timeline is known, so a bar maps to a section by accumulating + ``Section.bars``). Graph and generator forms have no fixed layout + ahead of the playhead, so they return ``None`` (callers fall back to + the playhead section). A looping form wraps; a finite form past its + end returns ``None``. + + Used to key a relative ``pin_chord`` to the section that *owns* the + pinned bar rather than the section at the playhead — they differ when + the harmonic clock's lookahead projects a pin into a later, possibly + differently-keyed, section. This is a layout lookup (linear from bar + 1); live ``form_jump`` is not reflected, which is acceptable for + pre-set pins (the playhead path stays authoritative for live moves). + """ + + if self._sequence is None or bar < 1: + return None + + total = sum(section.bars for section in self._sequence) + + if total <= 0: + return None + + index0 = bar - 1 + + if index0 >= total: + if self._at_end == "loop": + index0 = index0 % total + else: + return None + + cursor = 0 + + for position, section in enumerate(self._sequence): + if cursor <= index0 < cursor + section.bars: + # Mirror the playhead path (_sequence_next_position/_pick_next) + # at the end of the timeline: a looping form's last section + # leads back to the first, a holding form's repeats itself — + # so .ending and next_section agree between the two sources. + if position + 1 < len(self._sequence): + following: typing.Optional[str] = self._sequence[position + 1].name + elif self._at_end == "loop": + following = self._sequence[0].name + elif self._at_end == "hold": + following = section.name + else: + following = None + + return SectionInfo( + name=section.name, + bar=index0 - cursor, + bars=section.bars, + index=position, + next_section=following, + energy=section.energy, + key=section.key, + scale=section.scale, + ) + cursor += section.bars + + return None + + @property + def total_bars(self) -> int: + """Return the global bar count since the form started.""" + + return self._total_bars + + def jump_to(self, section_name: str) -> None: + """Force the form to a named section immediately. + + Available in **graph mode** (dict forms) and **sequence mode** + (list/Form forms — the jump lands on the next occurrence of the + name, searching forward and wrapping, and the form continues from + there). A generator form cannot be navigated. + + The section restarts from bar 0. The musical effect is not heard + until the *next pattern rebuild cycle*, because already-queued MIDI + notes are unaffected. This is the same natural quantization that + applies to all ``composition.data`` writes and + ``composition.tweak()`` calls. + + Args: + section_name: Name of the section to jump to. Must exist in the + form definition passed to ``composition.form()``. + + Raises: + ValueError: If the form is a generator, or the name is unknown. + + Example:: + + composition.form_jump("chorus") # via Composition helper + """ + + if self._sequence is not None: + self._position = self._find_occurrence(section_name, "jump_to") + self._current = self._sequence[self._position] + self._bar_in_section = 0 + self._section_index += 1 + self._finished = False + self._queued_position = None + self._pick_next() + logger.info(f"Form: jump → {section_name}") + return + + if self._section_bars is None: + raise ValueError( + "jump_to() needs a navigable form (a graph dict, a list, or a Form value) — " + "a generator form cannot be navigated" + ) + + if section_name not in self._section_bars: + known = ", ".join(sorted(self._section_bars)) + raise ValueError( + f"Section '{section_name}' not found in form. Known sections: {known}" + ) + + self._current = subsequence.forms.Section( + name=section_name, bars=self._section_bars[section_name] + ) + self._bar_in_section = 0 + self._section_index += 1 + self._finished = False + self._pick_next() + logger.info(f"Form: jump → {section_name}") diff --git a/subsequence/forms.py b/subsequence/forms.py index e9d2e8e..490019b 100644 --- a/subsequence/forms.py +++ b/subsequence/forms.py @@ -20,238 +20,260 @@ @dataclasses.dataclass(frozen=True) class Section: - - """One section of a form — the payload home. - - Attributes: - name: The section name (``"verse"``). - bars: Length in bars (≥ 1). - energy: The section's energy level (0.0–1.0; the arranging dial). - Read by ``p.energy`` and ``min_energy=`` gating; a - ``composition.energy()`` dict overrides it (the dict is the - later, performance-level dial). - key: Optional key override — re-anchors *key-relative* content - (degrees, romans, generated material, and key-relative section - progressions bound with ``section_chords``) to this section's - tonic. *Absolute* content (note names, MIDI pitches, frozen - chords) is never moved, and *chord-relative* content - (``ChordTone``, ``Approach``) tracks the sounding chord rather - than the key — see the three-intent model in the docs. The - live graph engine (``harmony(style=...)``) stays in the - composition key by design (a stateful walk does not transpose - mid-stream). - scale: Optional scale/mode override (e.g. ``"minor"``) — moves the - mode as well as the tonic, so a section can genuinely change - to the relative or parallel minor. Falls back to the form's - scale, then the composition's. - """ - - name: str - bars: int - energy: float = 0.5 - key: typing.Optional[str] = None - scale: typing.Optional[str] = None - - def __post_init__ (self) -> None: - - """Validate the payload loudly.""" - - if not isinstance(self.name, str) or not self.name: - raise ValueError(f"a section needs a non-empty string name, got {self.name!r}") - - if not isinstance(self.bars, int) or isinstance(self.bars, bool) or self.bars < 1: - raise ValueError(f"Section {self.name!r} must last at least 1 bar, got {self.bars!r}") - - if not 0.0 <= float(self.energy) <= 1.0: - raise ValueError(f"Section {self.name!r} energy must be 0.0–1.0, got {self.energy!r}") - - -def _coerce_section (element: typing.Any) -> Section: - - """Coerce a Form element — a Section passes through, a (name, bars) tuple converts.""" - - if isinstance(element, Section): - return element - - if isinstance(element, tuple) and len(element) == 2: - name, bars = element - return Section(name = name, bars = bars) - - raise TypeError( - f"Form elements are Sections or (name, bars) tuples — got {element!r}" - ) + """One section of a form — the payload home. + + Attributes: + name: The section name (``"verse"``). + bars: Length in bars (≥ 1). + energy: The section's energy level (0.0–1.0; the arranging dial). + Read by ``p.energy`` and ``min_energy=`` gating; a + ``composition.energy()`` dict overrides it (the dict is the + later, performance-level dial). + key: Optional key override — re-anchors *key-relative* content + (degrees, romans, generated material, and key-relative section + progressions bound with ``section_chords``) to this section's + tonic. *Absolute* content (note names, MIDI pitches, frozen + chords) is never moved, and *chord-relative* content + (``ChordTone``, ``Approach``) tracks the sounding chord rather + than the key — see the three-intent model in the docs. The + live graph engine (``harmony(style=...)``) stays in the + composition key by design (a stateful walk does not transpose + mid-stream). + scale: Optional scale/mode override (e.g. ``"minor"``) — moves the + mode as well as the tonic, so a section can genuinely change + to the relative or parallel minor. Falls back to the form's + scale, then the composition's. + """ + + name: str + bars: int + energy: float = 0.5 + key: typing.Optional[str] = None + scale: typing.Optional[str] = None + + def __post_init__(self) -> None: + """Validate the payload loudly.""" + + if not isinstance(self.name, str) or not self.name: + raise ValueError( + f"a section needs a non-empty string name, got {self.name!r}" + ) + + if ( + not isinstance(self.bars, int) + or isinstance(self.bars, bool) + or self.bars < 1 + ): + raise ValueError( + f"Section {self.name!r} must last at least 1 bar, got {self.bars!r}" + ) + + if not 0.0 <= float(self.energy) <= 1.0: + raise ValueError( + f"Section {self.name!r} energy must be 0.0–1.0, got {self.energy!r}" + ) + + +def _coerce_section(element: typing.Any) -> Section: + """Coerce a Form element — a Section passes through, a (name, bars) tuple converts.""" + + if isinstance(element, Section): + return element + + if isinstance(element, tuple) and len(element) == 2: + name, bars = element + return Section(name=name, bars=bars) + + raise TypeError( + f"Form elements are Sections or (name, bars) tuples — got {element!r}" + ) @dataclasses.dataclass(frozen=True) class Form: + """A frozen sequence of Sections — the editable, bindable form value. - """A frozen sequence of Sections — the editable, bindable form value. - - List-friendly: the constructor coerces ``("name", bars)`` tuples, so - ``Form([("verse", 8), ("chorus", 8)])`` and - ``Form([Section("verse", 8), Section("chorus", 8)])`` are the same value. - Repetition is Python list arithmetic before construction. - - A form may carry its own ``key``/``scale`` — the **form tier** of the - key-source chain (``Section.key`` overrides it; it overrides the - composition key). A whole AABA in one key with one section borrowing - another is ``Form([...], key="A")`` plus a ``Section(..., key="F")``. - """ - - sections: typing.Tuple[Section, ...] - key: typing.Optional[str] = None - scale: typing.Optional[str] = None - - def __init__ ( - self, - sections: typing.Iterable[typing.Any], - key: typing.Optional[str] = None, - scale: typing.Optional[str] = None, - ) -> None: - - """Coerce any iterable of Sections / (name, bars) tuples.""" - - coerced = tuple(_coerce_section(element) for element in sections) - - if not coerced: - raise ValueError("a Form needs at least one section") - - object.__setattr__(self, "sections", coerced) - object.__setattr__(self, "key", key) - object.__setattr__(self, "scale", scale) - - @property - def bars (self) -> int: - - """Total length in bars.""" - - return sum(section.bars for section in self.sections) - - def __len__ (self) -> int: - - """Number of sections.""" - - return len(self.sections) - - def __iter__ (self) -> typing.Iterator[Section]: - - """Iterate the sections in order.""" - - return iter(self.sections) - - def __add__ (self, other: "Form") -> "Form": - - """Sequential concatenation: ``intro_form + body_form``. - - The **left** operand's form-tier ``key``/``scale`` survives (a single - value cannot hold two form keys); the right form's form-tier key is - dropped. Per-section ``Section.key``/``scale`` on either side is - preserved — the sections concatenate intact. - """ - - if not isinstance(other, Form): - return NotImplemented - - return Form(self.sections + other.sections, key = self.key, scale = self.scale) - - def replace ( - self, - slot: int, - section: typing.Optional[Section] = None, - **changes: typing.Any, - ) -> "Form": - - """Replace the section at a 1-based slot — whole, or by field. - - ``form.replace(3, bars=16)`` stretches slot 3; - ``form.replace(3, Section("drop", 16, energy=1.0))`` swaps it out. - """ - - index = _check_slot(slot, len(self.sections)) - - if section is not None and changes: - raise ValueError("pass either a Section or field changes, not both") - - if section is None: - if not changes: - raise ValueError("replace() needs a Section or field changes (bars=, energy=, key=, name=)") - section = dataclasses.replace(self.sections[index], **changes) - - return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index + 1:], key = self.key, scale = self.scale) - - def insert (self, slot: int, section: typing.Any) -> "Form": - - """Insert a section *at* a 1-based slot (existing sections shift right). - - ``slot`` may be ``len(form) + 1`` to append. - """ - - if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= len(self.sections) + 1: - raise ValueError(f"slot {slot!r} is out of range (1–{len(self.sections) + 1})") - - index = slot - 1 - - return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index:], key = self.key, scale = self.scale) - - def with_energy (self, energies: typing.Dict[str, float]) -> "Form": - - """Set the energy payload on named sections — ``{"chorus": 0.9}``. - - Every section whose name appears in the mapping takes the new value; - naming a section the form does not contain raises. Energy *ramps* - (``(start, end)`` tuples) live in ``composition.energy()``, not in - the payload — a Section carries one number. - """ - - names = {section.name for section in self.sections} - - for name in energies: - if name not in names: - known = ", ".join(sorted(names)) - raise ValueError(f"with_energy: no section named {name!r} in this form (sections: {known})") - - return Form(tuple( - dataclasses.replace(section, energy = energies[section.name]) - if section.name in energies else section - for section in self.sections - ), key = self.key, scale = self.scale) - - def describe (self) -> str: - - """A readable one-section-per-line summary.""" - - lines = [f"Form — {len(self.sections)} sections over {self.bars} bars"] - - if self.key is not None or self.scale is not None: - lines.append(f" (form key={self.key or '–'} scale={self.scale or '–'})") - - bar = 1 - - for slot, section in enumerate(self.sections, start = 1): - extras = f" energy={section.energy:g}" - if section.key is not None: - extras += f" key={section.key}" - if section.scale is not None: - extras += f" scale={section.scale}" - lines.append(f" {slot}. bars {bar}–{bar + section.bars - 1} {section.name:<10} ({section.bars} bars){extras}") - bar += section.bars - - return "\n".join(lines) + List-friendly: the constructor coerces ``("name", bars)`` tuples, so + ``Form([("verse", 8), ("chorus", 8)])`` and + ``Form([Section("verse", 8), Section("chorus", 8)])`` are the same value. + Repetition is Python list arithmetic before construction. - def __str__ (self) -> str: + A form may carry its own ``key``/``scale`` — the **form tier** of the + key-source chain (``Section.key`` overrides it; it overrides the + composition key). A whole AABA in one key with one section borrowing + another is ``Form([...], key="A")`` plus a ``Section(..., key="F")``. + """ - """Same as :meth:`describe`.""" + sections: typing.Tuple[Section, ...] + key: typing.Optional[str] = None + scale: typing.Optional[str] = None + + def __init__( + self, + sections: typing.Iterable[typing.Any], + key: typing.Optional[str] = None, + scale: typing.Optional[str] = None, + ) -> None: + """Coerce any iterable of Sections / (name, bars) tuples.""" + + coerced = tuple(_coerce_section(element) for element in sections) + + if not coerced: + raise ValueError("a Form needs at least one section") - return self.describe() + object.__setattr__(self, "sections", coerced) + object.__setattr__(self, "key", key) + object.__setattr__(self, "scale", scale) + + @property + def bars(self) -> int: + """Total length in bars.""" + + return sum(section.bars for section in self.sections) + + def __len__(self) -> int: + """Number of sections.""" + + return len(self.sections) + + def __iter__(self) -> typing.Iterator[Section]: + """Iterate the sections in order.""" + + return iter(self.sections) + + def __add__(self, other: "Form") -> "Form": + """Sequential concatenation: ``intro_form + body_form``. + + The **left** operand's form-tier ``key``/``scale`` survives (a single + value cannot hold two form keys); the right form's form-tier key is + dropped. Per-section ``Section.key``/``scale`` on either side is + preserved — the sections concatenate intact. + """ + + if not isinstance(other, Form): + return NotImplemented + + return Form(self.sections + other.sections, key=self.key, scale=self.scale) + + def replace( + self, + slot: int, + section: typing.Optional[Section] = None, + **changes: typing.Any, + ) -> "Form": + """Replace the section at a 1-based slot — whole, or by field. + + ``form.replace(3, bars=16)`` stretches slot 3; + ``form.replace(3, Section("drop", 16, energy=1.0))`` swaps it out. + """ + + index = _check_slot(slot, len(self.sections)) + + if section is not None and changes: + raise ValueError("pass either a Section or field changes, not both") + + if section is None: + if not changes: + raise ValueError( + "replace() needs a Section or field changes (bars=, energy=, key=, name=)" + ) + section = dataclasses.replace(self.sections[index], **changes) + + return Form( + self.sections[:index] + + (_coerce_section(section),) + + self.sections[index + 1 :], + key=self.key, + scale=self.scale, + ) + + def insert(self, slot: int, section: typing.Any) -> "Form": + """Insert a section *at* a 1-based slot (existing sections shift right). + + ``slot`` may be ``len(form) + 1`` to append. + """ + + if ( + not isinstance(slot, int) + or isinstance(slot, bool) + or not 1 <= slot <= len(self.sections) + 1 + ): + raise ValueError( + f"slot {slot!r} is out of range (1–{len(self.sections) + 1})" + ) + + index = slot - 1 + return Form( + self.sections[:index] + (_coerce_section(section),) + self.sections[index:], + key=self.key, + scale=self.scale, + ) + + def with_energy(self, energies: typing.Dict[str, float]) -> "Form": + """Set the energy payload on named sections — ``{"chorus": 0.9}``. -def _check_slot (slot: int, count: int) -> int: + Every section whose name appears in the mapping takes the new value; + naming a section the form does not contain raises. Energy *ramps* + (``(start, end)`` tuples) live in ``composition.energy()``, not in + the payload — a Section carries one number. + """ - """Validate a 1-based section slot and return its 0-based index.""" + names = {section.name for section in self.sections} - if not isinstance(slot, int) or isinstance(slot, bool): - raise TypeError(f"section slots are 1-based ints, got {slot!r}") - if slot < 1 or slot > count: - raise ValueError(f"section slot {slot} is out of range (1–{count})") + for name in energies: + if name not in names: + known = ", ".join(sorted(names)) + raise ValueError( + f"with_energy: no section named {name!r} in this form (sections: {known})" + ) - return slot - 1 + return Form( + tuple( + dataclasses.replace(section, energy=energies[section.name]) + if section.name in energies + else section + for section in self.sections + ), + key=self.key, + scale=self.scale, + ) + + def describe(self) -> str: + """A readable one-section-per-line summary.""" + + lines = [f"Form — {len(self.sections)} sections over {self.bars} bars"] + + if self.key is not None or self.scale is not None: + lines.append(f" (form key={self.key or '–'} scale={self.scale or '–'})") + + bar = 1 + + for slot, section in enumerate(self.sections, start=1): + extras = f" energy={section.energy:g}" + if section.key is not None: + extras += f" key={section.key}" + if section.scale is not None: + extras += f" scale={section.scale}" + lines.append( + f" {slot}. bars {bar}–{bar + section.bars - 1} {section.name:<10} ({section.bars} bars){extras}" + ) + bar += section.bars + + return "\n".join(lines) + + def __str__(self) -> str: + """Same as :meth:`describe`.""" + + return self.describe() + + +def _check_slot(slot: int, count: int) -> int: + """Validate a 1-based section slot and return its 0-based index.""" + + if not isinstance(slot, int) or isinstance(slot, bool): + raise TypeError(f"section slots are 1-based ints, got {slot!r}") + if slot < 1 or slot > count: + raise ValueError(f"section slot {slot} is out of range (1–{count})") + + return slot - 1 diff --git a/subsequence/groove.py b/subsequence/groove.py index bbc71ee..9c28fbe 100644 --- a/subsequence/groove.py +++ b/subsequence/groove.py @@ -14,310 +14,310 @@ import subsequence.constants if typing.TYPE_CHECKING: - import subsequence.pattern + import subsequence.pattern @dataclasses.dataclass class Groove: - - """ - A timing/velocity template applied to quantized grid positions. - - A groove is a repeating pattern of per-step timing offsets and optional - velocity adjustments aligned to a rhythmic grid. Apply it as a post-build - transform with ``p.groove(template)`` to give a pattern its characteristic - feel — swing, shuffle, MPC-style pocket, or anything extracted from an - Ableton ``.agr`` file. - - Parameters: - offsets: Timing offset per grid slot, in beats. Repeats cyclically. - Positive values delay the note; negative values push it earlier. - grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes). - velocities: Optional velocity scale per grid slot (1.0 = unchanged). - Repeats cyclically alongside offsets. - - Example:: - - # Ableton-style 57% swing on 16th notes - groove = Groove.swing(percent=57) - - # Custom groove with timing and velocity - groove = Groove( - grid=0.25, - offsets=[0.0, +0.02, 0.0, -0.01], - velocities=[1.0, 0.7, 0.9, 0.6], - ) - """ - - offsets: typing.List[float] - grid: float = 0.25 - velocities: typing.Optional[typing.List[float]] = None - - def __post_init__ (self) -> None: - if not self.offsets: - raise ValueError("offsets must not be empty") - if self.grid <= 0: - raise ValueError("grid must be positive") - if self.velocities is not None and not self.velocities: - raise ValueError("velocities must not be empty (use None for no velocity adjustment)") - - @staticmethod - def swing (percent: float = 57.0, grid: float = 0.25) -> "Groove": - - """ - Create a swing groove from a percentage. - - 50% is straight (no swing). 67% is approximately triplet swing. - 57% is a moderate shuffle — the Ableton default. - - Parameters: - percent: Swing amount (50–75 is the useful range). - grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths). - """ - - if percent < 50.0 or percent > 99.0: - raise ValueError("swing percent must be between 50 and 99") - pair_duration = grid * 2 - offset = (percent / 100.0 - 0.5) * pair_duration - return Groove(offsets=[0.0, offset], grid=grid) - - @staticmethod - def from_agr (path: str, grid: typing.Optional[float] = None) -> "Groove": - - """ - Import timing and velocity data from an Ableton .agr groove file. - - An ``.agr`` file is an XML document containing a MIDI clip whose - note positions encode the groove's rhythmic feel. This method reads - those note start times and velocities and converts them into the - ``Groove`` dataclass format (per-step offsets and velocity scales). - - Without ``grid=``, the grid is inferred as ``clip length / note - count`` — which assumes the clip plays **exactly one note per grid - cell** (the standard shape for a groove clip). A clip with rests or - chords breaks that assumption: pass ``grid=`` explicitly (e.g. - ``grid=0.25`` for a 16th-note groove) and empty cells keep a neutral - offset. A clip whose notes cannot be assigned one-per-cell raises - rather than importing a wrong feel. - - **What is extracted:** - - - ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets - relative to ideal grid positions. - - ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity - scaling (normalised to the highest velocity in the file). - - ``TimingAmount`` from the Groove element → pre-scales the timing - offsets (100 = full, 70 = 70% of the groove's timing). - - ``VelocityAmount`` from the Groove element → pre-scales velocity - deviation (100 = full groove velocity, 0 = no velocity changes). - - The resulting ``Groove`` reflects the file author's intended - strength. Use ``strength=`` when applying to further adjust. - - **What is NOT imported:** - - ``RandomAmount`` (use ``p.randomize()`` separately for random - jitter) and ``QuantizationAmount`` (not applicable - Subsequence - notes are already grid-quantized by construction). - - Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``, - ``OffVelocity``, ``Probability``) are also ignored. - - Parameters: - path: Path to the .agr file. - grid: Grid size in beats (0.25 = 16th notes). ``None`` (default) - infers it from the clip, assuming one note per cell. - """ - - tree = xml.etree.ElementTree.parse(path) - root = tree.getroot() - - # Find the MIDI clip - clip = root.find(".//MidiClip") - if clip is None: - raise ValueError(f"No MidiClip found in {path}") - - # Get clip length - current_end = clip.find("CurrentEnd") - if current_end is None: - raise ValueError(f"No CurrentEnd found in {path}") - clip_length = float(current_end.get("Value", "4")) - - # Read Groove Pool blend parameters - groove_elem = root.find(".//Groove") - timing_amount = 100.0 - velocity_amount = 100.0 - if groove_elem is not None: - timing_el = groove_elem.find("TimingAmount") - if timing_el is not None: - timing_amount = float(timing_el.get("Value", "100")) - velocity_el = groove_elem.find("VelocityAmount") - if velocity_el is not None: - velocity_amount = float(velocity_el.get("Value", "100")) - - timing_scale = timing_amount / 100.0 - velocity_scale = velocity_amount / 100.0 - - # Extract note events sorted by time - events = clip.findall(".//MidiNoteEvent") - if not events: - raise ValueError(f"No MidiNoteEvent elements found in {path}") - - times: typing.List[float] = [] - velocities_raw: typing.List[float] = [] - for event in events: - times.append(float(event.get("Time", "0"))) - velocities_raw.append(float(event.get("Velocity", "127"))) - - # Sort as PAIRS - sorting times alone desynced each offset from its - # note's velocity whenever the XML listed events out of time order. - paired = sorted(zip(times, velocities_raw)) - times = [t for t, _ in paired] - velocities_raw = [v for _, v in paired] - - note_count = len(times) - - # Infer grid from clip length and note count — valid only for the - # one-note-per-cell clip shape (see docstring); grid= overrides. - if grid is None: - grid = clip_length / note_count - - if grid <= 0: - raise ValueError(f"grid must be positive — got {grid}") - - slot_count = max(1, int(round(clip_length / grid))) - - # Bind each note to its NEAREST grid line (robust to rests under an - # explicit grid — empty cells keep a neutral offset), refusing - # ambiguous clips instead of importing a garbage feel. - slot_offsets = [0.0] * slot_count - slot_velocities: typing.List[typing.Optional[float]] = [None] * slot_count - - for time, velocity in zip(times, velocities_raw): - - slot = int(round(time / grid)) - - if not 0 <= slot < slot_count: - raise ValueError( - f"{path}: note at beat {time:g} falls outside the {slot_count}-cell " - f"grid (grid={grid:g}, clip length {clip_length:g}) — pass grid= " - "matching the clip's note spacing" - ) - - if slot_velocities[slot] is not None: - raise ValueError( - f"{path}: two notes share grid cell {slot} (a chord, or a grid " - "coarser than the clip's note spacing) — pass grid= matching the " - "clip (e.g. grid=0.25 for 16ths)" - ) - - slot_offsets[slot] = (time - slot * grid) * timing_scale - slot_velocities[slot] = velocity - - # Calculate velocity scales (relative to max velocity in the file), - # blended toward 1.0 by VelocityAmount; empty cells stay neutral (1.0). - filled = [v for v in slot_velocities if v is not None] - max_vel = max(filled) - has_velocity_variation = any(v != max_vel for v in filled) - groove_velocities: typing.Optional[typing.List[float]] = None - if has_velocity_variation and max_vel > 0: - raw_scales = [(v / max_vel) if v is not None else 1.0 for v in slot_velocities] - # velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change) - groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales] - # If blending has removed all variation, set to None - if all(abs(v - 1.0) < 1e-9 for v in groove_velocities): - groove_velocities = None - - return Groove(offsets=slot_offsets, grid=grid, velocities=groove_velocities) - - -def apply_groove ( - steps: typing.Dict[int, "subsequence.pattern.Step"], - groove: Groove, - pulses_per_quarter: int = subsequence.constants.MIDI_QUARTER_NOTE, - strength: float = 1.0, + """ + A timing/velocity template applied to quantized grid positions. + + A groove is a repeating pattern of per-step timing offsets and optional + velocity adjustments aligned to a rhythmic grid. Apply it as a post-build + transform with ``p.groove(template)`` to give a pattern its characteristic + feel — swing, shuffle, MPC-style pocket, or anything extracted from an + Ableton ``.agr`` file. + + Parameters: + offsets: Timing offset per grid slot, in beats. Repeats cyclically. + Positive values delay the note; negative values push it earlier. + grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes). + velocities: Optional velocity scale per grid slot (1.0 = unchanged). + Repeats cyclically alongside offsets. + + Example:: + + # Ableton-style 57% swing on 16th notes + groove = Groove.swing(percent=57) + + # Custom groove with timing and velocity + groove = Groove( + grid=0.25, + offsets=[0.0, +0.02, 0.0, -0.01], + velocities=[1.0, 0.7, 0.9, 0.6], + ) + """ + + offsets: typing.List[float] + grid: float = 0.25 + velocities: typing.Optional[typing.List[float]] = None + + def __post_init__(self) -> None: + if not self.offsets: + raise ValueError("offsets must not be empty") + if self.grid <= 0: + raise ValueError("grid must be positive") + if self.velocities is not None and not self.velocities: + raise ValueError( + "velocities must not be empty (use None for no velocity adjustment)" + ) + + @staticmethod + def swing(percent: float = 57.0, grid: float = 0.25) -> "Groove": + """ + Create a swing groove from a percentage. + + 50% is straight (no swing). 67% is approximately triplet swing. + 57% is a moderate shuffle — the Ableton default. + + Parameters: + percent: Swing amount (50–75 is the useful range). + grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths). + """ + + if percent < 50.0 or percent > 99.0: + raise ValueError("swing percent must be between 50 and 99") + pair_duration = grid * 2 + offset = (percent / 100.0 - 0.5) * pair_duration + return Groove(offsets=[0.0, offset], grid=grid) + + @staticmethod + def from_agr(path: str, grid: typing.Optional[float] = None) -> "Groove": + """ + Import timing and velocity data from an Ableton .agr groove file. + + An ``.agr`` file is an XML document containing a MIDI clip whose + note positions encode the groove's rhythmic feel. This method reads + those note start times and velocities and converts them into the + ``Groove`` dataclass format (per-step offsets and velocity scales). + + Without ``grid=``, the grid is inferred as ``clip length / note + count`` — which assumes the clip plays **exactly one note per grid + cell** (the standard shape for a groove clip). A clip with rests or + chords breaks that assumption: pass ``grid=`` explicitly (e.g. + ``grid=0.25`` for a 16th-note groove) and empty cells keep a neutral + offset. A clip whose notes cannot be assigned one-per-cell raises + rather than importing a wrong feel. + + **What is extracted:** + + - ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets + relative to ideal grid positions. + - ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity + scaling (normalised to the highest velocity in the file). + - ``TimingAmount`` from the Groove element → pre-scales the timing + offsets (100 = full, 70 = 70% of the groove's timing). + - ``VelocityAmount`` from the Groove element → pre-scales velocity + deviation (100 = full groove velocity, 0 = no velocity changes). + + The resulting ``Groove`` reflects the file author's intended + strength. Use ``strength=`` when applying to further adjust. + + **What is NOT imported:** + + ``RandomAmount`` (use ``p.randomize()`` separately for random + jitter) and ``QuantizationAmount`` (not applicable - Subsequence + notes are already grid-quantized by construction). + + Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``, + ``OffVelocity``, ``Probability``) are also ignored. + + Parameters: + path: Path to the .agr file. + grid: Grid size in beats (0.25 = 16th notes). ``None`` (default) + infers it from the clip, assuming one note per cell. + """ + + tree = xml.etree.ElementTree.parse(path) + root = tree.getroot() + + # Find the MIDI clip + clip = root.find(".//MidiClip") + if clip is None: + raise ValueError(f"No MidiClip found in {path}") + + # Get clip length + current_end = clip.find("CurrentEnd") + if current_end is None: + raise ValueError(f"No CurrentEnd found in {path}") + clip_length = float(current_end.get("Value", "4")) + + # Read Groove Pool blend parameters + groove_elem = root.find(".//Groove") + timing_amount = 100.0 + velocity_amount = 100.0 + if groove_elem is not None: + timing_el = groove_elem.find("TimingAmount") + if timing_el is not None: + timing_amount = float(timing_el.get("Value", "100")) + velocity_el = groove_elem.find("VelocityAmount") + if velocity_el is not None: + velocity_amount = float(velocity_el.get("Value", "100")) + + timing_scale = timing_amount / 100.0 + velocity_scale = velocity_amount / 100.0 + + # Extract note events sorted by time + events = clip.findall(".//MidiNoteEvent") + if not events: + raise ValueError(f"No MidiNoteEvent elements found in {path}") + + times: typing.List[float] = [] + velocities_raw: typing.List[float] = [] + for event in events: + times.append(float(event.get("Time", "0"))) + velocities_raw.append(float(event.get("Velocity", "127"))) + + # Sort as PAIRS - sorting times alone desynced each offset from its + # note's velocity whenever the XML listed events out of time order. + paired = sorted(zip(times, velocities_raw)) + times = [t for t, _ in paired] + velocities_raw = [v for _, v in paired] + + note_count = len(times) + + # Infer grid from clip length and note count — valid only for the + # one-note-per-cell clip shape (see docstring); grid= overrides. + if grid is None: + grid = clip_length / note_count + + if grid <= 0: + raise ValueError(f"grid must be positive — got {grid}") + + slot_count = max(1, int(round(clip_length / grid))) + + # Bind each note to its NEAREST grid line (robust to rests under an + # explicit grid — empty cells keep a neutral offset), refusing + # ambiguous clips instead of importing a garbage feel. + slot_offsets = [0.0] * slot_count + slot_velocities: typing.List[typing.Optional[float]] = [None] * slot_count + + for time, velocity in zip(times, velocities_raw): + slot = int(round(time / grid)) + + if not 0 <= slot < slot_count: + raise ValueError( + f"{path}: note at beat {time:g} falls outside the {slot_count}-cell " + f"grid (grid={grid:g}, clip length {clip_length:g}) — pass grid= " + "matching the clip's note spacing" + ) + + if slot_velocities[slot] is not None: + raise ValueError( + f"{path}: two notes share grid cell {slot} (a chord, or a grid " + "coarser than the clip's note spacing) — pass grid= matching the " + "clip (e.g. grid=0.25 for 16ths)" + ) + + slot_offsets[slot] = (time - slot * grid) * timing_scale + slot_velocities[slot] = velocity + + # Calculate velocity scales (relative to max velocity in the file), + # blended toward 1.0 by VelocityAmount; empty cells stay neutral (1.0). + filled = [v for v in slot_velocities if v is not None] + max_vel = max(filled) + has_velocity_variation = any(v != max_vel for v in filled) + groove_velocities: typing.Optional[typing.List[float]] = None + if has_velocity_variation and max_vel > 0: + raw_scales = [ + (v / max_vel) if v is not None else 1.0 for v in slot_velocities + ] + # velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change) + groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales] + # If blending has removed all variation, set to None + if all(abs(v - 1.0) < 1e-9 for v in groove_velocities): + groove_velocities = None + + return Groove(offsets=slot_offsets, grid=grid, velocities=groove_velocities) + + +def apply_groove( + steps: typing.Dict[int, "subsequence.pattern.Step"], + groove: Groove, + pulses_per_quarter: int = subsequence.constants.MIDI_QUARTER_NOTE, + strength: float = 1.0, ) -> typing.Dict[int, "subsequence.pattern.Step"]: - - """ - Apply a groove template to a step dictionary keyed by pulse positions. - - Notes close to a grid position are shifted by the groove's offset for - that slot. Notes between grid positions are left untouched. - - Parameters: - steps: Step dictionary (pulse → Step). - groove: The groove template to apply. - pulses_per_quarter: Internal MIDI clock resolution (default 24). - strength: How much of the groove to apply (0.0–1.0). - 0.0 leaves all timing and velocity unchanged; 1.0 applies - the full groove. Intermediate values blend between the two, - equivalent to Ableton’s TimingAmount / VelocityAmount dials. - """ - - if not 0.0 <= strength <= 1.0: - raise ValueError("strength must be between 0.0 and 1.0") - - grid_pulses = groove.grid * pulses_per_quarter - if grid_pulses <= 0: - return dict(steps) - - half_grid = grid_pulses / 2.0 - num_offsets = len(groove.offsets) - num_velocities = len(groove.velocities) if groove.velocities else 0 - - new_steps: typing.Dict[int, subsequence.pattern.Step] = {} - - for old_pulse, step in steps.items(): - - # Find nearest grid position - grid_index = round(old_pulse / grid_pulses) - ideal_pulse = grid_index * grid_pulses - - # Only groove notes that sit close to a grid position; notes deliberately - # placed between grid lines (flams, pushes) keep both their timing AND - # velocity. The window is ±25% of a cell (half_grid * 0.5) — narrow on - # purpose, so off-grid expression survives a quantised groove. - if abs(old_pulse - ideal_pulse) > half_grid * 0.5: - new_pulse = old_pulse - else: - slot = grid_index % num_offsets - - # Blend from the note's OWN pulse toward the groove target so - # strength=0.0 truly leaves timing untouched. (Blending from - # ideal_pulse quantised away in-window micro-timing — e.g. from - # randomize() — at every strength, including 0.) - groove_target = ideal_pulse + groove.offsets[slot] * pulses_per_quarter - new_pulse = int(round(old_pulse + (groove_target - old_pulse) * strength)) - new_pulse = max(0, new_pulse) - - # Velocity scaling applies only to grooved (on-grid) notes, for the - # same reason — an off-grid note shouldn't pick up a slot's accent. - if groove.velocities and num_velocities > 0: - vel_slot = grid_index % num_velocities - # Blend between 1.0 (no effect) and the groove's scale (full effect) - vel_scale = 1.0 + (groove.velocities[vel_slot] - 1.0) * strength - step = _scale_step_velocity(step, vel_scale) - - if new_pulse not in new_steps: - new_steps[new_pulse] = subsequence.pattern.Step() - - new_steps[new_pulse].notes.extend(step.notes) - - return new_steps - - -def _scale_step_velocity (step: "subsequence.pattern.Step", scale: float) -> "subsequence.pattern.Step": - - """ - Return a copy of the step with scaled velocities. - """ - - import subsequence.pattern - - new_notes = [] - for note in step.notes: - new_notes.append(dataclasses.replace( - note, - velocity=max(1, min(127, int(round(note.velocity * scale)))) - )) - return subsequence.pattern.Step(notes=new_notes) + """ + Apply a groove template to a step dictionary keyed by pulse positions. + + Notes close to a grid position are shifted by the groove's offset for + that slot. Notes between grid positions are left untouched. + + Parameters: + steps: Step dictionary (pulse → Step). + groove: The groove template to apply. + pulses_per_quarter: Internal MIDI clock resolution (default 24). + strength: How much of the groove to apply (0.0–1.0). + 0.0 leaves all timing and velocity unchanged; 1.0 applies + the full groove. Intermediate values blend between the two, + equivalent to Ableton’s TimingAmount / VelocityAmount dials. + """ + + if not 0.0 <= strength <= 1.0: + raise ValueError("strength must be between 0.0 and 1.0") + + grid_pulses = groove.grid * pulses_per_quarter + if grid_pulses <= 0: + return dict(steps) + + half_grid = grid_pulses / 2.0 + num_offsets = len(groove.offsets) + num_velocities = len(groove.velocities) if groove.velocities else 0 + + new_steps: typing.Dict[int, subsequence.pattern.Step] = {} + + for old_pulse, step in steps.items(): + # Find nearest grid position + grid_index = round(old_pulse / grid_pulses) + ideal_pulse = grid_index * grid_pulses + + # Only groove notes that sit close to a grid position; notes deliberately + # placed between grid lines (flams, pushes) keep both their timing AND + # velocity. The window is ±25% of a cell (half_grid * 0.5) — narrow on + # purpose, so off-grid expression survives a quantised groove. + if abs(old_pulse - ideal_pulse) > half_grid * 0.5: + new_pulse = old_pulse + else: + slot = grid_index % num_offsets + + # Blend from the note's OWN pulse toward the groove target so + # strength=0.0 truly leaves timing untouched. (Blending from + # ideal_pulse quantised away in-window micro-timing — e.g. from + # randomize() — at every strength, including 0.) + groove_target = ideal_pulse + groove.offsets[slot] * pulses_per_quarter + new_pulse = int(round(old_pulse + (groove_target - old_pulse) * strength)) + new_pulse = max(0, new_pulse) + + # Velocity scaling applies only to grooved (on-grid) notes, for the + # same reason — an off-grid note shouldn't pick up a slot's accent. + if groove.velocities and num_velocities > 0: + vel_slot = grid_index % num_velocities + # Blend between 1.0 (no effect) and the groove's scale (full effect) + vel_scale = 1.0 + (groove.velocities[vel_slot] - 1.0) * strength + step = _scale_step_velocity(step, vel_scale) + + if new_pulse not in new_steps: + new_steps[new_pulse] = subsequence.pattern.Step() + + new_steps[new_pulse].notes.extend(step.notes) + + return new_steps + + +def _scale_step_velocity( + step: "subsequence.pattern.Step", scale: float +) -> "subsequence.pattern.Step": + """ + Return a copy of the step with scaled velocities. + """ + + import subsequence.pattern + + new_notes = [] + for note in step.notes: + new_notes.append( + dataclasses.replace( + note, velocity=max(1, min(127, int(round(note.velocity * scale)))) + ) + ) + return subsequence.pattern.Step(notes=new_notes) diff --git a/subsequence/harmonic_rhythm.py b/subsequence/harmonic_rhythm.py index c4ae383..16009fc 100644 --- a/subsequence/harmonic_rhythm.py +++ b/subsequence/harmonic_rhythm.py @@ -6,78 +6,80 @@ @dataclasses.dataclass(frozen=True) class HarmonicRhythm: - - """A bounded, optionally-quantised *harmonic rhythm* — how long each chord lasts. - - Harmonic rhythm is the rate at which the chords change. It can be regular, - irregular, or static; this spec describes the **irregular** case — each chord - lasts a fresh random length somewhere between ``low`` and ``high`` beats. When - ``step`` is given, those lengths snap to whole multiples of it, so the result - is irregular but still lands on a musical grid (e.g. always a whole-note - boundary). - - Build one with :func:`between` rather than constructing it directly:: - - harmonic_rhythm = between(WHOLE, 3 * WHOLE, step=WHOLE) # 1, 2, or 3 whole notes - - The other two harmonic-rhythm shapes are expressed without this class: - a single ``float`` (static — every chord the same length) and a ``list`` of - floats (a *shaped* rhythm such as ``[WHOLE, HALF, HALF]``, cycled per chord). - ``p.progression()`` / ``comp.chords()`` accept all three. - """ - - low: float - high: float - step: typing.Optional[float] = None - - def __post_init__ (self) -> None: - - """Validate the bounds at construction so a typo surfaces at the call site.""" - - if self.low <= 0: - raise ValueError(f"harmonic rhythm low ({self.low:g}) must be positive — lengths are in beats") - if self.high < self.low: - raise ValueError(f"harmonic rhythm high ({self.high:g}) must be at least low ({self.low:g})") - if self.step is not None and self.step <= 0: - raise ValueError(f"harmonic rhythm step ({self.step:g}) must be positive") - - def resolve (self, rng: random.Random) -> float: - - """Draw one chord length in beats from this spec. - - With a ``step``, the length is a whole multiple of it snapped *inside* - ``[low, high]`` (so a quantised rhythm never strays past its bounds). If - no whole multiple fits within the bounds, the nearest in-range length is - used. Without a ``step``, the draw is continuous and uniform. - """ - - if self.step: - # Smallest/largest step-multiples that still sit within the bounds. - # The epsilon absorbs float dust so e.g. 12 / 4 floors to 3, not 2. - lo = max(1, math.ceil(self.low / self.step - 1e-9)) - hi = math.floor(self.high / self.step + 1e-9) - if hi < lo: - hi = lo - length = rng.randint(lo, hi) * self.step - - # Honour the bounds over the grid: when no whole multiple fits inside - # [low, high] (e.g. between(2, 3, step=4)), clamp to the nearest edge so - # the result never strays past the bounds the musician asked for. - return float(min(self.high, max(self.low, length))) - - return float(rng.uniform(self.low, self.high)) - - -def between (low: float, high: float, step: typing.Optional[float] = None) -> HarmonicRhythm: - - """A harmonic rhythm that varies *between* two lengths (in beats). - - Each chord lasts a random length in ``[low, high]``. Pass ``step`` to snap - those lengths to a grid — e.g. ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` gives - one, two, or three whole notes, never anything in between. - - Reads aloud the way you'd describe it: "between one and three whole notes, - in whole-note steps." - """ - - return HarmonicRhythm(low=low, high=high, step=step) + """A bounded, optionally-quantised *harmonic rhythm* — how long each chord lasts. + + Harmonic rhythm is the rate at which the chords change. It can be regular, + irregular, or static; this spec describes the **irregular** case — each chord + lasts a fresh random length somewhere between ``low`` and ``high`` beats. When + ``step`` is given, those lengths snap to whole multiples of it, so the result + is irregular but still lands on a musical grid (e.g. always a whole-note + boundary). + + Build one with :func:`between` rather than constructing it directly:: + + harmonic_rhythm = between(WHOLE, 3 * WHOLE, step=WHOLE) # 1, 2, or 3 whole notes + + The other two harmonic-rhythm shapes are expressed without this class: + a single ``float`` (static — every chord the same length) and a ``list`` of + floats (a *shaped* rhythm such as ``[WHOLE, HALF, HALF]``, cycled per chord). + ``p.progression()`` / ``comp.chords()`` accept all three. + """ + + low: float + high: float + step: typing.Optional[float] = None + + def __post_init__(self) -> None: + """Validate the bounds at construction so a typo surfaces at the call site.""" + + if self.low <= 0: + raise ValueError( + f"harmonic rhythm low ({self.low:g}) must be positive — lengths are in beats" + ) + if self.high < self.low: + raise ValueError( + f"harmonic rhythm high ({self.high:g}) must be at least low ({self.low:g})" + ) + if self.step is not None and self.step <= 0: + raise ValueError(f"harmonic rhythm step ({self.step:g}) must be positive") + + def resolve(self, rng: random.Random) -> float: + """Draw one chord length in beats from this spec. + + With a ``step``, the length is a whole multiple of it snapped *inside* + ``[low, high]`` (so a quantised rhythm never strays past its bounds). If + no whole multiple fits within the bounds, the nearest in-range length is + used. Without a ``step``, the draw is continuous and uniform. + """ + + if self.step: + # Smallest/largest step-multiples that still sit within the bounds. + # The epsilon absorbs float dust so e.g. 12 / 4 floors to 3, not 2. + lo = max(1, math.ceil(self.low / self.step - 1e-9)) + hi = math.floor(self.high / self.step + 1e-9) + if hi < lo: + hi = lo + length = rng.randint(lo, hi) * self.step + + # Honour the bounds over the grid: when no whole multiple fits inside + # [low, high] (e.g. between(2, 3, step=4)), clamp to the nearest edge so + # the result never strays past the bounds the musician asked for. + return float(min(self.high, max(self.low, length))) + + return float(rng.uniform(self.low, self.high)) + + +def between( + low: float, high: float, step: typing.Optional[float] = None +) -> HarmonicRhythm: + """A harmonic rhythm that varies *between* two lengths (in beats). + + Each chord lasts a random length in ``[low, high]``. Pass ``step`` to snap + those lengths to a grid — e.g. ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` gives + one, two, or three whole notes, never anything in between. + + Reads aloud the way you'd describe it: "between one and three whole notes, + in whole-note steps." + """ + + return HarmonicRhythm(low=low, high=high, step=step) diff --git a/subsequence/harmonic_state.py b/subsequence/harmonic_state.py index 10d7cc1..460743d 100644 --- a/subsequence/harmonic_state.py +++ b/subsequence/harmonic_state.py @@ -20,333 +20,323 @@ DEFAULT_ROOT_DIVERSITY: float = 0.4 - # --------------------------------------------------------------------------- # Graph style registry — see _resolve_graph_style() below. # To register a new chord graph, add it to _D7_STYLES or _SIMPLE_STYLES there. # --------------------------------------------------------------------------- -def _resolve_graph_style ( - style: str, - include_dominant_7th: bool, - minor_turnaround_weight: float +def _resolve_graph_style( + style: str, include_dominant_7th: bool, minor_turnaround_weight: float ) -> subsequence.chord_graphs.ChordGraph: - - """Create a ChordGraph instance from a string style name and legacy parameters.""" - - if style in ("diatonic_major", "functional_major"): - return subsequence.chord_graphs.functional_major.DiatonicMajor( - include_dominant_7th = include_dominant_7th - ) - - if style in ("turnaround", "turnaround_global"): - return subsequence.chord_graphs.turnaround_global.TurnaroundModulation( - include_dominant_7th = include_dominant_7th, - minor_turnaround_weight = minor_turnaround_weight - ) - - # Styles with only an include_dominant_7th parameter. - _D7_STYLES: typing.Dict[ - str, - typing.Callable[[bool], subsequence.chord_graphs.ChordGraph] - ] = { - "aeolian_minor": subsequence.chord_graphs.aeolian_minor.AeolianMinor, - "lydian_major": subsequence.chord_graphs.lydian_major.LydianMajor, - "dorian_minor": subsequence.chord_graphs.dorian_minor.DorianMinor, - "hooktheory_major": subsequence.chord_graphs.hooktheory_major.HooktheoryMajor, - "pop_major": subsequence.chord_graphs.hooktheory_major.HooktheoryMajor, - } - if style in _D7_STYLES: - return _D7_STYLES[style](include_dominant_7th) - - # Styles that take no extra parameters. - _SIMPLE_STYLES: typing.Dict[ - str, - typing.Callable[[], subsequence.chord_graphs.ChordGraph] - ] = { - "phrygian_minor": subsequence.chord_graphs.phrygian_minor.PhrygianMinor, - "chromatic_mediant": subsequence.chord_graphs.chromatic_mediant.ChromaticMediant, - "suspended": subsequence.chord_graphs.suspended.Suspended, - "mixolydian": subsequence.chord_graphs.mixolydian.Mixolydian, - "whole_tone": subsequence.chord_graphs.whole_tone.WholeTone, - "diminished": subsequence.chord_graphs.diminished.Diminished, - } - if style in _SIMPLE_STYLES: - return _SIMPLE_STYLES[style]() - - raise ValueError(f"Unknown graph style: {style!r}") - + """Create a ChordGraph instance from a string style name and legacy parameters.""" + + if style in ("diatonic_major", "functional_major"): + return subsequence.chord_graphs.functional_major.DiatonicMajor( + include_dominant_7th=include_dominant_7th + ) + + if style in ("turnaround", "turnaround_global"): + return subsequence.chord_graphs.turnaround_global.TurnaroundModulation( + include_dominant_7th=include_dominant_7th, + minor_turnaround_weight=minor_turnaround_weight, + ) + + # Styles with only an include_dominant_7th parameter. + _D7_STYLES: typing.Dict[ + str, typing.Callable[[bool], subsequence.chord_graphs.ChordGraph] + ] = { + "aeolian_minor": subsequence.chord_graphs.aeolian_minor.AeolianMinor, + "lydian_major": subsequence.chord_graphs.lydian_major.LydianMajor, + "dorian_minor": subsequence.chord_graphs.dorian_minor.DorianMinor, + "hooktheory_major": subsequence.chord_graphs.hooktheory_major.HooktheoryMajor, + "pop_major": subsequence.chord_graphs.hooktheory_major.HooktheoryMajor, + } + if style in _D7_STYLES: + return _D7_STYLES[style](include_dominant_7th) + + # Styles that take no extra parameters. + _SIMPLE_STYLES: typing.Dict[ + str, typing.Callable[[], subsequence.chord_graphs.ChordGraph] + ] = { + "phrygian_minor": subsequence.chord_graphs.phrygian_minor.PhrygianMinor, + "chromatic_mediant": subsequence.chord_graphs.chromatic_mediant.ChromaticMediant, + "suspended": subsequence.chord_graphs.suspended.Suspended, + "mixolydian": subsequence.chord_graphs.mixolydian.Mixolydian, + "whole_tone": subsequence.chord_graphs.whole_tone.WholeTone, + "diminished": subsequence.chord_graphs.diminished.Diminished, + } + if style in _SIMPLE_STYLES: + return _SIMPLE_STYLES[style]() + + raise ValueError(f"Unknown graph style: {style!r}") class HarmonicState: + """Holds the current chord and key context for the composition.""" + + def __init__( + self, + key_name: str, + graph_style: typing.Union[ + str, subsequence.chord_graphs.ChordGraph + ] = "functional_major", + include_dominant_7th: bool = True, + key_gravity_blend: float = 1.0, + nir_strength: float = 0.5, + minor_turnaround_weight: float = 0.0, + root_diversity: float = DEFAULT_ROOT_DIVERSITY, + rng: typing.Optional[random.Random] = None, + ) -> None: + """ + Initialize the harmonic state using a chord transition graph. + + Parameters: + key_name: Note name for the key (e.g., ``"C"``, ``"F#"``). + graph_style: Built-in style name or a custom ``ChordGraph`` instance. + include_dominant_7th: Include V7 chords in the graph (default True). + key_gravity_blend: Balance between functional and diatonic gravity + (0.0 = functional only, 1.0 = full diatonic). Default 1.0. + nir_strength: Melodic inertia from Narmour's Implication-Realization + model (0.0 = off, 1.0 = full). Default 0.5. + minor_turnaround_weight: For turnaround style, weight toward minor + turnarounds (0.0 to 1.0). Default 0.0. + root_diversity: Root-repetition damping factor (0.0 to 1.0). Each + recent chord sharing a candidate's root pitch class multiplies + the transition weight by this factor. At the default (0.4), one + recent same-root chord reduces the weight to 40%; two reduce it + to 16%. Set to 1.0 to disable the penalty entirely. Default 0.4. + rng: Optional seeded ``random.Random`` for deterministic playback. + """ + + if key_gravity_blend < 0 or key_gravity_blend > 1: + raise ValueError("Key gravity blend must be between 0 and 1") + + if nir_strength < 0 or nir_strength > 1: + raise ValueError("NIR strength must be between 0 and 1") + + if minor_turnaround_weight < 0 or minor_turnaround_weight > 1: + raise ValueError("Minor turnaround weight must be between 0 and 1") + + if root_diversity < 0 or root_diversity > 1: + raise ValueError("Root diversity must be between 0 and 1") + + self.key_name = key_name + self.key_root_pc = subsequence.chords.key_name_to_pc(key_name) + self.key_gravity_blend = key_gravity_blend + self.nir_strength = nir_strength + self.root_diversity = root_diversity + self.minor_turnaround_weight = minor_turnaround_weight + + if isinstance(graph_style, str): + chord_graph = _resolve_graph_style( + graph_style, include_dominant_7th, minor_turnaround_weight + ) + + else: + chord_graph = graph_style + + self.graph, tonic = chord_graph.build(key_name) + self._diatonic_chords, self._function_chords = chord_graph.gravity_sets( + key_name + ) + + self.rng = rng or random.Random() + self.current_chord = tonic + self.history: typing.List[subsequence.chords.Chord] = [] + + def _calculate_nir_score( + self, source: subsequence.chords.Chord, target: subsequence.chords.Chord + ) -> float: + """ + Calculate a Narmour Implication-Realization (NIR) score for a transition. + Returns a multiplier (default 1.0, >1.0 for boost). + """ + + # step() appends the current chord to history BEFORE choosing, so + # history[-1] is always the source itself; the implication interval + # needs the chord we arrived FROM, which is history[-2]. (Using + # history[-1] here was the pre-2026-06 bug that left the reversal + # and continuation rules permanently inert.) + if len(self.history) < 2: + return 1.0 + + prev = self.history[-2] + + # Calculate interval from Prev -> Source (The "Implication" generator) + # Using shortest-path distance in Pitch Class space (-6 to +6) + prev_diff = (source.root_pc - prev.root_pc) % 12 + if prev_diff > 6: + prev_diff -= 12 + + prev_interval = abs(prev_diff) + prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0 + + # Calculate interval from Source -> Target (The "Realization") + target_diff = (target.root_pc - source.root_pc) % 12 + if target_diff > 6: + target_diff -= 12 + + target_interval = abs(target_diff) + target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0 + + score = 1.0 + + # --- Rule A: Reversal (Gap Fill) --- + # If the previous step was a large leap (> 4 on the 0–6 pitch-class + # shortest-path scale, where a tritone is 6), expect a direction change. + if prev_interval > 4: + # Expect change in direction + if target_direction != prev_direction and target_direction != 0: + score += 0.5 + + # Expect smaller interval (Gap Fill) + if target_interval < 4: + score += 0.3 + + # --- Rule B: Process (Continuation/Inertia) --- + # If previous was Small Step (< 3 semitones), expect similarity. + elif prev_interval > 0 and prev_interval < 3: + # Expect same direction + if target_direction == prev_direction: + score += 0.4 + + # Expect similar size + if abs(target_interval - prev_interval) <= 1: + score += 0.2 + + # --- Rule C: Closure --- + # Return to Tonic (Closure) is often implied after tension + if target.root_pc == self.key_root_pc: + score += 0.2 + + # --- Rule D: Proximity --- + # General preference for small intervals (≤ 3 semitones). + if target_interval > 0 and target_interval <= 3: + score += 0.3 + + # Scale the boost portion by nir_strength (score starts at 1.0, boost is the excess) + return 1.0 + (score - 1.0) * self.nir_strength + + def _transition_weight( + self, + source: subsequence.chords.Chord, + target: subsequence.chords.Chord, + weight: int, + ) -> float: + """ + Combine three forces that shape chord transition probabilities: + + 1. **Key gravity** — blends functional pull (tonic, dominant) with + full diatonic pull, controlled by ``key_gravity_blend``. + 2. **Melodic inertia (NIR)** — Narmour's cognitive expectation + model favoring continuation after small steps and reversal + after large leaps, controlled by ``nir_strength``. + 3. **Root diversity** — exponential damping that discourages + revisiting a root pitch class heard recently, controlled by + ``root_diversity``. Each recent chord sharing the target's + root multiplies the weight by ``root_diversity`` (default + 0.4), so the penalty grows stronger with each consecutive + same-root step. + + The final modifier is: + + ``(1 + gravity_boost) × nir_score × diversity`` + """ + + is_function = 1.0 if target in self._function_chords else 0.0 + is_diatonic = 1.0 if target in self._diatonic_chords else 0.0 + + # Decision path: blend controls whether key gravity favors functional or full diatonic chords. + boost = ( + 1.0 - self.key_gravity_blend + ) * is_function + self.key_gravity_blend * is_diatonic - """Holds the current chord and key context for the composition.""" - - def __init__ ( - self, - key_name: str, - graph_style: typing.Union[str, subsequence.chord_graphs.ChordGraph] = "functional_major", - include_dominant_7th: bool = True, - key_gravity_blend: float = 1.0, - nir_strength: float = 0.5, - minor_turnaround_weight: float = 0.0, - root_diversity: float = DEFAULT_ROOT_DIVERSITY, - rng: typing.Optional[random.Random] = None - ) -> None: - - """ - Initialize the harmonic state using a chord transition graph. - - Parameters: - key_name: Note name for the key (e.g., ``"C"``, ``"F#"``). - graph_style: Built-in style name or a custom ``ChordGraph`` instance. - include_dominant_7th: Include V7 chords in the graph (default True). - key_gravity_blend: Balance between functional and diatonic gravity - (0.0 = functional only, 1.0 = full diatonic). Default 1.0. - nir_strength: Melodic inertia from Narmour's Implication-Realization - model (0.0 = off, 1.0 = full). Default 0.5. - minor_turnaround_weight: For turnaround style, weight toward minor - turnarounds (0.0 to 1.0). Default 0.0. - root_diversity: Root-repetition damping factor (0.0 to 1.0). Each - recent chord sharing a candidate's root pitch class multiplies - the transition weight by this factor. At the default (0.4), one - recent same-root chord reduces the weight to 40%; two reduce it - to 16%. Set to 1.0 to disable the penalty entirely. Default 0.4. - rng: Optional seeded ``random.Random`` for deterministic playback. - """ - - if key_gravity_blend < 0 or key_gravity_blend > 1: - raise ValueError("Key gravity blend must be between 0 and 1") - - if nir_strength < 0 or nir_strength > 1: - raise ValueError("NIR strength must be between 0 and 1") - - if minor_turnaround_weight < 0 or minor_turnaround_weight > 1: - raise ValueError("Minor turnaround weight must be between 0 and 1") - - if root_diversity < 0 or root_diversity > 1: - raise ValueError("Root diversity must be between 0 and 1") - - self.key_name = key_name - self.key_root_pc = subsequence.chords.key_name_to_pc(key_name) - self.key_gravity_blend = key_gravity_blend - self.nir_strength = nir_strength - self.root_diversity = root_diversity - self.minor_turnaround_weight = minor_turnaround_weight - - - if isinstance(graph_style, str): - chord_graph = _resolve_graph_style(graph_style, include_dominant_7th, minor_turnaround_weight) - - else: - chord_graph = graph_style - - self.graph, tonic = chord_graph.build(key_name) - self._diatonic_chords, self._function_chords = chord_graph.gravity_sets(key_name) - - self.rng = rng or random.Random() - self.current_chord = tonic - self.history: typing.List[subsequence.chords.Chord] = [] - - - def _calculate_nir_score (self, source: subsequence.chords.Chord, target: subsequence.chords.Chord) -> float: - - """ - Calculate a Narmour Implication-Realization (NIR) score for a transition. - Returns a multiplier (default 1.0, >1.0 for boost). - """ - - # step() appends the current chord to history BEFORE choosing, so - # history[-1] is always the source itself; the implication interval - # needs the chord we arrived FROM, which is history[-2]. (Using - # history[-1] here was the pre-2026-06 bug that left the reversal - # and continuation rules permanently inert.) - if len(self.history) < 2: - return 1.0 - - prev = self.history[-2] - - # Calculate interval from Prev -> Source (The "Implication" generator) - # Using shortest-path distance in Pitch Class space (-6 to +6) - prev_diff = (source.root_pc - prev.root_pc) % 12 - if prev_diff > 6: - prev_diff -= 12 - - prev_interval = abs(prev_diff) - prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0 - - # Calculate interval from Source -> Target (The "Realization") - target_diff = (target.root_pc - source.root_pc) % 12 - if target_diff > 6: - target_diff -= 12 - - target_interval = abs(target_diff) - target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0 - - score = 1.0 - - # --- Rule A: Reversal (Gap Fill) --- - # If the previous step was a large leap (> 4 on the 0–6 pitch-class - # shortest-path scale, where a tritone is 6), expect a direction change. - if prev_interval > 4: - # Expect change in direction - if target_direction != prev_direction and target_direction != 0: - score += 0.5 - - # Expect smaller interval (Gap Fill) - if target_interval < 4: - score += 0.3 - - # --- Rule B: Process (Continuation/Inertia) --- - # If previous was Small Step (< 3 semitones), expect similarity. - elif prev_interval > 0 and prev_interval < 3: - # Expect same direction - if target_direction == prev_direction: - score += 0.4 - - # Expect similar size - if abs(target_interval - prev_interval) <= 1: - score += 0.2 - - # --- Rule C: Closure --- - # Return to Tonic (Closure) is often implied after tension - if target.root_pc == self.key_root_pc: - score += 0.2 - - # --- Rule D: Proximity --- - # General preference for small intervals (≤ 3 semitones). - if target_interval > 0 and target_interval <= 3: - score += 0.3 - - # Scale the boost portion by nir_strength (score starts at 1.0, boost is the excess) - return 1.0 + (score - 1.0) * self.nir_strength - - def _transition_weight ( - self, - source: subsequence.chords.Chord, - target: subsequence.chords.Chord, - weight: int - ) -> float: - - """ - Combine three forces that shape chord transition probabilities: - - 1. **Key gravity** — blends functional pull (tonic, dominant) with - full diatonic pull, controlled by ``key_gravity_blend``. - 2. **Melodic inertia (NIR)** — Narmour's cognitive expectation - model favoring continuation after small steps and reversal - after large leaps, controlled by ``nir_strength``. - 3. **Root diversity** — exponential damping that discourages - revisiting a root pitch class heard recently, controlled by - ``root_diversity``. Each recent chord sharing the target's - root multiplies the weight by ``root_diversity`` (default - 0.4), so the penalty grows stronger with each consecutive - same-root step. - - The final modifier is: - - ``(1 + gravity_boost) × nir_score × diversity`` - """ - - is_function = 1.0 if target in self._function_chords else 0.0 - is_diatonic = 1.0 if target in self._diatonic_chords else 0.0 - - # Decision path: blend controls whether key gravity favors functional or full diatonic chords. - boost = (1.0 - self.key_gravity_blend) * is_function + self.key_gravity_blend * is_diatonic - - # Apply NIR gravity - nir_score = self._calculate_nir_score(source, target) - - # Root diversity: penalise transitions to a root heard recently. - recent_same_root = sum( - 1 for h in self.history - if h.root_pc == target.root_pc - ) - diversity = self.root_diversity ** recent_same_root - - return (1.0 + boost) * nir_score * diversity - - def _record_transition_source (self, chord: subsequence.chords.Chord) -> None: - - """History bookkeeping for one transition: the outgoing chord enters history. - - The first half of :meth:`step` — exposed so a constrained walk can - interleave it with its own draws (``before_choice``) and the NIR - weighting sees exactly the context it would live. - """ - - self.history.append(chord) - if len(self.history) > 4: - self.history.pop(0) - - def step (self) -> subsequence.chords.Chord: - - """Advance to the next chord based on the transition graph.""" - - # Update history before choosing next (so structure tracks the path) - self._record_transition_source(self.current_chord) - - # Decision path: chord changes occur here; key changes are not automatic. - self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight) + # Apply NIR gravity + nir_score = self._calculate_nir_score(source, target) - return self.current_chord + # Root diversity: penalise transitions to a root heard recently. + recent_same_root = sum(1 for h in self.history if h.root_pc == target.root_pc) + diversity = self.root_diversity**recent_same_root - def plan_next (self) -> subsequence.chords.Chord: + return (1.0 + boost) * nir_score * diversity - """Choose the next chord without committing it — the horizon's pre-step. + def _record_transition_source(self, chord: subsequence.chords.Chord) -> None: + """History bookkeeping for one transition: the outgoing chord enters history. - Draws from the RNG exactly as :meth:`step` would (the draw IS the - pre-commitment), but leaves ``current_chord`` and ``history`` - untouched. Pair with :meth:`commit_chord` when the planned chord - becomes the sounding one; ``commit_chord(plan_next())`` is draw-for- - draw equivalent to ``step()``. - """ + The first half of :meth:`step` — exposed so a constrained walk can + interleave it with its own draws (``before_choice``) and the NIR + weighting sees exactly the context it would live. + """ - saved_history = list(self.history) + self.history.append(chord) + if len(self.history) > 4: + self.history.pop(0) - self._record_transition_source(self.current_chord) + def step(self) -> subsequence.chords.Chord: + """Advance to the next chord based on the transition graph.""" - try: - return self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight) - finally: - self.history = saved_history + # Update history before choosing next (so structure tracks the path) + self._record_transition_source(self.current_chord) - def commit_chord (self, chord: subsequence.chords.Chord) -> subsequence.chords.Chord: + # Decision path: chord changes occur here; key changes are not automatic. + self.current_chord = self.graph.choose_next( + self.current_chord, self.rng, weight_modifier=self._transition_weight + ) - """Make *chord* current with step()'s history bookkeeping, no RNG draw. + return self.current_chord - Used by the harmonic clock to commit a planned chord or replay a - frozen progression span while keeping NIR context coherent (the - outgoing chord enters history as the transition source, exactly as - ``step()`` records it). - """ + def plan_next(self) -> subsequence.chords.Chord: + """Choose the next chord without committing it — the horizon's pre-step. - self._record_transition_source(self.current_chord) + Draws from the RNG exactly as :meth:`step` would (the draw IS the + pre-commitment), but leaves ``current_chord`` and ``history`` + untouched. Pair with :meth:`commit_chord` when the planned chord + becomes the sounding one; ``commit_chord(plan_next())`` is draw-for- + draw equivalent to ``step()``. + """ - self.current_chord = chord + saved_history = list(self.history) - return self.current_chord + self._record_transition_source(self.current_chord) + try: + return self.graph.choose_next( + self.current_chord, self.rng, weight_modifier=self._transition_weight + ) + finally: + self.history = saved_history - def get_current_chord (self) -> subsequence.chords.Chord: + def commit_chord(self, chord: subsequence.chords.Chord) -> subsequence.chords.Chord: + """Make *chord* current with step()'s history bookkeeping, no RNG draw. - """Return the current chord.""" + Used by the harmonic clock to commit a planned chord or replay a + frozen progression span while keeping NIR context coherent (the + outgoing chord enters history as the transition source, exactly as + ``step()`` records it). + """ - return self.current_chord + self._record_transition_source(self.current_chord) + self.current_chord = chord - def get_key_name (self) -> str: + return self.current_chord - """Return the current key name.""" + def get_current_chord(self) -> subsequence.chords.Chord: + """Return the current chord.""" - return self.key_name + return self.current_chord + def get_key_name(self) -> str: + """Return the current key name.""" - def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int: + return self.key_name - """Calculate the MIDI root for a chord relative to the key root.""" + def get_chord_root_midi( + self, base_midi: int, chord: subsequence.chords.Chord + ) -> int: + """Calculate the MIDI root for a chord relative to the key root.""" - offset = (chord.root_pc - self.key_root_pc) % 12 + offset = (chord.root_pc - self.key_root_pc) % 12 - return base_midi + offset + return base_midi + offset diff --git a/subsequence/harmony.py b/subsequence/harmony.py index 10a15f4..7991a2a 100644 --- a/subsequence/harmony.py +++ b/subsequence/harmony.py @@ -11,187 +11,181 @@ import subsequence.intervals -def diatonic_chords (key: str, mode: str = "ionian") -> typing.List[subsequence.chords.Chord]: - - """Return the diatonic triads for a key and mode. - - This is a convenience function for generating chord sequences without - using the chord graph engine. The returned ``Chord`` objects can be - passed directly to ``p.chord()`` or ``chord.tones()`` inside a pattern. - - Parameters: - key: Note name for the key (e.g., ``"C"``, ``"Eb"``, ``"F#"``). - mode: A mode with chord qualities defined (e.g. ``"ionian"``, - ``"dorian"``, ``"minor"``). Scales without chord qualities - (e.g. ``"hirajoshi"``) will raise ``ValueError`` — use - ``p.snap_to_scale()`` for pitch snapping instead. - - Returns: - List of ``Chord`` objects, one per scale degree. - - Example: - ```python - # All 7 chords in Eb Major - chords = subsequence.harmony.diatonic_chords("Eb") - - # Natural minor chords in A - chords = subsequence.harmony.diatonic_chords("A", mode="minor") - - # Dorian chords in D - chords = subsequence.harmony.diatonic_chords("D", mode="dorian") - ``` - """ - - if mode not in subsequence.intervals.SCALE_MODE_MAP: - available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) - raise ValueError(f"Unknown mode: {mode!r}. Available: {available}") - - _, qualities = subsequence.intervals.SCALE_MODE_MAP[mode] - - if qualities is None: - raise ValueError( - f"Mode {mode!r} has no chord qualities defined. " - "Use register_scale(..., qualities=[...]) to add them, " - "or use p.snap_to_scale() for pitch snapping without harmony." - ) - key_pc = subsequence.chords.key_name_to_pc(key) - scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) - - return [ - subsequence.chords.Chord(root_pc=root_pc, quality=quality) - for root_pc, quality in zip(scale_pcs, qualities) - ] - - -def diatonic_chord ( - key: str, - mode: str = "ionian", - degree: int = 0, +def diatonic_chords( + key: str, mode: str = "ionian" +) -> typing.List[subsequence.chords.Chord]: + """Return the diatonic triads for a key and mode. + + This is a convenience function for generating chord sequences without + using the chord graph engine. The returned ``Chord`` objects can be + passed directly to ``p.chord()`` or ``chord.tones()`` inside a pattern. + + Parameters: + key: Note name for the key (e.g., ``"C"``, ``"Eb"``, ``"F#"``). + mode: A mode with chord qualities defined (e.g. ``"ionian"``, + ``"dorian"``, ``"minor"``). Scales without chord qualities + (e.g. ``"hirajoshi"``) will raise ``ValueError`` — use + ``p.snap_to_scale()`` for pitch snapping instead. + + Returns: + List of ``Chord`` objects, one per scale degree. + + Example: + ```python + # All 7 chords in Eb Major + chords = subsequence.harmony.diatonic_chords("Eb") + + # Natural minor chords in A + chords = subsequence.harmony.diatonic_chords("A", mode="minor") + + # Dorian chords in D + chords = subsequence.harmony.diatonic_chords("D", mode="dorian") + ``` + """ + + if mode not in subsequence.intervals.SCALE_MODE_MAP: + available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) + raise ValueError(f"Unknown mode: {mode!r}. Available: {available}") + + _, qualities = subsequence.intervals.SCALE_MODE_MAP[mode] + + if qualities is None: + raise ValueError( + f"Mode {mode!r} has no chord qualities defined. " + "Use register_scale(..., qualities=[...]) to add them, " + "or use p.snap_to_scale() for pitch snapping without harmony." + ) + key_pc = subsequence.chords.key_name_to_pc(key) + scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) + + return [ + subsequence.chords.Chord(root_pc=root_pc, quality=quality) + for root_pc, quality in zip(scale_pcs, qualities) + ] + + +def diatonic_chord( + key: str, + mode: str = "ionian", + degree: int = 0, ) -> subsequence.chords.Chord: + """Return a single diatonic chord by scale degree. - """Return a single diatonic chord by scale degree. + Convenience wrapper around :func:`diatonic_chords` for the common + case where only one chord is needed. - Convenience wrapper around :func:`diatonic_chords` for the common - case where only one chord is needed. + Parameters: + key: Root note name (e.g. ``"E"``, ``"Bb"``). + mode: Scale mode (default ``"ionian"``). + degree: Zero-indexed scale degree (0 = I/tonic, 4 = V/dominant, etc.). - Parameters: - key: Root note name (e.g. ``"E"``, ``"Bb"``). - mode: Scale mode (default ``"ionian"``). - degree: Zero-indexed scale degree (0 = I/tonic, 4 = V/dominant, etc.). + Raises: + ValueError: If *degree* is out of range for the mode. - Raises: - ValueError: If *degree* is out of range for the mode. + Example: + ```python + tonic = diatonic_chord("E", "phrygian") # I + dominant = diatonic_chord("E", "phrygian", degree=4) # V + ``` + """ - Example: - ```python - tonic = diatonic_chord("E", "phrygian") # I - dominant = diatonic_chord("E", "phrygian", degree=4) # V - ``` - """ + chords = diatonic_chords(key, mode) - chords = diatonic_chords(key, mode) + if degree < 0 or degree >= len(chords): + raise ValueError( + f"degree {degree} out of range for {mode} (0\u2013{len(chords) - 1})" + ) - if degree < 0 or degree >= len(chords): - raise ValueError( - f"degree {degree} out of range for {mode} (0\u2013{len(chords) - 1})" - ) + return chords[degree] - return chords[degree] - -def diatonic_chord_sequence ( - key: str, - root_midi: int, - count: int, - mode: str = "ionian" +def diatonic_chord_sequence( + key: str, root_midi: int, count: int, mode: str = "ionian" ) -> typing.List[typing.Tuple[subsequence.chords.Chord, int]]: - - """Return a list of ``(Chord, midi_root)`` tuples stepping diatonically upward. - - Useful for mapping a continuous value (like altitude or brightness) to a - chord, or for building explicit rising/falling progressions without using - the chord graph engine. - - The returned list has ``count`` entries. Each entry contains the ``Chord`` - object (quality and pitch class) and the exact MIDI note number to use as - that chord's root. Pass both directly to ``p.chord(chord, root=midi_root)``. - - Counts larger than the number of scale degrees wrap into higher octaves - automatically. The sequence always steps upward — reverse the list for - a falling sequence. - - Parameters: - key: Note name for the key (e.g., ``"D"``, ``"Eb"``, ``"F#"``). - root_midi: MIDI note number for the first chord's root. Must fall on a - scale degree of the chosen key and mode. - count: Number of ``(Chord, midi_root)`` pairs to generate. - mode: One of ``"ionian"`` (or ``"major"``), ``"dorian"``, - ``"phrygian"``, ``"lydian"``, ``"mixolydian"``, - ``"aeolian"`` (or ``"minor"``), ``"locrian"``, - ``"harmonic_minor"``, ``"melodic_minor"``. - - Returns: - List of ``(Chord, int)`` tuples, one per step. - - Raises: - ValueError: If ``key`` or ``mode`` is not recognised, or if - ``root_midi`` does not fall on a scale degree of the key. - - Example: - ```python - # 7-step D Major ladder starting at D3 (MIDI 50) - sequence = subsequence.harmony.diatonic_chord_sequence("D", root_midi=50, count=7) - - # Map a 0-1 value to a chord (e.g. from ISS altitude) - chord, root = sequence[int(ratio * (len(sequence) - 1))] - p.chord(chord, root=root, sustain=True) - - # Falling sequence - for chord, root in reversed(subsequence.harmony.diatonic_chord_sequence("A", 57, 7, "minor")): - ... - ``` - """ - - # Validate mode before looking up the scale key. diatonic_chords() also - # validates internally, but diatonic_chord_sequence() is called directly - # from user code so we give a clear error here without going deeper. - if mode not in subsequence.intervals.SCALE_MODE_MAP: - available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) - raise ValueError(f"Unknown mode: {mode!r}. Available: {available}") - - scale_key, _ = subsequence.intervals.SCALE_MODE_MAP[mode] - scale_ivs = subsequence.intervals.get_intervals(scale_key) - - key_pc = subsequence.chords.key_name_to_pc(key) - start_pc = root_midi % 12 - - # Locate the scale degree that matches the starting MIDI note. - start_degree: typing.Optional[int] = None - - for i, iv in enumerate(scale_ivs): - if (key_pc + iv) % 12 == start_pc: - start_degree = i - break - - if start_degree is None: - raise ValueError( - f"MIDI note {root_midi} (pitch class {start_pc}) is not a scale " - f"degree of {key!r} {mode!r}." - ) - - all_chords = diatonic_chords(key, mode=mode) - result: typing.List[typing.Tuple[subsequence.chords.Chord, int]] = [] - - num_degrees = len(scale_ivs) - - for i in range(count): - degree = (start_degree + i) % num_degrees - octave_bump = (start_degree + i) // num_degrees - midi_root = ( - root_midi - + (scale_ivs[degree] - scale_ivs[start_degree]) - + 12 * octave_bump - ) - result.append((all_chords[degree], midi_root)) - - return result + """Return a list of ``(Chord, midi_root)`` tuples stepping diatonically upward. + + Useful for mapping a continuous value (like altitude or brightness) to a + chord, or for building explicit rising/falling progressions without using + the chord graph engine. + + The returned list has ``count`` entries. Each entry contains the ``Chord`` + object (quality and pitch class) and the exact MIDI note number to use as + that chord's root. Pass both directly to ``p.chord(chord, root=midi_root)``. + + Counts larger than the number of scale degrees wrap into higher octaves + automatically. The sequence always steps upward — reverse the list for + a falling sequence. + + Parameters: + key: Note name for the key (e.g., ``"D"``, ``"Eb"``, ``"F#"``). + root_midi: MIDI note number for the first chord's root. Must fall on a + scale degree of the chosen key and mode. + count: Number of ``(Chord, midi_root)`` pairs to generate. + mode: One of ``"ionian"`` (or ``"major"``), ``"dorian"``, + ``"phrygian"``, ``"lydian"``, ``"mixolydian"``, + ``"aeolian"`` (or ``"minor"``), ``"locrian"``, + ``"harmonic_minor"``, ``"melodic_minor"``. + + Returns: + List of ``(Chord, int)`` tuples, one per step. + + Raises: + ValueError: If ``key`` or ``mode`` is not recognised, or if + ``root_midi`` does not fall on a scale degree of the key. + + Example: + ```python + # 7-step D Major ladder starting at D3 (MIDI 50) + sequence = subsequence.harmony.diatonic_chord_sequence("D", root_midi=50, count=7) + + # Map a 0-1 value to a chord (e.g. from ISS altitude) + chord, root = sequence[int(ratio * (len(sequence) - 1))] + p.chord(chord, root=root, sustain=True) + + # Falling sequence + for chord, root in reversed(subsequence.harmony.diatonic_chord_sequence("A", 57, 7, "minor")): + ... + ``` + """ + + # Validate mode before looking up the scale key. diatonic_chords() also + # validates internally, but diatonic_chord_sequence() is called directly + # from user code so we give a clear error here without going deeper. + if mode not in subsequence.intervals.SCALE_MODE_MAP: + available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) + raise ValueError(f"Unknown mode: {mode!r}. Available: {available}") + + scale_key, _ = subsequence.intervals.SCALE_MODE_MAP[mode] + scale_ivs = subsequence.intervals.get_intervals(scale_key) + + key_pc = subsequence.chords.key_name_to_pc(key) + start_pc = root_midi % 12 + + # Locate the scale degree that matches the starting MIDI note. + start_degree: typing.Optional[int] = None + + for i, iv in enumerate(scale_ivs): + if (key_pc + iv) % 12 == start_pc: + start_degree = i + break + + if start_degree is None: + raise ValueError( + f"MIDI note {root_midi} (pitch class {start_pc}) is not a scale " + f"degree of {key!r} {mode!r}." + ) + + all_chords = diatonic_chords(key, mode=mode) + result: typing.List[typing.Tuple[subsequence.chords.Chord, int]] = [] + + num_degrees = len(scale_ivs) + + for i in range(count): + degree = (start_degree + i) % num_degrees + octave_bump = (start_degree + i) // num_degrees + midi_root = ( + root_midi + (scale_ivs[degree] - scale_ivs[start_degree]) + 12 * octave_bump + ) + result.append((all_chords[degree], midi_root)) + + return result diff --git a/subsequence/held_notes.py b/subsequence/held_notes.py index 7b7366a..747eb36 100644 --- a/subsequence/held_notes.py +++ b/subsequence/held_notes.py @@ -26,66 +26,63 @@ class HeldNotes: - - """The live set of notes held on a ``note_input`` keyboard.""" - - def __init__ (self, release_ms: float = 0.0, latch: bool = False) -> None: - - """Create a held-note tracker. - - Parameters: - release_ms: How long (milliseconds) a released note keeps counting - as held, smoothing the gap during hand-position changes. 0 - removes a note the instant its note-off arrives. Ignored when - ``latch`` is True. - latch: When True, the held set persists after release until the - next chord is started. - """ - - self._release_s: float = max(0.0, release_ms) / 1000.0 - self._latch: bool = latch - # pitch -> velocity for notes physically down right now. - self._on: typing.Dict[int, int] = {} - # pitch -> perf_counter deadline for just-released notes (debounce window). - self._releasing: typing.Dict[int, float] = {} - # pitch -> velocity for the latched chord (latch mode only). - self._latched: typing.Dict[int, int] = {} - - def note_on (self, pitch: int, velocity: int, now: float) -> None: - - """Register a note-on (``now`` = a perf_counter timestamp).""" - - self._releasing.pop(pitch, None) - if self._latch and not self._on: - # First key after every key was up — start a fresh latched chord. - self._latched.clear() - self._on[pitch] = velocity - - def note_off (self, pitch: int, now: float) -> None: - - """Register a note-off (``now`` = a perf_counter timestamp).""" - - velocity = self._on.pop(pitch, None) - if velocity is None: - return - if self._latch: - self._latched[pitch] = velocity - elif self._release_s > 0.0: - self._releasing[pitch] = now + self._release_s - - def snapshot (self, now: float) -> typing.List[int]: - - """Return the currently held MIDI notes, sorted ascending. - - Combines notes physically down, any still within the ``release_ms`` - debounce window at ``now``, and the latched chord. Expired - release-window notes are pruned as a side effect. - """ - - held: typing.Set[int] = set(self._on) - held.update(self._latched) - if self._releasing: - for pitch in [p for p, deadline in self._releasing.items() if deadline <= now]: - del self._releasing[pitch] - held.update(self._releasing) - return sorted(held) + """The live set of notes held on a ``note_input`` keyboard.""" + + def __init__(self, release_ms: float = 0.0, latch: bool = False) -> None: + """Create a held-note tracker. + + Parameters: + release_ms: How long (milliseconds) a released note keeps counting + as held, smoothing the gap during hand-position changes. 0 + removes a note the instant its note-off arrives. Ignored when + ``latch`` is True. + latch: When True, the held set persists after release until the + next chord is started. + """ + + self._release_s: float = max(0.0, release_ms) / 1000.0 + self._latch: bool = latch + # pitch -> velocity for notes physically down right now. + self._on: typing.Dict[int, int] = {} + # pitch -> perf_counter deadline for just-released notes (debounce window). + self._releasing: typing.Dict[int, float] = {} + # pitch -> velocity for the latched chord (latch mode only). + self._latched: typing.Dict[int, int] = {} + + def note_on(self, pitch: int, velocity: int, now: float) -> None: + """Register a note-on (``now`` = a perf_counter timestamp).""" + + self._releasing.pop(pitch, None) + if self._latch and not self._on: + # First key after every key was up — start a fresh latched chord. + self._latched.clear() + self._on[pitch] = velocity + + def note_off(self, pitch: int, now: float) -> None: + """Register a note-off (``now`` = a perf_counter timestamp).""" + + velocity = self._on.pop(pitch, None) + if velocity is None: + return + if self._latch: + self._latched[pitch] = velocity + elif self._release_s > 0.0: + self._releasing[pitch] = now + self._release_s + + def snapshot(self, now: float) -> typing.List[int]: + """Return the currently held MIDI notes, sorted ascending. + + Combines notes physically down, any still within the ``release_ms`` + debounce window at ``now``, and the latched chord. Expired + release-window notes are pruned as a side effect. + """ + + held: typing.Set[int] = set(self._on) + held.update(self._latched) + if self._releasing: + for pitch in [ + p for p, deadline in self._releasing.items() if deadline <= now + ]: + del self._releasing[pitch] + held.update(self._releasing) + return sorted(held) diff --git a/subsequence/helpers/network.py b/subsequence/helpers/network.py index 29d2847..a976ca7 100644 --- a/subsequence/helpers/network.py +++ b/subsequence/helpers/network.py @@ -8,51 +8,49 @@ import typing -def get_local_ip () -> str: +def get_local_ip() -> str: + """Discover the primary local IP address of the machine. - """Discover the primary local IP address of the machine. + Uses a non-connecting UDP socket trick to find the IP of the default + outbound interface currently used for external routing. - Uses a non-connecting UDP socket trick to find the IP of the default - outbound interface currently used for external routing. + Returns: + The local IP address as a string, or "127.0.0.1" if discovery fails. + """ - Returns: - The local IP address as a string, or "127.0.0.1" if discovery fails. - """ + local_ip = "127.0.0.1" + try: + # Connecting a UDP socket to an external address (no data is actually + # sent) reveals which local IP the OS would use for that route. + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + probe.connect(("8.8.8.8", 80)) + local_ip = probe.getsockname()[0] + finally: + probe.close() + except Exception: + pass - local_ip = "127.0.0.1" - try: - # Connecting a UDP socket to an external address (no data is actually - # sent) reveals which local IP the OS would use for that route. - probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - probe.connect(("8.8.8.8", 80)) - local_ip = probe.getsockname()[0] - finally: - probe.close() - except Exception: - pass + return local_ip - return local_ip +def get_local_broadcasts() -> typing.List[str]: + """Return subnet broadcast addresses inferred from local interface IPs. -def get_local_broadcasts () -> typing.List[str]: + Computes the /24 broadcast address based on the primary local IP route. - """Return subnet broadcast addresses inferred from local interface IPs. + Returns: + A list containing the computed broadcast address, or an empty list + if the primary interface is a loopback or link-local address. + """ - Computes the /24 broadcast address based on the primary local IP route. + broadcasts: typing.List[str] = [] + local_ip = get_local_ip() - Returns: - A list containing the computed broadcast address, or an empty list - if the primary interface is a loopback or link-local address. - """ + if not local_ip.startswith("127.") and not local_ip.startswith("169.254."): + parts = local_ip.split(".") + if len(parts) == 4: + parts[3] = "255" + broadcasts.append(".".join(parts)) - broadcasts: typing.List[str] = [] - local_ip = get_local_ip() - - if not local_ip.startswith("127.") and not local_ip.startswith("169.254."): - parts = local_ip.split(".") - if len(parts) == 4: - parts[3] = "255" - broadcasts.append(".".join(parts)) - - return broadcasts + return broadcasts diff --git a/subsequence/helpers/wing.py b/subsequence/helpers/wing.py index 5dd07b5..bd0230f 100644 --- a/subsequence/helpers/wing.py +++ b/subsequence/helpers/wing.py @@ -94,337 +94,345 @@ def mixer (p): # ── Internal helpers ────────────────────────────────────────────────────────── -def _build_osc (address: str) -> bytes: - """Build a no-argument OSC message for *address*.""" - return pythonosc.osc_message_builder.OscMessageBuilder(address=address).build().dgram +def _build_osc(address: str) -> bytes: + """Build a no-argument OSC message for *address*.""" + return ( + pythonosc.osc_message_builder.OscMessageBuilder(address=address).build().dgram + ) -def _parse_osc (data: bytes) -> typing.Optional[pythonosc.osc_message.OscMessage]: - """Parse raw bytes into an OscMessage, returning None on failure.""" - try: - return pythonosc.osc_message.OscMessage(data) - except Exception: - return None +def _parse_osc(data: bytes) -> typing.Optional[pythonosc.osc_message.OscMessage]: + """Parse raw bytes into an OscMessage, returning None on failure.""" + try: + return pythonosc.osc_message.OscMessage(data) + except Exception: + return None -def _classify (params: typing.List[typing.Any]) -> str: - """Return 'node' or 'leaf' based on the response param types. +def _classify(params: typing.List[typing.Any]) -> str: + """Return 'node' or 'leaf' based on the response param types. - - Any float or int in the params → numeric leaf. - - All strings, more than one → node (directory listing of children). - - Single string → string leaf. - """ - if not params: - return "leaf" - if any(isinstance(p, (float, int)) and not isinstance(p, bool) for p in params): - return "leaf" - if len(params) > 1: - return "node" - return "leaf" + - Any float or int in the params → numeric leaf. + - All strings, more than one → node (directory listing of children). + - Single string → string leaf. + """ + if not params: + return "leaf" + if any(isinstance(p, (float, int)) and not isinstance(p, bool) for p in params): + return "leaf" + if len(params) > 1: + return "node" + return "leaf" # ── Public API ──────────────────────────────────────────────────────────────── -def discover ( - port: int = WING_PORT, - timeout: float = 2.0, +def discover( + port: int = WING_PORT, + timeout: float = 2.0, ) -> typing.Optional[typing.Dict[str, str]]: - """Auto-discover a Behringer WING on the local network. - - Sends an OSC ``/?`` broadcast to UDP port *port* and waits for a reply. - The WING responds with a comma-separated info string containing its IP, - model, and firmware version. - - Parameters: - port: UDP port to broadcast on (default 2223 — the WING's OSC port). - timeout: Seconds to wait for a reply. - - Returns: - A ``dict`` with keys ``ip``, ``device``, ``model``, ``form_factor``, - ``firmware``, or ``None`` if no device replied. - - Example:: - - device = wing.discover() - if device: - print(device["ip"]) # "192.168.0.116" - print(device["firmware"]) # "3.1-0-g9f314617:release" - """ - dgram = _build_osc("/?") - - # Build list of broadcast addresses to try: global first, then subnet-specific - broadcasts = list(_BROADCAST_ADDRS) + subsequence.helpers.network.get_local_broadcasts() - # Deduplicate while preserving order - seen: typing.Set[str] = set() - unique_broadcasts: typing.List[str] = [] - for b in broadcasts: - if b not in seen: - seen.add(b) - unique_broadcasts.append(b) - - for broadcast in unique_broadcasts: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.settimeout(timeout / len(unique_broadcasts)) - sock.sendto(dgram, (broadcast, port)) - try: - data, addr = sock.recvfrom(4096) - except socket.timeout: - continue - except OSError as e: - # The docstring promises None when nothing replies — a machine - # with no route for this broadcast (offline laptop, ENETUNREACH) - # is "nothing replied", not a crash. Try the next address. - logger.debug(f"WING discovery broadcast to {broadcast} failed: {e}") - continue - finally: - sock.close() - - msg = _parse_osc(data) - if msg is None: - continue - - params = list(msg.params) - if not params or not isinstance(params[0], str): - continue - - # Response: "WING,192.168.0.116,WING-PP-20021049,wing-rack,ID,firmware" - fields = params[0].split(",") - result: typing.Dict[str, str] = { - "ip": addr[0], - "device": fields[0] if len(fields) > 0 else "", - "model": fields[2] if len(fields) > 2 else "", - "form_factor": fields[3] if len(fields) > 3 else "", - "firmware": fields[5] if len(fields) > 5 else "", - } - return result - - return None - - -def query ( - host: str, - address: str, - port: int = WING_PORT, - timeout: float = 2.0, + """Auto-discover a Behringer WING on the local network. + + Sends an OSC ``/?`` broadcast to UDP port *port* and waits for a reply. + The WING responds with a comma-separated info string containing its IP, + model, and firmware version. + + Parameters: + port: UDP port to broadcast on (default 2223 — the WING's OSC port). + timeout: Seconds to wait for a reply. + + Returns: + A ``dict`` with keys ``ip``, ``device``, ``model``, ``form_factor``, + ``firmware``, or ``None`` if no device replied. + + Example:: + + device = wing.discover() + if device: + print(device["ip"]) # "192.168.0.116" + print(device["firmware"]) # "3.1-0-g9f314617:release" + """ + dgram = _build_osc("/?") + + # Build list of broadcast addresses to try: global first, then subnet-specific + broadcasts = ( + list(_BROADCAST_ADDRS) + subsequence.helpers.network.get_local_broadcasts() + ) + # Deduplicate while preserving order + seen: typing.Set[str] = set() + unique_broadcasts: typing.List[str] = [] + for b in broadcasts: + if b not in seen: + seen.add(b) + unique_broadcasts.append(b) + + for broadcast in unique_broadcasts: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(timeout / len(unique_broadcasts)) + sock.sendto(dgram, (broadcast, port)) + try: + data, addr = sock.recvfrom(4096) + except socket.timeout: + continue + except OSError as e: + # The docstring promises None when nothing replies — a machine + # with no route for this broadcast (offline laptop, ENETUNREACH) + # is "nothing replied", not a crash. Try the next address. + logger.debug(f"WING discovery broadcast to {broadcast} failed: {e}") + continue + finally: + sock.close() + + msg = _parse_osc(data) + if msg is None: + continue + + params = list(msg.params) + if not params or not isinstance(params[0], str): + continue + + # Response: "WING,192.168.0.116,WING-PP-20021049,wing-rack,ID,firmware" + fields = params[0].split(",") + result: typing.Dict[str, str] = { + "ip": addr[0], + "device": fields[0] if len(fields) > 0 else "", + "model": fields[2] if len(fields) > 2 else "", + "form_factor": fields[3] if len(fields) > 3 else "", + "firmware": fields[5] if len(fields) > 5 else "", + } + return result + + return None + + +def query( + host: str, + address: str, + port: int = WING_PORT, + timeout: float = 2.0, ) -> typing.Optional[typing.Dict[str, typing.Any]]: - """Query a node or leaf address on the WING and return a structured dict. - - Sends an argument-less OSC message to *address*. The WING responds with - either a list of child node names (for a node address) or the current value - of a parameter (for a leaf address). - - Parameters: - host: WING IP address (e.g. ``"192.168.0.116"``). - address: OSC address to query (e.g. ``"/ch/1"`` or ``"/ch/1/fdr"``). - port: UDP port (default 2223). - timeout: Seconds to wait for a reply. - - Returns: - A ``dict`` with keys: - - - ``address`` (str): The OSC address of the response. - - ``type`` (str): ``"node"`` or ``"leaf"``. - - For **nodes**: ``children`` (list[str]) — child node names. - - For **string leaves**: ``value`` (str). - - For **numeric leaves**: ``value`` (str), ``value_f`` (float|None), - ``value_i`` (int|None). - - Returns ``None`` on timeout. - - Example:: - - result = wing.query(ip, "/ch/1/fdr") - # {'address': '/ch/1/fdr', 'type': 'leaf', 'value': '0.0', - # 'value_f': 0.75, 'value_i': 0} - - result = wing.query(ip, "/ch/1") - # {'address': '/ch/1', 'type': 'node', - # 'children': ['in', 'flt', 'clink', 'col', 'name', 'fdr', ...]} - """ - dgram = _build_osc(address) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - sock.settimeout(timeout) - sock.sendto(dgram, (host, port)) - try: - data, _ = sock.recvfrom(65535) - except socket.timeout: - return None - finally: - sock.close() - - msg = _parse_osc(data) - if msg is None: - return None - - params = list(msg.params) - kind = _classify(params) - - result: typing.Dict[str, typing.Any] = { - "address": msg.address, - "type": kind, - } - - if kind == "node": - result["children"] = [str(p) for p in params] - else: - # Leaf — extract typed values - result["value"] = str(params[0]) if params else "" - floats = [p for p in params if isinstance(p, float)] - ints = [p for p in params if isinstance(p, int) and not isinstance(p, bool)] - result["value_f"] = floats[0] if floats else None - result["value_i"] = ints[0] if ints else None - - return result - - -def walk ( - host: str, - address: str = "/", - port: int = WING_PORT, - timeout: float = 2.0, - max_depth: int = 3, - _depth: int = 0, + """Query a node or leaf address on the WING and return a structured dict. + + Sends an argument-less OSC message to *address*. The WING responds with + either a list of child node names (for a node address) or the current value + of a parameter (for a leaf address). + + Parameters: + host: WING IP address (e.g. ``"192.168.0.116"``). + address: OSC address to query (e.g. ``"/ch/1"`` or ``"/ch/1/fdr"``). + port: UDP port (default 2223). + timeout: Seconds to wait for a reply. + + Returns: + A ``dict`` with keys: + + - ``address`` (str): The OSC address of the response. + - ``type`` (str): ``"node"`` or ``"leaf"``. + - For **nodes**: ``children`` (list[str]) — child node names. + - For **string leaves**: ``value`` (str). + - For **numeric leaves**: ``value`` (str), ``value_f`` (float|None), + ``value_i`` (int|None). + + Returns ``None`` on timeout. + + Example:: + + result = wing.query(ip, "/ch/1/fdr") + # {'address': '/ch/1/fdr', 'type': 'leaf', 'value': '0.0', + # 'value_f': 0.75, 'value_i': 0} + + result = wing.query(ip, "/ch/1") + # {'address': '/ch/1', 'type': 'node', + # 'children': ['in', 'flt', 'clink', 'col', 'name', 'fdr', ...]} + """ + dgram = _build_osc(address) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.settimeout(timeout) + sock.sendto(dgram, (host, port)) + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + return None + finally: + sock.close() + + msg = _parse_osc(data) + if msg is None: + return None + + params = list(msg.params) + kind = _classify(params) + + result: typing.Dict[str, typing.Any] = { + "address": msg.address, + "type": kind, + } + + if kind == "node": + result["children"] = [str(p) for p in params] + else: + # Leaf — extract typed values + result["value"] = str(params[0]) if params else "" + floats = [p for p in params if isinstance(p, float)] + ints = [p for p in params if isinstance(p, int) and not isinstance(p, bool)] + result["value_f"] = floats[0] if floats else None + result["value_i"] = ints[0] if ints else None + + return result + + +def walk( + host: str, + address: str = "/", + port: int = WING_PORT, + timeout: float = 2.0, + max_depth: int = 3, + _depth: int = 0, ) -> typing.Optional[typing.Dict[str, typing.Any]]: - """Recursively walk the WING's node tree from *address*. - - Returns a nested dict representing the subtree. Stops at *max_depth* - levels to avoid issuing hundreds of queries against a large mixer tree. - - Parameters: - host: WING IP address. - address: Starting node address (default ``"/"`` — the root). - port: UDP port (default 2223). - timeout: Per-query timeout. - max_depth: Maximum recursion depth (default 3). - - Returns: - Nested ``dict`` — same structure as :func:`query` but with an extra - ``"children"`` key on nodes mapping child names to their own subtrees. - ``None`` on timeout. - - Example:: - - tree = wing.walk(ip, "/ch/1", max_depth=1) - for name, child in tree["children"].items(): - print(name, child) - """ - result = query(host, address, port=port, timeout=timeout) - if result is None: - return None - - if result["type"] == "node" and _depth < max_depth: - children_map: typing.Dict[str, typing.Any] = {} - for child_name in result["children"]: - child_address = address.rstrip("/") + "/" + child_name - child_result = walk( - host, child_address, port=port, timeout=timeout, - max_depth=max_depth, _depth=_depth + 1 - ) - children_map[child_name] = child_result - result["children"] = children_map - - return result - - -def print_node ( - host: str, - address: str = "/", - port: int = WING_PORT, - timeout: float = 2.0, + """Recursively walk the WING's node tree from *address*. + + Returns a nested dict representing the subtree. Stops at *max_depth* + levels to avoid issuing hundreds of queries against a large mixer tree. + + Parameters: + host: WING IP address. + address: Starting node address (default ``"/"`` — the root). + port: UDP port (default 2223). + timeout: Per-query timeout. + max_depth: Maximum recursion depth (default 3). + + Returns: + Nested ``dict`` — same structure as :func:`query` but with an extra + ``"children"`` key on nodes mapping child names to their own subtrees. + ``None`` on timeout. + + Example:: + + tree = wing.walk(ip, "/ch/1", max_depth=1) + for name, child in tree["children"].items(): + print(name, child) + """ + result = query(host, address, port=port, timeout=timeout) + if result is None: + return None + + if result["type"] == "node" and _depth < max_depth: + children_map: typing.Dict[str, typing.Any] = {} + for child_name in result["children"]: + child_address = address.rstrip("/") + "/" + child_name + child_result = walk( + host, + child_address, + port=port, + timeout=timeout, + max_depth=max_depth, + _depth=_depth + 1, + ) + children_map[child_name] = child_result + result["children"] = children_map + + return result + + +def print_node( + host: str, + address: str = "/", + port: int = WING_PORT, + timeout: float = 2.0, ) -> None: - """Query *address* and print a human-readable summary. + """Query *address* and print a human-readable summary. - For nodes: prints the list of children. - For leaves: prints the current value(s). + For nodes: prints the list of children. + For leaves: prints the current value(s). - Parameters: - host: WING IP address. - address: OSC address to query (default ``"/"``). - port: UDP port (default 2223). - timeout: Seconds to wait for a reply. + Parameters: + host: WING IP address. + address: OSC address to query (default ``"/"``). + port: UDP port (default 2223). + timeout: Seconds to wait for a reply. - Example:: + Example:: - wing.print_node("192.168.0.116", "/ch/1") - wing.print_node("192.168.0.116", "/ch/1/fdr") - """ - result = query(host, address, port=port, timeout=timeout) + wing.print_node("192.168.0.116", "/ch/1") + wing.print_node("192.168.0.116", "/ch/1/fdr") + """ + result = query(host, address, port=port, timeout=timeout) - if result is None: - print(f"{address} (no reply)") - return + if result is None: + print(f"{address} (no reply)") + return - if result["type"] == "node": - children = result["children"] - print(f"{address} [{len(children)} children]") - for child in children: - print(f" {child}") - else: - value_f = result.get("value_f") - value_i = result.get("value_i") - value_str = result.get("value", "") + if result["type"] == "node": + children = result["children"] + print(f"{address} [{len(children)} children]") + for child in children: + print(f" {child}") + else: + value_f = result.get("value_f") + value_i = result.get("value_i") + value_str = result.get("value", "") - parts: typing.List[str] = [repr(value_str)] - if value_f is not None: - parts.append(f"float={value_f}") - if value_i is not None: - parts.append(f"int={value_i}") + parts: typing.List[str] = [repr(value_str)] + if value_f is not None: + parts.append(f"float={value_f}") + if value_i is not None: + parts.append(f"int={value_i}") - print(f"{address} {', '.join(parts)}") + print(f"{address} {', '.join(parts)}") # ── CLI entry point ─────────────────────────────────────────────────────────── -def _main () -> None: - """Command-line interface. - - Usage:: - - # Auto-discover WING on the LAN - python -m subsequence.helpers.wing - - # Query a specific address (requires --host or auto-discovery) - python -m subsequence.helpers.wing /ch/1 - python -m subsequence.helpers.wing /ch/1/fdr - python -m subsequence.helpers.wing --host 192.168.0.116 /ch/1 - """ - args = sys.argv[1:] - - host: typing.Optional[str] = None - address: typing.Optional[str] = None - - i = 0 - while i < len(args): - if args[i] == "--host" and i + 1 < len(args): - host = args[i + 1] - i += 2 - elif args[i].startswith("/"): - address = args[i] - i += 1 - else: - i += 1 - - # Discover if no host given - if host is None: - print("Discovering WING on local network...") - device = discover() - if device is None: - print("No WING found.") - return - host = device["ip"] - print( - f"Found {device['device']} {device['model']} ({device['form_factor']}) " - f"at {host} firmware {device['firmware']}" - ) - - if address is not None: - print_node(host, address) +def _main() -> None: + """Command-line interface. + + Usage:: + + # Auto-discover WING on the LAN + python -m subsequence.helpers.wing + + # Query a specific address (requires --host or auto-discovery) + python -m subsequence.helpers.wing /ch/1 + python -m subsequence.helpers.wing /ch/1/fdr + python -m subsequence.helpers.wing --host 192.168.0.116 /ch/1 + """ + args = sys.argv[1:] + + host: typing.Optional[str] = None + address: typing.Optional[str] = None + + i = 0 + while i < len(args): + if args[i] == "--host" and i + 1 < len(args): + host = args[i + 1] + i += 2 + elif args[i].startswith("/"): + address = args[i] + i += 1 + else: + i += 1 + + # Discover if no host given + if host is None: + print("Discovering WING on local network...") + device = discover() + if device is None: + print("No WING found.") + return + host = device["ip"] + print( + f"Found {device['device']} {device['model']} ({device['form_factor']}) " + f"at {host} firmware {device['firmware']}" + ) + + if address is not None: + print_node(host, address) if __name__ == "__main__": - _main() + _main() diff --git a/subsequence/intervals.py b/subsequence/intervals.py index 764dc38..3c038bc 100644 --- a/subsequence/intervals.py +++ b/subsequence/intervals.py @@ -15,58 +15,58 @@ INTERVAL_DEFINITIONS: typing.Dict[str, typing.List[int]] = { - "augmented": [0, 3, 4, 7, 8, 11], - "augmented_7th": [0, 4, 8, 10], - "augmented_triad": [0, 4, 8], - "blues_scale": [0, 3, 5, 6, 7, 10], - "chromatic": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], - "diminished_7th": [0, 3, 6, 9], - "diminished_triad": [0, 3, 6], - "dominant_7th": [0, 4, 7, 10], - "dominant_9th": [0, 4, 7, 10, 14], - "dorian_mode": [0, 2, 3, 5, 7, 9, 10], - "double_harmonic": [0, 1, 4, 5, 7, 8, 11], - "enigmatic": [0, 1, 4, 6, 8, 10, 11], - "half_diminished_7th": [0, 3, 6, 10], - "harmonic_minor": [0, 2, 3, 5, 7, 8, 11], - "hungarian_minor": [0, 2, 3, 6, 7, 8, 11], - "locrian_mode": [0, 1, 3, 5, 6, 8, 10], - "lydian": [0, 2, 4, 6, 7, 9, 11], - "lydian_dominant": [0, 2, 4, 6, 7, 9, 10], - "major_6th": [0, 4, 7, 9], - "major_7th": [0, 4, 7, 11], - "major_9th": [0, 4, 7, 11, 14], - "major_ionian": [0, 2, 4, 5, 7, 9, 11], - "major_pentatonic": [0, 2, 4, 7, 9], - "major_triad": [0, 4, 7], - "melodic_minor": [0, 2, 3, 5, 7, 9, 11], - "minor_6th": [0, 3, 7, 9], - "minor_7th": [0, 3, 7, 10], - "minor_9th": [0, 3, 7, 10, 14], - "minor_blues": [0, 3, 5, 6, 7, 10], - "minor_major_7th": [0, 3, 7, 11], - "minor_pentatonic": [0, 3, 5, 7, 10], - "minor_triad": [0, 3, 7], - "mixolydian": [0, 2, 4, 5, 7, 9, 10], - "natural_minor": [0, 2, 3, 5, 7, 8, 10], - "neapolitan_major": [0, 1, 3, 5, 7, 9, 11], - "phrygian_dominant": [0, 1, 4, 5, 7, 8, 10], - "phrygian_mode": [0, 1, 3, 5, 7, 8, 10], - "power_chord": [0, 7], - "superlocrian": [0, 1, 3, 4, 6, 8, 10], - "sus2": [0, 2, 7], - "sus4": [0, 5, 7], - "whole_tone": [0, 2, 4, 6, 8, 10], - # -- Non-western / pentatonic scales -- - "hirajoshi": [0, 2, 3, 7, 8], - "in_sen": [0, 1, 5, 7, 10], - "iwato": [0, 1, 5, 6, 10], - "yo": [0, 2, 5, 7, 9], - "egyptian": [0, 2, 5, 7, 10], - "root": [0], - "fifth": [0, 7], - "minor_3rd": [0, 3], - "tritone": [0, 6], + "augmented": [0, 3, 4, 7, 8, 11], + "augmented_7th": [0, 4, 8, 10], + "augmented_triad": [0, 4, 8], + "blues_scale": [0, 3, 5, 6, 7, 10], + "chromatic": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + "diminished_7th": [0, 3, 6, 9], + "diminished_triad": [0, 3, 6], + "dominant_7th": [0, 4, 7, 10], + "dominant_9th": [0, 4, 7, 10, 14], + "dorian_mode": [0, 2, 3, 5, 7, 9, 10], + "double_harmonic": [0, 1, 4, 5, 7, 8, 11], + "enigmatic": [0, 1, 4, 6, 8, 10, 11], + "half_diminished_7th": [0, 3, 6, 10], + "harmonic_minor": [0, 2, 3, 5, 7, 8, 11], + "hungarian_minor": [0, 2, 3, 6, 7, 8, 11], + "locrian_mode": [0, 1, 3, 5, 6, 8, 10], + "lydian": [0, 2, 4, 6, 7, 9, 11], + "lydian_dominant": [0, 2, 4, 6, 7, 9, 10], + "major_6th": [0, 4, 7, 9], + "major_7th": [0, 4, 7, 11], + "major_9th": [0, 4, 7, 11, 14], + "major_ionian": [0, 2, 4, 5, 7, 9, 11], + "major_pentatonic": [0, 2, 4, 7, 9], + "major_triad": [0, 4, 7], + "melodic_minor": [0, 2, 3, 5, 7, 9, 11], + "minor_6th": [0, 3, 7, 9], + "minor_7th": [0, 3, 7, 10], + "minor_9th": [0, 3, 7, 10, 14], + "minor_blues": [0, 3, 5, 6, 7, 10], + "minor_major_7th": [0, 3, 7, 11], + "minor_pentatonic": [0, 3, 5, 7, 10], + "minor_triad": [0, 3, 7], + "mixolydian": [0, 2, 4, 5, 7, 9, 10], + "natural_minor": [0, 2, 3, 5, 7, 8, 10], + "neapolitan_major": [0, 1, 3, 5, 7, 9, 11], + "phrygian_dominant": [0, 1, 4, 5, 7, 8, 10], + "phrygian_mode": [0, 1, 3, 5, 7, 8, 10], + "power_chord": [0, 7], + "superlocrian": [0, 1, 3, 4, 6, 8, 10], + "sus2": [0, 2, 7], + "sus4": [0, 5, 7], + "whole_tone": [0, 2, 4, 6, 8, 10], + # -- Non-western / pentatonic scales -- + "hirajoshi": [0, 2, 3, 7, 8], + "in_sen": [0, 1, 5, 7, 10], + "iwato": [0, 1, 5, 6, 10], + "yo": [0, 2, 5, 7, 9], + "egyptian": [0, 2, 5, 7, 10], + "root": [0], + "fifth": [0, 7], + "minor_3rd": [0, 3], + "tritone": [0, 6], } @@ -81,41 +81,95 @@ # -- Church modes (rotations of the major scale) -- IONIAN_QUALITIES: typing.List[str] = [ - "major", "minor", "minor", "major", "major", "minor", "diminished" + "major", + "minor", + "minor", + "major", + "major", + "minor", + "diminished", ] DORIAN_QUALITIES: typing.List[str] = [ - "minor", "minor", "major", "major", "minor", "diminished", "major" + "minor", + "minor", + "major", + "major", + "minor", + "diminished", + "major", ] PHRYGIAN_QUALITIES: typing.List[str] = [ - "minor", "major", "major", "minor", "diminished", "major", "minor" + "minor", + "major", + "major", + "minor", + "diminished", + "major", + "minor", ] LYDIAN_QUALITIES: typing.List[str] = [ - "major", "major", "minor", "diminished", "major", "minor", "minor" + "major", + "major", + "minor", + "diminished", + "major", + "minor", + "minor", ] MIXOLYDIAN_QUALITIES: typing.List[str] = [ - "major", "minor", "diminished", "major", "minor", "minor", "major" + "major", + "minor", + "diminished", + "major", + "minor", + "minor", + "major", ] AEOLIAN_QUALITIES: typing.List[str] = [ - "minor", "diminished", "major", "minor", "minor", "major", "major" + "minor", + "diminished", + "major", + "minor", + "minor", + "major", + "major", ] LOCRIAN_QUALITIES: typing.List[str] = [ - "diminished", "major", "minor", "minor", "major", "major", "minor" + "diminished", + "major", + "minor", + "minor", + "major", + "major", + "minor", ] # -- Non-modal scales -- HARMONIC_MINOR_QUALITIES: typing.List[str] = [ - "minor", "diminished", "augmented", "minor", "major", "major", "diminished" + "minor", + "diminished", + "augmented", + "minor", + "major", + "major", + "diminished", ] MELODIC_MINOR_QUALITIES: typing.List[str] = [ - "minor", "minor", "augmented", "major", "major", "diminished", "diminished" + "minor", + "minor", + "augmented", + "major", + "major", + "diminished", + "diminished", ] @@ -123,27 +177,29 @@ # qualities is None for scales without predefined chord mappings — these # can still be used with scale_pitch_classes() and p.snap_to_scale(), but not # with diatonic_chords() or composition.harmony(). -SCALE_MODE_MAP: typing.Dict[str, typing.Tuple[str, typing.Optional[typing.List[str]]]] = { - # -- Western diatonic modes (7-note, with chord qualities) -- - "ionian": ("major_ionian", IONIAN_QUALITIES), - "major": ("major_ionian", IONIAN_QUALITIES), - "dorian": ("dorian_mode", DORIAN_QUALITIES), - "phrygian": ("phrygian_mode", PHRYGIAN_QUALITIES), - "lydian": ("lydian", LYDIAN_QUALITIES), - "mixolydian": ("mixolydian", MIXOLYDIAN_QUALITIES), - "aeolian": ("natural_minor", AEOLIAN_QUALITIES), - "minor": ("natural_minor", AEOLIAN_QUALITIES), - "locrian": ("locrian_mode", LOCRIAN_QUALITIES), - "harmonic_minor": ("harmonic_minor", HARMONIC_MINOR_QUALITIES), - "melodic_minor": ("melodic_minor", MELODIC_MINOR_QUALITIES), - # -- Non-western and pentatonic scales (no chord qualities) -- - "hirajoshi": ("hirajoshi", None), - "in_sen": ("in_sen", None), - "iwato": ("iwato", None), - "yo": ("yo", None), - "egyptian": ("egyptian", None), - "major_pentatonic": ("major_pentatonic", None), - "minor_pentatonic": ("minor_pentatonic", None), +SCALE_MODE_MAP: typing.Dict[ + str, typing.Tuple[str, typing.Optional[typing.List[str]]] +] = { + # -- Western diatonic modes (7-note, with chord qualities) -- + "ionian": ("major_ionian", IONIAN_QUALITIES), + "major": ("major_ionian", IONIAN_QUALITIES), + "dorian": ("dorian_mode", DORIAN_QUALITIES), + "phrygian": ("phrygian_mode", PHRYGIAN_QUALITIES), + "lydian": ("lydian", LYDIAN_QUALITIES), + "mixolydian": ("mixolydian", MIXOLYDIAN_QUALITIES), + "aeolian": ("natural_minor", AEOLIAN_QUALITIES), + "minor": ("natural_minor", AEOLIAN_QUALITIES), + "locrian": ("locrian_mode", LOCRIAN_QUALITIES), + "harmonic_minor": ("harmonic_minor", HARMONIC_MINOR_QUALITIES), + "melodic_minor": ("melodic_minor", MELODIC_MINOR_QUALITIES), + # -- Non-western and pentatonic scales (no chord qualities) -- + "hirajoshi": ("hirajoshi", None), + "in_sen": ("in_sen", None), + "iwato": ("iwato", None), + "yo": ("yo", None), + "egyptian": ("egyptian", None), + "major_pentatonic": ("major_pentatonic", None), + "minor_pentatonic": ("minor_pentatonic", None), } # Backwards-compatible alias. @@ -153,284 +209,283 @@ # Snapshot of every built-in scale name, taken at import time. register_scale() # refuses to overwrite these so a custom scale can never silently change what # "minor" or "hirajoshi" means mid-composition. -_BUILTIN_SCALE_NAMES: typing.FrozenSet[str] = frozenset(INTERVAL_DEFINITIONS) | frozenset(SCALE_MODE_MAP) - - -def scale_pitch_classes (key_pc: int, mode: str = "ionian") -> typing.List[int]: - - """ - Return the pitch classes (0–11) that belong to a key and mode. - - Parameters: - key_pc: Root pitch class (0 = C, 1 = C#/Db, …, 11 = B). - mode: Scale mode name. Supports all keys of ``DIATONIC_MODE_MAP`` - (e.g. ``"ionian"``, ``"dorian"``, ``"minor"``, ``"harmonic_minor"``). - - Returns: - Pitch classes in scale-degree order, starting from the root - (length varies by mode). Values wrap mod-12, so the list is - not numerically sorted for non-C roots. - - Example: - ```python - # C major pitch classes - scale_pitch_classes(0, "ionian") # → [0, 2, 4, 5, 7, 9, 11] - - # A minor pitch classes - scale_pitch_classes(9, "aeolian") # → [9, 11, 0, 2, 4, 5, 7] (mod-12) - ``` - """ - - if mode not in SCALE_MODE_MAP: - raise ValueError( - f"Unknown mode '{mode}'. Available: {sorted(SCALE_MODE_MAP)}. " - "Use register_scale() to add custom scales." - ) - - scale_key, _ = SCALE_MODE_MAP[mode] - intervals = get_intervals(scale_key) - return [(key_pc + i) % 12 for i in intervals] - - -def scale_notes ( - key: str, - mode: str = "ionian", - low: int = 60, - high: int = 72, - count: typing.Optional[int] = None, +_BUILTIN_SCALE_NAMES: typing.FrozenSet[str] = frozenset( + INTERVAL_DEFINITIONS +) | frozenset(SCALE_MODE_MAP) + + +def scale_pitch_classes(key_pc: int, mode: str = "ionian") -> typing.List[int]: + """ + Return the pitch classes (0–11) that belong to a key and mode. + + Parameters: + key_pc: Root pitch class (0 = C, 1 = C#/Db, …, 11 = B). + mode: Scale mode name. Supports all keys of ``DIATONIC_MODE_MAP`` + (e.g. ``"ionian"``, ``"dorian"``, ``"minor"``, ``"harmonic_minor"``). + + Returns: + Pitch classes in scale-degree order, starting from the root + (length varies by mode). Values wrap mod-12, so the list is + not numerically sorted for non-C roots. + + Example: + ```python + # C major pitch classes + scale_pitch_classes(0, "ionian") # → [0, 2, 4, 5, 7, 9, 11] + + # A minor pitch classes + scale_pitch_classes(9, "aeolian") # → [9, 11, 0, 2, 4, 5, 7] (mod-12) + ``` + """ + + if mode not in SCALE_MODE_MAP: + raise ValueError( + f"Unknown mode '{mode}'. Available: {sorted(SCALE_MODE_MAP)}. " + "Use register_scale() to add custom scales." + ) + + scale_key, _ = SCALE_MODE_MAP[mode] + intervals = get_intervals(scale_key) + return [(key_pc + i) % 12 for i in intervals] + + +def scale_notes( + key: str, + mode: str = "ionian", + low: int = 60, + high: int = 72, + count: typing.Optional[int] = None, ) -> typing.List[int]: - - """Return MIDI note numbers for a scale within a pitch range. - - Parameters: - key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.). - This acts as a **pitch-class filter only** — it determines which - semitone positions (0–11) are valid members of the scale, but does - not affect which octave notes are drawn from. Notes are selected - starting from ``low`` upward; ``key`` controls *which* notes are - kept, not where the sequence starts. To guarantee the first - returned note is the root, ``low`` must be a MIDI number whose - pitch class matches ``key``. When starting from an arbitrary MIDI - number, derive the key name with - ``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``. - mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP` - (e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``, - ``"major_pentatonic"``). Use :func:`register_scale` for custom scales. - low: Lowest MIDI note (inclusive). When ``count`` is set, this is - the starting note from which the scale ascends. **If ``low`` is - not a member of the scale defined by ``key``, it is silently - skipped** and the first returned note will be the next in-scale - pitch above ``low``. - high: Highest MIDI note (inclusive). Ignored when ``count`` is set. - count: Exact number of notes to return. Notes ascend from ``low`` - through successive scale degrees, cycling into higher octaves - as needed. When ``None`` (default), all scale tones between - ``low`` and ``high`` are returned. - - Returns: - Sorted list of MIDI note numbers. - - Examples: - ```python - import subsequence - import subsequence.constants.midi_notes as notes - - # C major: all tones from middle C to C5 - subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5) - # → [60, 62, 64, 65, 67, 69, 71, 72] - - # E natural minor (aeolian) across one octave - subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3) - # → [40, 42, 43, 45, 47, 48, 50, 52] - - # 15 notes of A minor pentatonic ascending from A3 - subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15) - # → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91] - - # Misalignment: key="E" but low=C4 — first note is C, not E - subsequence.scale_notes("E", "minor", low=60, count=4) - # → [60, 62, 64, 66] (C D E F# — all in E natural minor, but starts on C) - - # Fix: derive key name from root_pitch so low is always in the scale - root_pitch = 64 # E4 - key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12] # → "E" - subsequence.scale_notes(key, "minor", low=root_pitch, count=4) - # → [64, 66, 67, 69] (E F# G A — starts on the root) - ``` - """ - - key_pc = subsequence.chords.key_name_to_pc(key) - pcs = set(scale_pitch_classes(key_pc, mode)) - - if count is not None: - if not pcs: - return [] - result: typing.List[int] = [] - pitch = low - while len(result) < count and pitch <= 127: - if pitch % 12 in pcs: - result.append(pitch) - pitch += 1 - return result - - return [p for p in range(low, high + 1) if p % 12 in pcs] - - -def quantize_pitch (pitch: int, scale_pcs: typing.Sequence[int]) -> int: - - """ - Snap a MIDI pitch to the nearest note in the given scale. - - Searches outward in semitone steps from the input pitch. When two - notes are equidistant (e.g. C# between C and D in C major), the - upward direction is preferred. - - Parameters: - pitch: MIDI note number to quantize. - scale_pcs: Pitch classes accepted by the scale (0–11). Typically - the output of :func:`scale_pitch_classes`. - - Returns: - A MIDI note number that lies within the scale. - - Example: - ```python - # Snap C# (61) to C (60) in C major - scale = scale_pitch_classes(0, "ionian") # [0, 2, 4, 5, 7, 9, 11] - quantize_pitch(61, scale) # → 60 - ``` - """ - - pc = pitch % 12 - - if pc in scale_pcs: - return pitch - - for offset in range(1, 7): - if (pc + offset) % 12 in scale_pcs: - return pitch + offset - if (pc - offset) % 12 in scale_pcs: - return pitch - offset - - # The search radius of ±6 semitones covers every gap in every scale with - # no gap wider than one tritone. A wider gap (unusual custom scale) falls - # through here and keeps the original off-scale pitch — warn so the caller - # knows the result is not actually snapped to the scale. - logger.warning( - "quantize_pitch: no scale note within ±6 semitones of MIDI %d (pc=%d); " - "returning pitch unquantized. scale_pcs=%s", - pitch, pc, sorted(scale_pcs), - ) - return pitch - - -def get_intervals (name: str) -> typing.List[int]: - - """ - Return a named interval list from the registry. - """ - - if name not in INTERVAL_DEFINITIONS: - raise ValueError(f"Unknown interval set: {name}") - - return list(INTERVAL_DEFINITIONS[name]) - - -def register_scale ( - name: str, - intervals: typing.List[int], - qualities: typing.Optional[typing.List[str]] = None + """Return MIDI note numbers for a scale within a pitch range. + + Parameters: + key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.). + This acts as a **pitch-class filter only** — it determines which + semitone positions (0–11) are valid members of the scale, but does + not affect which octave notes are drawn from. Notes are selected + starting from ``low`` upward; ``key`` controls *which* notes are + kept, not where the sequence starts. To guarantee the first + returned note is the root, ``low`` must be a MIDI number whose + pitch class matches ``key``. When starting from an arbitrary MIDI + number, derive the key name with + ``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``. + mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP` + (e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``, + ``"major_pentatonic"``). Use :func:`register_scale` for custom scales. + low: Lowest MIDI note (inclusive). When ``count`` is set, this is + the starting note from which the scale ascends. **If ``low`` is + not a member of the scale defined by ``key``, it is silently + skipped** and the first returned note will be the next in-scale + pitch above ``low``. + high: Highest MIDI note (inclusive). Ignored when ``count`` is set. + count: Exact number of notes to return. Notes ascend from ``low`` + through successive scale degrees, cycling into higher octaves + as needed. When ``None`` (default), all scale tones between + ``low`` and ``high`` are returned. + + Returns: + Sorted list of MIDI note numbers. + + Examples: + ```python + import subsequence + import subsequence.constants.midi_notes as notes + + # C major: all tones from middle C to C5 + subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5) + # → [60, 62, 64, 65, 67, 69, 71, 72] + + # E natural minor (aeolian) across one octave + subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3) + # → [40, 42, 43, 45, 47, 48, 50, 52] + + # 15 notes of A minor pentatonic ascending from A3 + subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15) + # → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91] + + # Misalignment: key="E" but low=C4 — first note is C, not E + subsequence.scale_notes("E", "minor", low=60, count=4) + # → [60, 62, 64, 66] (C D E F# — all in E natural minor, but starts on C) + + # Fix: derive key name from root_pitch so low is always in the scale + root_pitch = 64 # E4 + key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12] # → "E" + subsequence.scale_notes(key, "minor", low=root_pitch, count=4) + # → [64, 66, 67, 69] (E F# G A — starts on the root) + ``` + """ + + key_pc = subsequence.chords.key_name_to_pc(key) + pcs = set(scale_pitch_classes(key_pc, mode)) + + if count is not None: + if not pcs: + return [] + result: typing.List[int] = [] + pitch = low + while len(result) < count and pitch <= 127: + if pitch % 12 in pcs: + result.append(pitch) + pitch += 1 + return result + + return [p for p in range(low, high + 1) if p % 12 in pcs] + + +def quantize_pitch(pitch: int, scale_pcs: typing.Sequence[int]) -> int: + """ + Snap a MIDI pitch to the nearest note in the given scale. + + Searches outward in semitone steps from the input pitch. When two + notes are equidistant (e.g. C# between C and D in C major), the + upward direction is preferred. + + Parameters: + pitch: MIDI note number to quantize. + scale_pcs: Pitch classes accepted by the scale (0–11). Typically + the output of :func:`scale_pitch_classes`. + + Returns: + A MIDI note number that lies within the scale. + + Example: + ```python + # Snap C# (61) to C (60) in C major + scale = scale_pitch_classes(0, "ionian") # [0, 2, 4, 5, 7, 9, 11] + quantize_pitch(61, scale) # → 60 + ``` + """ + + pc = pitch % 12 + + if pc in scale_pcs: + return pitch + + for offset in range(1, 7): + if (pc + offset) % 12 in scale_pcs: + return pitch + offset + if (pc - offset) % 12 in scale_pcs: + return pitch - offset + + # The search radius of ±6 semitones covers every gap in every scale with + # no gap wider than one tritone. A wider gap (unusual custom scale) falls + # through here and keeps the original off-scale pitch — warn so the caller + # knows the result is not actually snapped to the scale. + logger.warning( + "quantize_pitch: no scale note within ±6 semitones of MIDI %d (pc=%d); " + "returning pitch unquantized. scale_pcs=%s", + pitch, + pc, + sorted(scale_pcs), + ) + return pitch + + +def get_intervals(name: str) -> typing.List[int]: + """ + Return a named interval list from the registry. + """ + + if name not in INTERVAL_DEFINITIONS: + raise ValueError(f"Unknown interval set: {name}") + + return list(INTERVAL_DEFINITIONS[name]) + + +def register_scale( + name: str, + intervals: typing.List[int], + qualities: typing.Optional[typing.List[str]] = None, ) -> None: - - """ - Register a custom scale for use with ``p.snap_to_scale()`` and - ``scale_pitch_classes()``. - - Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be - overwritten. Custom names may be re-registered freely — live reload - re-runs registration on every save, so this must not raise. - - Parameters: - name: Scale name (used in ``p.snap_to_scale(key, name)``). Must not - be the name of a built-in scale. - intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]`` - for Hirajōshi). Must be whole numbers, start with 0, ascend - strictly, and stay within 0–11. - qualities: Optional chord quality per scale degree (e.g. - ``["minor", "major", "minor", "major", "diminished"]``). - Required only if you want to use the scale with - ``diatonic_chords()`` or ``diatonic_chord_sequence()``. - - Raises: - ValueError: If *name* is a built-in scale, or *intervals* / - *qualities* fail the rules above. - - Example:: - - import subsequence - - subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11]) - - @comp.pattern(channel=0, length=4) - def melody (p): - p.note(60, beat=0) - p.snap_to_scale("C", "raga_bhairav") - """ - - if name in _BUILTIN_SCALE_NAMES: - raise ValueError( - f"Cannot overwrite built-in scale '{name}'. " - "Choose a different name for your custom scale." - ) - - if not intervals: - raise ValueError("intervals must not be empty") - if not all(isinstance(i, int) for i in intervals): - raise ValueError("intervals must be whole numbers (semitone offsets)") - if intervals[0] != 0: - raise ValueError("intervals must start with 0") - if any(b <= a for a, b in zip(intervals, intervals[1:])): - raise ValueError("intervals must be strictly ascending") - if any(i < 0 or i > 11 for i in intervals): - raise ValueError("intervals must contain values between 0 and 11") - if qualities is not None and len(qualities) != len(intervals): - raise ValueError( - f"qualities length ({len(qualities)}) must match " - f"intervals length ({len(intervals)})" - ) - - INTERVAL_DEFINITIONS[name] = intervals - SCALE_MODE_MAP[name] = (name, qualities) - - -def get_diatonic_intervals ( - scale_notes: typing.List[int], - intervals: typing.Optional[typing.List[int]] = None, - mode: str = "scale" + """ + Register a custom scale for use with ``p.snap_to_scale()`` and + ``scale_pitch_classes()``. + + Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be + overwritten. Custom names may be re-registered freely — live reload + re-runs registration on every save, so this must not raise. + + Parameters: + name: Scale name (used in ``p.snap_to_scale(key, name)``). Must not + be the name of a built-in scale. + intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]`` + for Hirajōshi). Must be whole numbers, start with 0, ascend + strictly, and stay within 0–11. + qualities: Optional chord quality per scale degree (e.g. + ``["minor", "major", "minor", "major", "diminished"]``). + Required only if you want to use the scale with + ``diatonic_chords()`` or ``diatonic_chord_sequence()``. + + Raises: + ValueError: If *name* is a built-in scale, or *intervals* / + *qualities* fail the rules above. + + Example:: + + import subsequence + + subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11]) + + @comp.pattern(channel=0, length=4) + def melody (p): + p.note(60, beat=0) + p.snap_to_scale("C", "raga_bhairav") + """ + + if name in _BUILTIN_SCALE_NAMES: + raise ValueError( + f"Cannot overwrite built-in scale '{name}'. " + "Choose a different name for your custom scale." + ) + + if not intervals: + raise ValueError("intervals must not be empty") + if not all(isinstance(i, int) for i in intervals): + raise ValueError("intervals must be whole numbers (semitone offsets)") + if intervals[0] != 0: + raise ValueError("intervals must start with 0") + if any(b <= a for a, b in zip(intervals, intervals[1:])): + raise ValueError("intervals must be strictly ascending") + if any(i < 0 or i > 11 for i in intervals): + raise ValueError("intervals must contain values between 0 and 11") + if qualities is not None and len(qualities) != len(intervals): + raise ValueError( + f"qualities length ({len(qualities)}) must match " + f"intervals length ({len(intervals)})" + ) + + INTERVAL_DEFINITIONS[name] = intervals + SCALE_MODE_MAP[name] = (name, qualities) + + +def get_diatonic_intervals( + scale_notes: typing.List[int], + intervals: typing.Optional[typing.List[int]] = None, + mode: str = "scale", ) -> typing.List[typing.List[int]]: + """ + Construct diatonic chords from a scale. + """ - """ - Construct diatonic chords from a scale. - """ - - if intervals is None: - intervals = [0, 2, 4] - - if mode not in ("scale", "chromatic"): - raise ValueError("mode must be 'scale' or 'chromatic'") + if intervals is None: + intervals = [0, 2, 4] - diatonic_intervals: typing.List[typing.List[int]] = [] - num_scale_notes = len(scale_notes) + if mode not in ("scale", "chromatic"): + raise ValueError("mode must be 'scale' or 'chromatic'") - for i in range(num_scale_notes): + diatonic_intervals: typing.List[typing.List[int]] = [] + num_scale_notes = len(scale_notes) - if mode == "scale": - chord = [scale_notes[(i + offset) % num_scale_notes] for offset in intervals] + for i in range(num_scale_notes): + if mode == "scale": + chord = [ + scale_notes[(i + offset) % num_scale_notes] for offset in intervals + ] - else: - root = scale_notes[i] - chord = [(root + offset) % 12 for offset in intervals] + else: + root = scale_notes[i] + chord = [(root + offset) % 12 for offset in intervals] - diatonic_intervals.append(chord) + diatonic_intervals.append(chord) - return diatonic_intervals + return diatonic_intervals diff --git a/subsequence/keystroke.py b/subsequence/keystroke.py index c3c5c40..42debbb 100644 --- a/subsequence/keystroke.py +++ b/subsequence/keystroke.py @@ -51,199 +51,203 @@ HOTKEYS_UNAVAILABLE_REASON: typing.Optional[str] = None try: - import termios - import tty + import termios + import tty - if not sys.stdin.isatty(): - raise OSError("stdin is not a TTY (running in a pipe or non-interactive context)") + if not sys.stdin.isatty(): + raise OSError( + "stdin is not a TTY (running in a pipe or non-interactive context)" + ) - # Quick sanity check — attempt to read and restore the current settings. - _fd = sys.stdin.fileno() - _saved = termios.tcgetattr(_fd) - termios.tcsetattr(_fd, termios.TCSADRAIN, _saved) + # Quick sanity check — attempt to read and restore the current settings. + _fd = sys.stdin.fileno() + _saved = termios.tcgetattr(_fd) + termios.tcsetattr(_fd, termios.TCSADRAIN, _saved) - HOTKEYS_SUPPORTED = True + HOTKEYS_SUPPORTED = True except ImportError: - HOTKEYS_UNAVAILABLE_REASON = ( - "The 'tty' and 'termios' modules are not available on this platform. " - "Hotkeys require a POSIX operating system (Linux or macOS)." - ) + HOTKEYS_UNAVAILABLE_REASON = ( + "The 'tty' and 'termios' modules are not available on this platform. " + "Hotkeys require a POSIX operating system (Linux or macOS)." + ) except OSError as _e: - HOTKEYS_UNAVAILABLE_REASON = ( - f"Hotkeys require an interactive terminal (TTY) on stdin. " - f"Reason: {_e}" - ) + HOTKEYS_UNAVAILABLE_REASON = ( + f"Hotkeys require an interactive terminal (TTY) on stdin. Reason: {_e}" + ) except Exception as _e: - HOTKEYS_UNAVAILABLE_REASON = f"Hotkeys unavailable: {_e}" + HOTKEYS_UNAVAILABLE_REASON = f"Hotkeys unavailable: {_e}" # --------------------------------------------------------------------------- # Listener class # --------------------------------------------------------------------------- -class KeystrokeListener: - - """Background daemon thread that reads single keystrokes from stdin. - - Puts stdin into *cbreak* mode so each keypress is delivered immediately, - without waiting for Enter. Keystrokes are placed in a thread-safe queue - and retrieved by the caller via :meth:`drain`. - - Terminal settings are always restored on shutdown, even if an exception - occurs, so a crashed listener will not leave the terminal in a broken state. - - If the current platform does not support hotkeys (:data:`HOTKEYS_SUPPORTED` - is ``False``), :meth:`start` logs a warning and returns immediately without - starting the thread. All other methods remain safe no-ops. - - Example:: - - listener = KeystrokeListener() - listener.start() - - # ...later, from the event loop... - for key in listener.drain(): - handle(key) - - listener.stop() - """ - - def __init__ (self) -> None: - - """Initialise the listener in a stopped state.""" - - self._queue: queue.Queue[str] = queue.Queue() - self._thread: typing.Optional[threading.Thread] = None - self._running: bool = False - self._old_settings: typing.Optional[typing.List[typing.Any]] = None - #: ``True`` after a successful :meth:`start` on a supported platform. - self.active: bool = False - - def start (self) -> None: - - """Start the background keystroke listener thread. - - Puts stdin into cbreak mode and begins reading. Call :meth:`stop` - to restore normal terminal behaviour. Safe to call more than once — - a second call while already running is a no-op. - - If :data:`HOTKEYS_SUPPORTED` is ``False``, logs a warning and returns - without starting the thread. :attr:`active` will remain ``False``. - """ - - if self._running: - return - - # A previous listener may still be inside its ~0.1 s poll: wait for it, - # or it would see the new _running=True, never exit, and the new thread - # would snapshot CBREAK mode as the "original" terminal settings. - if self._thread is not None and self._thread.is_alive(): - self._thread.join(timeout=0.5) - - if not HOTKEYS_SUPPORTED: - logger.warning( - f"Hotkeys are not available on this system and will be disabled. " - f"{HOTKEYS_UNAVAILABLE_REASON}" - ) - return - - self._running = True - self.active = True - self._thread = threading.Thread( - target = self._listen, - name = "subsequence-keystroke-listener", - daemon = True, # Dies automatically when the main thread exits. - ) - self._thread.start() - - def stop (self) -> None: - - """Signal the listener to stop and restore the terminal. - - Waits briefly for the background thread (it polls every ~0.1 s), then - restores the terminal settings directly if the thread has not done so — - a daemon thread killed at interpreter exit never runs its ``finally`` - block, which used to leave the shell in cbreak mode (no echo) on most - clean exits. Safe to call on an unsupported platform — it is a no-op. - """ +class KeystrokeListener: + """Background daemon thread that reads single keystrokes from stdin. - self._running = False - self.active = False + Puts stdin into *cbreak* mode so each keypress is delivered immediately, + without waiting for Enter. Keystrokes are placed in a thread-safe queue + and retrieved by the caller via :meth:`drain`. - if self._thread is not None and self._thread.is_alive(): - self._thread.join(timeout=0.5) + Terminal settings are always restored on shutdown, even if an exception + occurs, so a crashed listener will not leave the terminal in a broken state. - # Belt and braces: if the thread is somehow still alive (blocked - # read), restore the terminal from here - tcsetattr is idempotent. - if self._thread is not None and self._thread.is_alive() and self._old_settings is not None: - import termios # noqa: PLC0415 - termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_settings) + If the current platform does not support hotkeys (:data:`HOTKEYS_SUPPORTED` + is ``False``), :meth:`start` logs a warning and returns immediately without + starting the thread. All other methods remain safe no-ops. - def drain (self) -> typing.List[str]: + Example:: - """Return all keystrokes that have arrived since the last drain. + listener = KeystrokeListener() + listener.start() - Non-blocking. Returns an empty list if nothing has been pressed, or - if the listener is not active. Safe to call at any time. + # ...later, from the event loop... + for key in listener.drain(): + handle(key) - Returns: - A list of single-character strings, one per keypress, in order. - """ + listener.stop() + """ - keys: typing.List[str] = [] + def __init__(self) -> None: + """Initialise the listener in a stopped state.""" - while True: - try: - keys.append(self._queue.get_nowait()) - except queue.Empty: - break + self._queue: queue.Queue[str] = queue.Queue() + self._thread: typing.Optional[threading.Thread] = None + self._running: bool = False + self._old_settings: typing.Optional[typing.List[typing.Any]] = None - return keys + #: ``True`` after a successful :meth:`start` on a supported platform. + self.active: bool = False - def _listen (self) -> None: + def start(self) -> None: + """Start the background keystroke listener thread. - """Internal thread target. Runs until ``_running`` is set False. + Puts stdin into cbreak mode and begins reading. Call :meth:`stop` + to restore normal terminal behaviour. Safe to call more than once — + a second call while already running is a no-op. - Uses :func:`select.select` with a short timeout so the thread can - notice the ``_running = False`` signal without blocking indefinitely. - Terminal settings are restored in the ``finally`` block so they are - always cleaned up, even if an exception occurs. - """ - - # These imports are guaranteed safe here — _listen is only called - # when HOTKEYS_SUPPORTED is True, which already confirmed they exist. - import termios # noqa: PLC0415 - import tty # noqa: PLC0415 + If :data:`HOTKEYS_SUPPORTED` is ``False``, logs a warning and returns + without starting the thread. :attr:`active` will remain ``False``. + """ - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - - # Shared with stop() so it can restore the terminal if this thread is - # killed before the finally block runs (daemon threads at exit). - self._old_settings = old_settings - - try: - # cbreak: one character at a time, no Enter required. - # Differs from raw in that Ctrl+C / Ctrl+Z still work normally. - tty.setcbreak(fd) - - while self._running: - # Poll with a short timeout so we can check _running regularly. - ready, _, _ = select.select([sys.stdin], [], [], 0.1) - if ready: - char = sys.stdin.read(1) - if char: - self._queue.put(char) - - except Exception: - # A broken listener must not crash the composition — but dying - # silently left "why did my hotkeys stop working?" unanswerable - # (the finally below marks the listener inactive). - logger.warning("Keystroke listener stopped after an unexpected error — hotkeys are now inactive", exc_info=True) - - finally: - # Always restore terminal, even after exceptions. - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - self.active = False + if self._running: + return + + # A previous listener may still be inside its ~0.1 s poll: wait for it, + # or it would see the new _running=True, never exit, and the new thread + # would snapshot CBREAK mode as the "original" terminal settings. + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=0.5) + + if not HOTKEYS_SUPPORTED: + logger.warning( + f"Hotkeys are not available on this system and will be disabled. " + f"{HOTKEYS_UNAVAILABLE_REASON}" + ) + return + + self._running = True + self.active = True + self._thread = threading.Thread( + target=self._listen, + name="subsequence-keystroke-listener", + daemon=True, # Dies automatically when the main thread exits. + ) + self._thread.start() + + def stop(self) -> None: + """Signal the listener to stop and restore the terminal. + + Waits briefly for the background thread (it polls every ~0.1 s), then + restores the terminal settings directly if the thread has not done so — + a daemon thread killed at interpreter exit never runs its ``finally`` + block, which used to leave the shell in cbreak mode (no echo) on most + clean exits. Safe to call on an unsupported platform — it is a no-op. + """ + + self._running = False + self.active = False + + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=0.5) + + # Belt and braces: if the thread is somehow still alive (blocked + # read), restore the terminal from here - tcsetattr is idempotent. + if ( + self._thread is not None + and self._thread.is_alive() + and self._old_settings is not None + ): + import termios # noqa: PLC0415 + + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_settings) + + def drain(self) -> typing.List[str]: + """Return all keystrokes that have arrived since the last drain. + + Non-blocking. Returns an empty list if nothing has been pressed, or + if the listener is not active. Safe to call at any time. + + Returns: + A list of single-character strings, one per keypress, in order. + """ + + keys: typing.List[str] = [] + + while True: + try: + keys.append(self._queue.get_nowait()) + except queue.Empty: + break + + return keys + + def _listen(self) -> None: + """Internal thread target. Runs until ``_running`` is set False. + + Uses :func:`select.select` with a short timeout so the thread can + notice the ``_running = False`` signal without blocking indefinitely. + Terminal settings are restored in the ``finally`` block so they are + always cleaned up, even if an exception occurs. + """ + + # These imports are guaranteed safe here — _listen is only called + # when HOTKEYS_SUPPORTED is True, which already confirmed they exist. + import termios # noqa: PLC0415 + import tty # noqa: PLC0415 + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + + # Shared with stop() so it can restore the terminal if this thread is + # killed before the finally block runs (daemon threads at exit). + self._old_settings = old_settings + + try: + # cbreak: one character at a time, no Enter required. + # Differs from raw in that Ctrl+C / Ctrl+Z still work normally. + tty.setcbreak(fd) + + while self._running: + # Poll with a short timeout so we can check _running regularly. + ready, _, _ = select.select([sys.stdin], [], [], 0.1) + if ready: + char = sys.stdin.read(1) + if char: + self._queue.put(char) + + except Exception: + # A broken listener must not crash the composition — but dying + # silently left "why did my hotkeys stop working?" unanswerable + # (the finally below marks the listener inactive). + logger.warning( + "Keystroke listener stopped after an unexpected error — hotkeys are now inactive", + exc_info=True, + ) + + finally: + # Always restore terminal, even after exceptions. + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + self.active = False diff --git a/subsequence/link_clock.py b/subsequence/link_clock.py index d3e5cc2..13d929f 100644 --- a/subsequence/link_clock.py +++ b/subsequence/link_clock.py @@ -21,108 +21,111 @@ import typing -def _require_aalink () -> typing.Any: - """Import aalink or raise a helpful RuntimeError.""" - try: - import aalink # type: ignore - return aalink - except ImportError: - raise RuntimeError( - "Ableton Link support requires the 'aalink' package.\n" - "Install it with: pip install subsequence[link]" - ) from None +def _require_aalink() -> typing.Any: + """Import aalink or raise a helpful RuntimeError.""" + try: + import aalink # type: ignore + return aalink + except ImportError: + raise RuntimeError( + "Ableton Link support requires the 'aalink' package.\n" + "Install it with: pip install subsequence[link]" + ) from None -class LinkClock: - """ - Thin wrapper around ``aalink.Link`` for Subsequence's pulse-based clock. - - Parameters: - bpm: Initial tempo in BPM (proposed to the Link session). - quantum: Beat cycle length — 4.0 means one bar in 4/4 time. - loop: The running asyncio event loop (required by aalink). - """ - - def __init__ (self, bpm: float, quantum: float, loop: asyncio.AbstractEventLoop) -> None: - - """ - Join the Link session immediately, proposing *bpm* and setting the bar length to *quantum* beats. - """ - - aalink = _require_aalink() - self._link = aalink.Link(bpm, loop) - self._link.enabled = True - self._link.quantum = float(quantum) - - # ------------------------------------------------------------------ - # Properties that mirror the Link session state - # ------------------------------------------------------------------ - - @property - def beat (self) -> float: - """Current absolute beat position in the Link session timeline.""" - return float(self._link.beat) - - @property - def tempo (self) -> float: - """Current session tempo in BPM (authoritative from the Link network).""" - return float(self._link.tempo) - - @property - def quantum (self) -> float: - """Beat cycle length (e.g. 4.0 for one bar in 4/4).""" - return float(self._link.quantum) - - @property - def num_peers (self) -> int: - """Number of connected Link peers (not counting self).""" - return int(self._link.num_peers) - - @property - def playing (self) -> bool: - """Whether the Link session transport is playing.""" - return bool(self._link.playing) - - # ------------------------------------------------------------------ - # Sync / control - # ------------------------------------------------------------------ - - async def sync (self, beat: float) -> float: - """Wait until the Link session beat reaches *beat*, then return *beat*. - - This is the primary timing primitive used by the sequencer loop. - Calling ``await link_clock.sync(beat_origin + pulse / PPQN)`` for each - successive pulse gives accurate, Link-synchronised timing. - """ - return float(await self._link.sync(beat)) - - async def wait_for_bar (self) -> float: - """Wait for the next quantum boundary (bar start) and return it. - - Use this to start the sequencer at a musically clean position that is - phase-aligned with all other Link participants. - - Returns the beat value at which playback should begin (``beat_origin``). - """ - current = self._link.beat - # Next quantum boundary strictly after the current beat - # math.floor, not int(): Link beats can be negative before transport - # zero, and int() truncates toward zero - skipping the boundary at 0.0 - # and delaying the start by a full extra quantum. - next_boundary = (math.floor(current / self._link.quantum) + 1) * self._link.quantum - result = await self._link.sync(next_boundary) - return float(result) - - def request_tempo (self, bpm: float) -> None: - """Propose a new tempo to the Link session. - - Other peers may accept or reject the change depending on their own - session rules. Subsequence's sequencer will pick up the network- - authoritative tempo on the next pulse. - """ - self._link.tempo = float(bpm) - - def disable (self) -> None: - """Disconnect from the Link session.""" - self._link.enabled = False +class LinkClock: + """ + Thin wrapper around ``aalink.Link`` for Subsequence's pulse-based clock. + + Parameters: + bpm: Initial tempo in BPM (proposed to the Link session). + quantum: Beat cycle length — 4.0 means one bar in 4/4 time. + loop: The running asyncio event loop (required by aalink). + """ + + def __init__( + self, bpm: float, quantum: float, loop: asyncio.AbstractEventLoop + ) -> None: + """ + Join the Link session immediately, proposing *bpm* and setting the bar length to *quantum* beats. + """ + + aalink = _require_aalink() + self._link = aalink.Link(bpm, loop) + self._link.enabled = True + self._link.quantum = float(quantum) + + # ------------------------------------------------------------------ + # Properties that mirror the Link session state + # ------------------------------------------------------------------ + + @property + def beat(self) -> float: + """Current absolute beat position in the Link session timeline.""" + return float(self._link.beat) + + @property + def tempo(self) -> float: + """Current session tempo in BPM (authoritative from the Link network).""" + return float(self._link.tempo) + + @property + def quantum(self) -> float: + """Beat cycle length (e.g. 4.0 for one bar in 4/4).""" + return float(self._link.quantum) + + @property + def num_peers(self) -> int: + """Number of connected Link peers (not counting self).""" + return int(self._link.num_peers) + + @property + def playing(self) -> bool: + """Whether the Link session transport is playing.""" + return bool(self._link.playing) + + # ------------------------------------------------------------------ + # Sync / control + # ------------------------------------------------------------------ + + async def sync(self, beat: float) -> float: + """Wait until the Link session beat reaches *beat*, then return *beat*. + + This is the primary timing primitive used by the sequencer loop. + Calling ``await link_clock.sync(beat_origin + pulse / PPQN)`` for each + successive pulse gives accurate, Link-synchronised timing. + """ + return float(await self._link.sync(beat)) + + async def wait_for_bar(self) -> float: + """Wait for the next quantum boundary (bar start) and return it. + + Use this to start the sequencer at a musically clean position that is + phase-aligned with all other Link participants. + + Returns the beat value at which playback should begin (``beat_origin``). + """ + current = self._link.beat + # Next quantum boundary strictly after the current beat + # math.floor, not int(): Link beats can be negative before transport + # zero, and int() truncates toward zero - skipping the boundary at 0.0 + # and delaying the start by a full extra quantum. + next_boundary = ( + math.floor(current / self._link.quantum) + 1 + ) * self._link.quantum + result = await self._link.sync(next_boundary) + return float(result) + + def request_tempo(self, bpm: float) -> None: + """Propose a new tempo to the Link session. + + Other peers may accept or reject the change depending on their own + session rules. Subsequence's sequencer will pick up the network- + authoritative tempo on the next pulse. + """ + self._link.tempo = float(bpm) + + def disable(self) -> None: + """Disconnect from the Link session.""" + self._link.enabled = False diff --git a/subsequence/live_client.py b/subsequence/live_client.py index 7f7b4bd..87115d8 100644 --- a/subsequence/live_client.py +++ b/subsequence/live_client.py @@ -6,7 +6,7 @@ python -m subsequence.live_client --port 5555 The client connects to a live server started by ``composition.live()`` and -provides an interactive Python prompt. Multi-line blocks are supported - +provides an interactive Python prompt. Multi-line blocks are supported - type a line ending with ``:`` and the client will wait for more input. Press Ctrl+C to cancel the current input. Press Ctrl+D to quit. @@ -22,166 +22,161 @@ class LiveClient: + """TCP client that sends code to a running Subsequence live server.""" - """TCP client that sends code to a running Subsequence live server.""" + def __init__(self) -> None: + """Initialize with no connection.""" - def __init__ (self) -> None: + self._sock: typing.Optional[socket.socket] = None - """Initialize with no connection.""" + def connect(self, host: str = "127.0.0.1", port: int = 5555) -> None: + """Connect to the live server.""" - self._sock: typing.Optional[socket.socket] = None + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.connect((host, port)) - def connect (self, host: str = "127.0.0.1", port: int = 5555) -> None: + def send(self, code: str) -> str: + """Send code to the server and return the response.""" - """Connect to the live server.""" + if self._sock is None: + raise ConnectionError("Not connected") - self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._sock.connect((host, port)) + self._sock.sendall(code.encode("utf-8") + SENTINEL) - def send (self, code: str) -> str: + chunks: typing.List[bytes] = [] - """Send code to the server and return the response.""" + while True: + chunk = self._sock.recv(4096) - if self._sock is None: - raise ConnectionError("Not connected") + if not chunk: + raise ConnectionError("Server closed connection") - self._sock.sendall(code.encode("utf-8") + SENTINEL) + if SENTINEL in chunk: + before, _, _ = chunk.partition(SENTINEL) + chunks.append(before) + break - chunks: typing.List[bytes] = [] + chunks.append(chunk) - while True: - chunk = self._sock.recv(4096) + return b"".join(chunks).decode("utf-8") - if not chunk: - raise ConnectionError("Server closed connection") + def close(self) -> None: + """Close the connection.""" - if SENTINEL in chunk: - before, _, _ = chunk.partition(SENTINEL) - chunks.append(before) - break + if self._sock is not None: + self._sock.close() + self._sock = None - chunks.append(chunk) - return b"".join(chunks).decode("utf-8") +def _is_incomplete(code: str) -> bool: + """Return True if the code looks like an incomplete multi-line block.""" - def close (self) -> None: + stripped = code.rstrip() - """Close the connection.""" + if not stripped: + return False - if self._sock is not None: - self._sock.close() - self._sock = None + # Trailing colon suggests a block header (def, if, for, etc.). + if stripped.endswith(":"): + return True + # Unclosed brackets or parens. + opens = sum(1 for c in code if c in "([{") + closes = sum(1 for c in code if c in ")]}") -def _is_incomplete (code: str) -> bool: + if opens > closes: + return True - """Return True if the code looks like an incomplete multi-line block.""" + # Trailing backslash (line continuation). + if stripped.endswith("\\"): + return True - stripped = code.rstrip() + return False - if not stripped: - return False - # Trailing colon suggests a block header (def, if, for, etc.). - if stripped.endswith(":"): - return True +def main() -> None: + """Run the interactive REPL loop.""" - # Unclosed brackets or parens. - opens = sum(1 for c in code if c in "([{") - closes = sum(1 for c in code if c in ")]}") + parser = argparse.ArgumentParser(description="Subsequence live coding client") + parser.add_argument( + "--host", default="127.0.0.1", help="Server host (default: 127.0.0.1)" + ) + parser.add_argument( + "--port", type=int, default=5555, help="Server port (default: 5555)" + ) + args = parser.parse_args() - if opens > closes: - return True + client = LiveClient() - # Trailing backslash (line continuation). - if stripped.endswith("\\"): - return True + try: + client.connect(args.host, args.port) + except ConnectionRefusedError: + print(f"Could not connect to {args.host}:{args.port}") + print("Is the composition running with composition.live() enabled?") + sys.exit(1) - return False + print(f"Connected to Subsequence on {args.host}:{args.port}") + # Fetch and display status header. A failure here is cosmetic (the REPL + # still works), but say so instead of hiding it. + try: + info_response = client.send("composition.live_info()") + print(info_response) + except Exception as e: + print(f"(could not fetch the status header: {e})") -def main () -> None: + print() - """Run the interactive REPL loop.""" + try: + while True: + try: + line = input(">>> ") + except KeyboardInterrupt: + print() + continue - parser = argparse.ArgumentParser(description="Subsequence live coding client") - parser.add_argument("--host", default="127.0.0.1", help="Server host (default: 127.0.0.1)") - parser.add_argument("--port", type=int, default=5555, help="Server port (default: 5555)") - args = parser.parse_args() + lines = [line] - client = LiveClient() + # Accumulate multi-line blocks. Once a block has started, only a + # BLANK line ends it (standard REPL behaviour) — terminating when + # the joined code merely "looked complete" cut every block off + # after its first body line. + in_block = _is_incomplete(line) - try: - client.connect(args.host, args.port) - except ConnectionRefusedError: - print(f"Could not connect to {args.host}:{args.port}") - print("Is the composition running with composition.live() enabled?") - sys.exit(1) + while in_block or _is_incomplete("\n".join(lines)): + try: + continuation = input("... ") + except KeyboardInterrupt: + print() + lines = [] + break - print(f"Connected to Subsequence on {args.host}:{args.port}") + if in_block and continuation.strip() == "": + break - # Fetch and display status header. A failure here is cosmetic (the REPL - # still works), but say so instead of hiding it. - try: - info_response = client.send("composition.live_info()") - print(info_response) - except Exception as e: - print(f"(could not fetch the status header: {e})") + lines.append(continuation) - print() + if not lines: + continue - try: + code = "\n".join(lines).strip() - while True: + if not code: + continue - try: - line = input(">>> ") - except KeyboardInterrupt: - print() - continue + try: + response = client.send(code) + print(response) + except ConnectionError: + print("Connection lost.") + break - lines = [line] + except EOFError: + print() - # Accumulate multi-line blocks. Once a block has started, only a - # BLANK line ends it (standard REPL behaviour) — terminating when - # the joined code merely "looked complete" cut every block off - # after its first body line. - in_block = _is_incomplete(line) - - while in_block or _is_incomplete("\n".join(lines)): - try: - continuation = input("... ") - except KeyboardInterrupt: - print() - lines = [] - break - - if in_block and continuation.strip() == "": - break - - lines.append(continuation) - - if not lines: - continue - - code = "\n".join(lines).strip() - - if not code: - continue - - try: - response = client.send(code) - print(response) - except ConnectionError: - print("Connection lost.") - break - - except EOFError: - print() - - finally: - client.close() + finally: + client.close() if __name__ == "__main__": - main() + main() diff --git a/subsequence/live_reloader.py b/subsequence/live_reloader.py index 49f3e7b..d5dc287 100644 --- a/subsequence/live_reloader.py +++ b/subsequence/live_reloader.py @@ -83,216 +83,220 @@ if typing.TYPE_CHECKING: - import subsequence.composition + import subsequence.composition logger = logging.getLogger(__name__) class LiveReloader: - - """Watch a Python file and re-exec it on save into a live composition. - - Constructed by ``Composition.watch(path)``; users do not instantiate - this class directly. Owns a daemon thread that polls the file's - modification time and a reference back to the composition for - scheduling reloads onto its event loop. - """ - - def __init__ ( - self, - composition: "subsequence.composition.Composition", - path: typing.Union[str, pathlib.Path], - poll_interval: float = 0.25, - skip_initial_exec: bool = False, - ) -> None: - - """Initialise the reloader in a stopped state. - - Parameters: - composition: The live ``Composition`` instance to reload into. - path: Path to the Python file to watch. - poll_interval: Seconds between ``st_mtime`` polls. Default - 0.25 s gives a responsive feel for editor saves without - busy-waiting. - skip_initial_exec: When ``True``, ``start()`` skips the - compile + exec phase of the initial load and only records - ``_last_mtime``. Set by ``Composition.watch()`` when it - detects a self-watch (the file calling ``watch()`` is the - file being watched), since the outer Python script execution - will already run the patterns at the module level — a second - exec via ``_load_initial`` would double-register every one. - """ - - self._composition = composition - self._path: pathlib.Path = pathlib.Path(path) - self._poll_interval = poll_interval - self._skip_initial_exec = skip_initial_exec - - # Last known mtime; set by the initial load and updated on each - # detected change. Used by the watcher loop to skip unchanged ticks. - self._last_mtime: typing.Optional[float] = None - - # Daemon thread state — created on start(). - self._thread: typing.Optional[threading.Thread] = None - self._stop_event = threading.Event() - - def start (self) -> None: - - """Perform the initial synchronous load, then spawn the watcher thread. - - Raises :exc:`SyntaxError` or :exc:`FileNotFoundError` if the file - cannot be loaded — better to fail loudly here than to leave the - user wondering why no patterns are running. - - Safe to call once. A second call while the watcher is already - running is a no-op. - """ - - if self._thread is not None and self._thread.is_alive(): - logger.debug(f"LiveReloader.start() no-op: already watching {self._path}") - return - - # Initial load — synchronous, on the calling thread. Raises on failure. - self._load_initial() - - self._stop_event.clear() - self._thread = threading.Thread( - target = self._watch_loop, - name = f"subsequence-live-reloader-{self._path.name}", - daemon = True, - ) - self._thread.start() - logger.info(f"LiveReloader watching {self._path} (poll {self._poll_interval}s)") - - def stop (self) -> None: - - """Signal the watcher thread to exit; safe to call multiple times. - - Joins the thread with a short timeout so shutdown is bounded. - """ - - self._stop_event.set() - - if self._thread is not None and self._thread.is_alive(): - self._thread.join(timeout = self._poll_interval * 2 + 0.5) - - self._thread = None - - # ── Internals ────────────────────────────────────────────────────────── - - def _load_initial (self) -> None: - - """Synchronous first load; raises on failure. - - Reads, compiles and execs the file on the calling thread. Doesn't - go through ``Composition.load_patterns()`` because that method - schedules onto the event loop when one is running and waits via - ``future.result()`` — which would deadlock if ``watch()`` happens - to be called from inside the event loop (e.g. in tests). The - ``_load_initial`` contract is pre-play setup, so direct exec is - correct here: decorators populate ``_pending_patterns`` and the - composition's ``play()`` graduates them. - - When ``self._skip_initial_exec`` is ``True`` (single-file self-watch), - the compile+exec step is skipped — the outer Python script will run - the decorators itself. We still stat for ``_last_mtime`` so the - watcher loop doesn't immediately re-trigger on the first poll. - """ - - if not self._skip_initial_exec: - - content = self._path.read_text(encoding = "utf-8") - compiled = compile(content, str(self._path), "exec") - - namespace = self._composition._build_live_namespace(source_label = str(self._path)) - - # Mirror _apply_source_async's bookkeeping so the FIRST save can - # already diff against what this file declares now — recording - # only this file's names, not the wrapper script's. - self._composition._declared_names = set() - exec(compiled, namespace) - self._composition._source_declared[str(self._path)] = set(self._composition._declared_names) - - try: - self._last_mtime = os.stat(self._path).st_mtime - except OSError: - self._last_mtime = None - - def _watch_loop (self) -> None: - - """Polling loop running in the daemon thread. - - Stats the file every ``poll_interval`` seconds; on detected mtime - change, schedules :meth:`_reload_async` onto the composition's - event loop via :func:`asyncio.run_coroutine_threadsafe`. - """ - - while not self._stop_event.is_set(): - - try: - mtime = os.stat(self._path).st_mtime - except OSError: - # File disappeared or became unreadable — wait it out. - # Editors that save via write-temp-then-rename can produce - # brief windows like this. - self._stop_event.wait(self._poll_interval) - continue - - # != rather than >: a timestamp-preserving replacement (mv backup.py - # watched.py) can legitimately have an OLDER mtime. - if self._last_mtime is None or mtime != self._last_mtime: - - loop = self._composition._sequencer._event_loop - - if loop is None: - # Event loop isn't running yet (watch() called before play(), - # or play() not called). Don't advance _last_mtime — the - # next poll will pick up the same change and try again. - logger.debug("LiveReloader: no event loop yet, deferring reload") - else: - self._last_mtime = mtime - asyncio.run_coroutine_threadsafe(self._reload_async(), loop = loop) - - # Use the stop event's wait() so shutdown is instantaneous instead - # of having to wait out the full poll interval. - self._stop_event.wait(self._poll_interval) - - async def _reload_async (self) -> None: - - """Read, compile, apply — runs on the event loop thread. - - Delegates the exec + activate + diff-and-unregister phases to - ``Composition._apply_source_async``. We do the compile step here - (rather than via ``Composition.load_patterns``) so SyntaxError can - be reported with a watcher-specific log message, and so the apply - coroutine runs directly on the loop without re-scheduling through - ``run_coroutine_threadsafe``. - - Errors are logged but do not abort the watcher. - """ - - try: - content = self._path.read_text(encoding = "utf-8") - except OSError as exc: - logger.warning(f"LiveReloader: could not read {self._path}: {exc}") - return - - # Syntax check — bail early without touching state. - try: - compiled = compile(content, str(self._path), "exec") - except SyntaxError: - logger.warning(f"LiveReloader: SyntaxError in {self._path}, skipping reload:\n{traceback.format_exc()}") - return - - namespace = self._composition._build_live_namespace(source_label = str(self._path)) - - try: - await self._composition._apply_source_async(compiled, namespace, source_key = str(self._path)) - except Exception: - # Apply re-raises on exec failure; suppress here so the watcher - # keeps running. The diff-and-unregister phase inside - # _apply_source_async is skipped automatically when exec raises, - # so previous state is preserved. - logger.warning(f"LiveReloader: error executing {self._path}, skipping reload:\n{traceback.format_exc()}") - return + """Watch a Python file and re-exec it on save into a live composition. + + Constructed by ``Composition.watch(path)``; users do not instantiate + this class directly. Owns a daemon thread that polls the file's + modification time and a reference back to the composition for + scheduling reloads onto its event loop. + """ + + def __init__( + self, + composition: "subsequence.composition.Composition", + path: typing.Union[str, pathlib.Path], + poll_interval: float = 0.25, + skip_initial_exec: bool = False, + ) -> None: + """Initialise the reloader in a stopped state. + + Parameters: + composition: The live ``Composition`` instance to reload into. + path: Path to the Python file to watch. + poll_interval: Seconds between ``st_mtime`` polls. Default + 0.25 s gives a responsive feel for editor saves without + busy-waiting. + skip_initial_exec: When ``True``, ``start()`` skips the + compile + exec phase of the initial load and only records + ``_last_mtime``. Set by ``Composition.watch()`` when it + detects a self-watch (the file calling ``watch()`` is the + file being watched), since the outer Python script execution + will already run the patterns at the module level — a second + exec via ``_load_initial`` would double-register every one. + """ + + self._composition = composition + self._path: pathlib.Path = pathlib.Path(path) + self._poll_interval = poll_interval + self._skip_initial_exec = skip_initial_exec + + # Last known mtime; set by the initial load and updated on each + # detected change. Used by the watcher loop to skip unchanged ticks. + self._last_mtime: typing.Optional[float] = None + + # Daemon thread state — created on start(). + self._thread: typing.Optional[threading.Thread] = None + self._stop_event = threading.Event() + + def start(self) -> None: + """Perform the initial synchronous load, then spawn the watcher thread. + + Raises :exc:`SyntaxError` or :exc:`FileNotFoundError` if the file + cannot be loaded — better to fail loudly here than to leave the + user wondering why no patterns are running. + + Safe to call once. A second call while the watcher is already + running is a no-op. + """ + + if self._thread is not None and self._thread.is_alive(): + logger.debug(f"LiveReloader.start() no-op: already watching {self._path}") + return + + # Initial load — synchronous, on the calling thread. Raises on failure. + self._load_initial() + + self._stop_event.clear() + self._thread = threading.Thread( + target=self._watch_loop, + name=f"subsequence-live-reloader-{self._path.name}", + daemon=True, + ) + self._thread.start() + logger.info(f"LiveReloader watching {self._path} (poll {self._poll_interval}s)") + + def stop(self) -> None: + """Signal the watcher thread to exit; safe to call multiple times. + + Joins the thread with a short timeout so shutdown is bounded. + """ + + self._stop_event.set() + + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=self._poll_interval * 2 + 0.5) + + self._thread = None + + # ── Internals ────────────────────────────────────────────────────────── + + def _load_initial(self) -> None: + """Synchronous first load; raises on failure. + + Reads, compiles and execs the file on the calling thread. Doesn't + go through ``Composition.load_patterns()`` because that method + schedules onto the event loop when one is running and waits via + ``future.result()`` — which would deadlock if ``watch()`` happens + to be called from inside the event loop (e.g. in tests). The + ``_load_initial`` contract is pre-play setup, so direct exec is + correct here: decorators populate ``_pending_patterns`` and the + composition's ``play()`` graduates them. + + When ``self._skip_initial_exec`` is ``True`` (single-file self-watch), + the compile+exec step is skipped — the outer Python script will run + the decorators itself. We still stat for ``_last_mtime`` so the + watcher loop doesn't immediately re-trigger on the first poll. + """ + + if not self._skip_initial_exec: + content = self._path.read_text(encoding="utf-8") + compiled = compile(content, str(self._path), "exec") + + namespace = self._composition._build_live_namespace( + source_label=str(self._path) + ) + + # Mirror _apply_source_async's bookkeeping so the FIRST save can + # already diff against what this file declares now — recording + # only this file's names, not the wrapper script's. + self._composition._declared_names = set() + exec(compiled, namespace) + self._composition._source_declared[str(self._path)] = set( + self._composition._declared_names + ) + + try: + self._last_mtime = os.stat(self._path).st_mtime + except OSError: + self._last_mtime = None + + def _watch_loop(self) -> None: + """Polling loop running in the daemon thread. + + Stats the file every ``poll_interval`` seconds; on detected mtime + change, schedules :meth:`_reload_async` onto the composition's + event loop via :func:`asyncio.run_coroutine_threadsafe`. + """ + + while not self._stop_event.is_set(): + logger.debug("polling file %s", self._path) + + try: + mtime = os.stat(self._path).st_mtime + except OSError: + # File disappeared or became unreadable — wait it out. + # Editors that save via write-temp-then-rename can produce + # brief windows like this. + self._stop_event.wait(self._poll_interval) + continue + + # != rather than >: a timestamp-preserving replacement (mv backup.py + # watched.py) can legitimately have an OLDER mtime. + if self._last_mtime is None or mtime != self._last_mtime: + loop = self._composition._sequencer._event_loop + + if loop is None: + # Event loop isn't running yet (watch() called before play(), + # or play() not called). Don't advance _last_mtime — the + # next poll will pick up the same change and try again. + logger.debug("LiveReloader: no event loop yet, deferring reload") + else: + self._last_mtime = mtime + asyncio.run_coroutine_threadsafe(self._reload_async(), loop=loop) + + # Use the stop event's wait() so shutdown is instantaneous instead + # of having to wait out the full poll interval. + self._stop_event.wait(self._poll_interval) + + async def _reload_async(self) -> None: + """Read, compile, apply — runs on the event loop thread. + + Delegates the exec + activate + diff-and-unregister phases to + ``Composition._apply_source_async``. We do the compile step here + (rather than via ``Composition.load_patterns``) so SyntaxError can + be reported with a watcher-specific log message, and so the apply + coroutine runs directly on the loop without re-scheduling through + ``run_coroutine_threadsafe``. + + Errors are logged but do not abort the watcher. + """ + + try: + content = self._path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning(f"LiveReloader: could not read {self._path}: {exc}") + return + + # Syntax check — bail early without touching state. + try: + compiled = compile(content, str(self._path), "exec") + except SyntaxError: + logger.warning( + f"LiveReloader: SyntaxError in {self._path}, skipping reload:\n{traceback.format_exc()}" + ) + return + + namespace = self._composition._build_live_namespace( + source_label=str(self._path) + ) + + try: + await self._composition._apply_source_async( + compiled, namespace, source_key=str(self._path) + ) + except Exception: + # Apply re-raises on exec failure; suppress here so the watcher + # keeps running. The diff-and-unregister phase inside + # _apply_source_async is skipped automatically when exec raises, + # so previous state is preserved. + logger.warning( + f"LiveReloader: error executing {self._path}, skipping reload:\n{traceback.format_exc()}" + ) + return diff --git a/subsequence/live_server.py b/subsequence/live_server.py index 66577ea..fc26ed4 100644 --- a/subsequence/live_server.py +++ b/subsequence/live_server.py @@ -24,7 +24,7 @@ import typing if typing.TYPE_CHECKING: - from subsequence.composition import Composition + from subsequence.composition import Composition logger = logging.getLogger(__name__) @@ -33,129 +33,119 @@ class LiveServer: + """Async TCP server that evaluates Python code inside a running composition.""" - """Async TCP server that evaluates Python code inside a running composition.""" + def __init__(self, composition: "Composition", port: int = 5555) -> None: + """Store a reference to the composition and the port to listen on.""" - def __init__ (self, composition: "Composition", port: int = 5555) -> None: + self._composition = composition + self._port = port + self._server: typing.Optional[asyncio.AbstractServer] = None + self._namespace: typing.Dict[str, typing.Any] = {} - """Store a reference to the composition and the port to listen on.""" + async def start(self) -> None: + """Start listening for connections on localhost.""" - self._composition = composition - self._port = port - self._server: typing.Optional[asyncio.AbstractServer] = None - self._namespace: typing.Dict[str, typing.Any] = {} + self._namespace = self._composition._build_live_namespace() - async def start (self) -> None: - - """Start listening for connections on localhost.""" - - self._namespace = self._composition._build_live_namespace() - - self._server = await asyncio.start_server( - self._handle_connection, - host = "127.0.0.1", - port = self._port - ) - - logger.info(f"Live server listening on 127.0.0.1:{self._port}") - - async def stop (self) -> None: - - """Close the server and wait for it to shut down.""" - - if self._server is not None: - self._server.close() - await self._server.wait_closed() - self._server = None - logger.info("Live server stopped") - - async def _handle_connection (self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - - """Handle a single client connection with an eval/exec loop.""" - - peer = writer.get_extra_info("peername") - logger.info(f"Live client connected: {peer}") - - try: - - while True: - - code = await self._read_message(reader) - - if code is None: - break - - response = await asyncio.to_thread(self._evaluate, code) - writer.write(response.encode() + SENTINEL) - await writer.drain() - - except ConnectionResetError: - logger.info(f"Live client disconnected (reset): {peer}") - - except Exception as exc: - logger.warning(f"Live connection error: {exc}") - - finally: - writer.close() - try: - await writer.wait_closed() - except Exception: - pass - logger.info(f"Live client disconnected: {peer}") - - async def _read_message (self, reader: asyncio.StreamReader) -> typing.Optional[str]: - - """Read bytes until the sentinel or EOF, returning the decoded string or None.""" - - chunks: typing.List[bytes] = [] - - while True: - - try: - chunk = await reader.read(4096) - except ConnectionResetError: - return None - - if not chunk: - return None - - if SENTINEL in chunk: - before, _, _ = chunk.partition(SENTINEL) - chunks.append(before) - break - - chunks.append(chunk) - - data = b"".join(chunks).decode("utf-8").strip() - - return data if data else None - - def _evaluate (self, code: str) -> str: - - """Validate, then eval/exec the code string. Return the result or error traceback.""" - - # Validate syntax before executing - never run invalid code. - try: - compile(code, "", "exec") - except SyntaxError: - return traceback.format_exc() - - # Try as an expression first (returns a value). - try: - result = eval(compile(code, "", "eval"), self._namespace) - return repr(result) if result is not None else "OK" - except SyntaxError: - pass - except SystemExit: - return "SystemExit is not allowed in live mode." - except Exception: - return traceback.format_exc() - - # Fall back to statement execution. - try: - exec(compile(code, "", "exec"), self._namespace) - return "OK" - except SystemExit: - return "SystemExit is not allowed in live mode." - except Exception: - return traceback.format_exc() + self._server = await asyncio.start_server( + self._handle_connection, host="127.0.0.1", port=self._port + ) + + logger.info(f"Live server listening on 127.0.0.1:{self._port}") + + async def stop(self) -> None: + """Close the server and wait for it to shut down.""" + + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + logger.info("Live server stopped") + + async def _handle_connection( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Handle a single client connection with an eval/exec loop.""" + + peer = writer.get_extra_info("peername") + logger.info(f"Live client connected: {peer}") + + try: + while True: + code = await self._read_message(reader) + + if code is None: + break + + response = await asyncio.to_thread(self._evaluate, code) + writer.write(response.encode() + SENTINEL) + await writer.drain() + + except ConnectionResetError: + logger.info(f"Live client disconnected (reset): {peer}") + + except Exception as exc: + logger.warning(f"Live connection error: {exc}") + + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + logger.info(f"Live client disconnected: {peer}") + + async def _read_message(self, reader: asyncio.StreamReader) -> typing.Optional[str]: + """Read bytes until the sentinel or EOF, returning the decoded string or None.""" + + chunks: typing.List[bytes] = [] + + while True: + try: + chunk = await reader.read(4096) + except ConnectionResetError: + return None + + if not chunk: + return None + + if SENTINEL in chunk: + before, _, _ = chunk.partition(SENTINEL) + chunks.append(before) + break + + chunks.append(chunk) + + data = b"".join(chunks).decode("utf-8").strip() + + return data if data else None + + def _evaluate(self, code: str) -> str: + """Validate, then eval/exec the code string. Return the result or error traceback.""" + + # Validate syntax before executing - never run invalid code. + try: + compile(code, "", "exec") + except SyntaxError: + return traceback.format_exc() + + # Try as an expression first (returns a value). + try: + result = eval(compile(code, "", "eval"), self._namespace) + return repr(result) if result is not None else "OK" + except SyntaxError: + pass + except SystemExit: + return "SystemExit is not allowed in live mode." + except Exception: + return traceback.format_exc() + + # Fall back to statement execution. + try: + exec(compile(code, "", "exec"), self._namespace) + return "OK" + except SystemExit: + return "SystemExit is not allowed in live mode." + except Exception: + return traceback.format_exc() diff --git a/subsequence/melodic_state.py b/subsequence/melodic_state.py index 71a1c05..a163067 100644 --- a/subsequence/melodic_state.py +++ b/subsequence/melodic_state.py @@ -31,35 +31,34 @@ @dataclasses.dataclass(frozen=True) class ScoringContext: - - """Everything a scoring factor may read about one candidate. - - ``beat``, ``position``, and ``contour_target`` are optional threading - from the caller — ``None`` when the context does not apply (a factor - that needs them returns 1.0 without them). - - Attributes: - candidate: The candidate MIDI pitch. - history: Recent chosen pitches, oldest first (capped at 4). - chord_tone_pcs: Pitch classes of the current chord tones (empty - set when no chord context). - tonic_pc: The key's tonic pitch class (Rule C's landing point). - low / high: The register bounds of the pitch pool. - beat: The beat the note will sound on, within its pattern cycle. - position: Normalised 0–1 position through a generated span. - contour_target: Normalised 0–1 target height at *position* (the - contour envelope's value). - """ - - candidate: int - history: typing.Tuple[int, ...] - chord_tone_pcs: typing.FrozenSet[int] - tonic_pc: int - low: int - high: int - beat: typing.Optional[float] = None - position: typing.Optional[float] = None - contour_target: typing.Optional[float] = None + """Everything a scoring factor may read about one candidate. + + ``beat``, ``position``, and ``contour_target`` are optional threading + from the caller — ``None`` when the context does not apply (a factor + that needs them returns 1.0 without them). + + Attributes: + candidate: The candidate MIDI pitch. + history: Recent chosen pitches, oldest first (capped at 4). + chord_tone_pcs: Pitch classes of the current chord tones (empty + set when no chord context). + tonic_pc: The key's tonic pitch class (Rule C's landing point). + low / high: The register bounds of the pitch pool. + beat: The beat the note will sound on, within its pattern cycle. + position: Normalised 0–1 position through a generated span. + contour_target: Normalised 0–1 target height at *position* (the + contour envelope's value). + """ + + candidate: int + history: typing.Tuple[int, ...] + chord_tone_pcs: typing.FrozenSet[int] + tonic_pc: int + low: int + high: int + beat: typing.Optional[float] = None + position: typing.Optional[float] = None + contour_target: typing.Optional[float] = None # A scoring factor: reads the state's dials and one candidate's context, @@ -67,417 +66,403 @@ class ScoringContext: ScoringFactor = typing.Callable[["MelodicState", ScoringContext], float] -def nir_factor (state: "MelodicState", ctx: ScoringContext) -> float: - - """Narmour expectation: reversal after leaps, continuation after steps, - closure on the tonic, preference for proximity — scaled by ``nir_strength``.""" - - if not ctx.history: - return 1.0 - - last_note = ctx.history[-1] - - target_diff = ctx.candidate - last_note - target_interval = abs(target_diff) - target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0 +def nir_factor(state: "MelodicState", ctx: ScoringContext) -> float: + """Narmour expectation: reversal after leaps, continuation after steps, + closure on the tonic, preference for proximity — scaled by ``nir_strength``.""" - boost = 0.0 + if not ctx.history: + return 1.0 - # Rules A & B require an Implication context (prev -> last -> candidate). - if len(ctx.history) >= 2: - prev_note = ctx.history[-2] + last_note = ctx.history[-1] - prev_diff = last_note - prev_note - prev_interval = abs(prev_diff) - prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0 + target_diff = ctx.candidate - last_note + target_interval = abs(target_diff) + target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0 - # Rule A: Reversal (gap fill) — after a large leap, expect direction change. - if prev_interval > 4: - if target_direction != prev_direction and target_direction != 0: - boost += 0.5 + boost = 0.0 - if target_interval < 4: - boost += 0.3 + # Rules A & B require an Implication context (prev -> last -> candidate). + if len(ctx.history) >= 2: + prev_note = ctx.history[-2] - # Rule B: Process (continuation) — after a small step, expect more of the same. - elif 0 < prev_interval < 3: - if target_direction == prev_direction: - boost += 0.4 + prev_diff = last_note - prev_note + prev_interval = abs(prev_diff) + prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0 - if abs(target_interval - prev_interval) <= 1: - boost += 0.2 + # Rule A: Reversal (gap fill) — after a large leap, expect direction change. + if prev_interval > 4: + if target_direction != prev_direction and target_direction != 0: + boost += 0.5 - # Rule C: Closure — the tonic is a cognitively stable landing point. - if ctx.candidate % 12 == ctx.tonic_pc: - boost += 0.2 + if target_interval < 4: + boost += 0.3 - # Rule D: Proximity — smaller intervals are generally preferred. - if 0 < target_interval <= 3: - boost += 0.3 + # Rule B: Process (continuation) — after a small step, expect more of the same. + elif 0 < prev_interval < 3: + if target_direction == prev_direction: + boost += 0.4 - return 1.0 + boost * state.nir_strength + if abs(target_interval - prev_interval) <= 1: + boost += 0.2 + # Rule C: Closure — the tonic is a cognitively stable landing point. + if ctx.candidate % 12 == ctx.tonic_pc: + boost += 0.2 -def chord_tone_factor (state: "MelodicState", ctx: ScoringContext) -> float: + # Rule D: Proximity — smaller intervals are generally preferred. + if 0 < target_interval <= 3: + boost += 0.3 - """Boost candidates whose pitch class belongs to the current chord.""" + return 1.0 + boost * state.nir_strength - if ctx.chord_tone_pcs and ctx.candidate % 12 in ctx.chord_tone_pcs: - return 1.0 + state.chord_weight - return 1.0 +def chord_tone_factor(state: "MelodicState", ctx: ScoringContext) -> float: + """Boost candidates whose pitch class belongs to the current chord.""" + if ctx.chord_tone_pcs and ctx.candidate % 12 in ctx.chord_tone_pcs: + return 1.0 + state.chord_weight -def range_gravity_factor (state: "MelodicState", ctx: ScoringContext) -> float: + return 1.0 - """Penalise notes far from the centre of the register (quadratic).""" - centre = (ctx.low + ctx.high) / 2.0 - half_range = max(1.0, (ctx.high - ctx.low) / 2.0) - distance_ratio = abs(ctx.candidate - centre) / half_range +def range_gravity_factor(state: "MelodicState", ctx: ScoringContext) -> float: + """Penalise notes far from the centre of the register (quadratic).""" - return 1.0 - 0.3 * (distance_ratio ** 2) + centre = (ctx.low + ctx.high) / 2.0 + half_range = max(1.0, (ctx.high - ctx.low) / 2.0) + distance_ratio = abs(ctx.candidate - centre) / half_range + return 1.0 - 0.3 * (distance_ratio**2) -def diversity_factor (state: "MelodicState", ctx: ScoringContext) -> float: - """Exponential penalty for recently-heard pitches.""" +def diversity_factor(state: "MelodicState", ctx: ScoringContext) -> float: + """Exponential penalty for recently-heard pitches.""" - recent_occurrences = sum(1 for h in ctx.history if h == ctx.candidate) + recent_occurrences = sum(1 for h in ctx.history if h == ctx.candidate) - return state.pitch_diversity ** recent_occurrences + return state.pitch_diversity**recent_occurrences -def contour_factor (state: "MelodicState", ctx: ScoringContext) -> float: +def contour_factor(state: "MelodicState", ctx: ScoringContext) -> float: + """Pull candidates toward the contour envelope's target height. - """Pull candidates toward the contour envelope's target height. + Active only when the caller threads ``position``/``contour_target`` + (the generate path); a melodic walk without an envelope is unshaped. + """ - Active only when the caller threads ``position``/``contour_target`` - (the generate path); a melodic walk without an envelope is unshaped. - """ + if ctx.contour_target is None or ctx.high <= ctx.low: + return 1.0 - if ctx.contour_target is None or ctx.high <= ctx.low: - return 1.0 + height = (ctx.candidate - ctx.low) / (ctx.high - ctx.low) - height = (ctx.candidate - ctx.low) / (ctx.high - ctx.low) + # Cubic falloff: strong enough to be heard as a shape, soft enough that + # NIR/diversity still pick the path along it. + return max(0.04, (1.0 - abs(height - ctx.contour_target)) ** 3) - # Cubic falloff: strong enough to be heard as a shape, soft enough that - # NIR/diversity still pick the path along it. - return max(0.04, (1.0 - abs(height - ctx.contour_target)) ** 3) +def tessitura_factor(state: "MelodicState", ctx: ScoringContext) -> float: + """Regression toward the tessitura — von Hippel's reading of post-skip reversal. -def tessitura_factor (state: "MelodicState", ctx: ScoringContext) -> float: + The further the line has strayed from the register's centre, the more + candidates that move back toward it are boosted. Off by default + (``tessitura_strength=0``); the generate path turns it on, where it + buys gap-fill and post-skip reversal without hard rules. + """ - """Regression toward the tessitura — von Hippel's reading of post-skip reversal. + if state.tessitura_strength <= 0 or not ctx.history: + return 1.0 - The further the line has strayed from the register's centre, the more - candidates that move back toward it are boosted. Off by default - (``tessitura_strength=0``); the generate path turns it on, where it - buys gap-fill and post-skip reversal without hard rules. - """ + centre = (ctx.low + ctx.high) / 2.0 + half_range = max(1.0, (ctx.high - ctx.low) / 2.0) + displacement = (ctx.history[-1] - centre) / half_range - if state.tessitura_strength <= 0 or not ctx.history: - return 1.0 + if abs(displacement) < 1e-9: + return 1.0 - centre = (ctx.low + ctx.high) / 2.0 - half_range = max(1.0, (ctx.high - ctx.low) / 2.0) - displacement = (ctx.history[-1] - centre) / half_range + moves_home = (ctx.candidate - ctx.history[-1]) * (centre - ctx.history[-1]) > 0 - if abs(displacement) < 1e-9: - return 1.0 + if moves_home: + return 1.0 + state.tessitura_strength * min(1.0, abs(displacement)) * 0.6 - moves_home = (ctx.candidate - ctx.history[-1]) * (centre - ctx.history[-1]) > 0 - - if moves_home: - return 1.0 + state.tessitura_strength * min(1.0, abs(displacement)) * 0.6 - - return 1.0 + return 1.0 DEFAULT_FACTORS: typing.Tuple[ScoringFactor, ...] = ( - nir_factor, - chord_tone_factor, - range_gravity_factor, - diversity_factor, - contour_factor, - tessitura_factor, + nir_factor, + chord_tone_factor, + range_gravity_factor, + diversity_factor, + contour_factor, + tessitura_factor, ) class MelodicState: - - """Persistent melodic context that applies NIR scoring to single-note lines.""" - - - def __init__ ( - self, - key: typing.Optional[str] = None, - mode: typing.Optional[str] = None, - low: int = 48, - high: int = 72, - nir_strength: float = 0.5, - chord_weight: float = 0.4, - rest_probability: float = 0.0, - pitch_diversity: float = 0.6, - tessitura_strength: float = 0.0, - ) -> None: - - """Initialise a melodic state for a given key, mode, and MIDI register. - - Parameters: - key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``). - When omitted, the state adopts the **composition's** key the - first time ``p.melody()`` uses it (falling back to ``"C"``). - mode: Scale mode name. Accepts any mode registered with - :func:`~subsequence.intervals.scale_pitch_classes` (e.g. - ``"ionian"``, ``"aeolian"``, ``"dorian"``). When omitted, - adopts the composition's scale (falling back to ``"ionian"``). - low: Lowest MIDI note (inclusive) in the pitch pool. - high: Highest MIDI note (inclusive) in the pitch pool. - nir_strength: 0.0–1.0. Scales how strongly the NIR rules - influence candidate scores. 0.0 = uniform; 1.0 = full boost. - chord_weight: 0.0–1.0. Additive multiplier bonus for candidates - whose pitch class belongs to the current chord tones. - rest_probability: 0.0–1.0. Probability of producing a rest - (returning ``None``) at any given step. - pitch_diversity: 0.0–1.0. Exponential penalty per recent - repetition of the same pitch. Lower values discourage - repetition more aggressively. - tessitura_strength: 0.0–1.0. Regression pull toward the centre - of the register after the line strays (off by default; the - generate path enables it). - """ - - if nir_strength < 0 or nir_strength > 1: - raise ValueError("NIR strength must be between 0 and 1") - - if rest_probability < 0 or rest_probability > 1: - raise ValueError("Rest probability must be between 0 and 1") - - if pitch_diversity < 0 or pitch_diversity > 1: - raise ValueError("Pitch diversity must be between 0 and 1") - - if chord_weight < 0 or chord_weight > 1: - raise ValueError("Chord weight must be between 0 and 1") - - if tessitura_strength < 0 or tessitura_strength > 1: - raise ValueError("Tessitura strength must be between 0 and 1") - - if low >= high: - raise ValueError("low must be below high") - - # None defers to the composition (configure_defaults); the fallbacks - # keep a bare MelodicState() working standalone. - self._explicit_key = key is not None - self._explicit_mode = mode is not None - self._explicit_pool = False - - self.key = key if key is not None else "C" - self.mode = mode if mode is not None else "ionian" - self.low = low - self.high = high - self.nir_strength = nir_strength - self.chord_weight = chord_weight - self.rest_probability = rest_probability - self.pitch_diversity = pitch_diversity - self.tessitura_strength = tessitura_strength - - # The soft side of the CHORAL separation — replace or extend freely. - self.factors: typing.List[ScoringFactor] = list(DEFAULT_FACTORS) - - self._rebuild_pool() - - # History of last N absolute MIDI pitches (capped at 4, same as HarmonicState). - self.history: typing.List[int] = [] - - - def _rebuild_pool (self) -> None: - - """Derive the pitch pool (the one hard constraint) from key/mode/register.""" - - self._tonic_pc: int = subsequence.chords.key_name_to_pc(self.key) - - self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes( - self.key, self.mode, low=self.low, high=self.high - ) - - - def configure_defaults (self, key: typing.Optional[str], mode: typing.Optional[str]) -> None: - - """Adopt the surrounding key/scale where this state left them unset. - - Called by ``p.melody()`` every build. It **tracks** the builder's - current key/scale (which is the section's effective key under a form), - so a state placed across sections follows each section's key — its - melodic *history* is untouched, only the pitch pool and tonic move. - An explicit constructor key/scale or an explicit pool always wins and - is never overridden. - """ - - if self._explicit_pool: - return - - changed = False - - # Re-track on every call (not just the first): a persistent state used - # across sections must follow the live key, or the first section to - # place it would freeze the key forever. - if not self._explicit_key and key is not None and key != self.key: - self.key = key - changed = True - - if not self._explicit_mode and mode is not None and mode != self.mode: - self.mode = mode - changed = True - - if changed: - self._rebuild_pool() - - - def set_pool (self, pitches: typing.Sequence[int]) -> None: - - """Replace the pitch pool with explicit MIDI pitches — the experimental seam. - - Admits sieve output, non-octave organisations, or any hand-picked - pool; key/mode no longer constrain candidates (the tonic pitch - class, for Rule C, stays the key's). - """ - - pool = sorted(int(p) for p in pitches) - - if not pool: - raise ValueError("set_pool() needs at least one pitch") - - self._pitch_pool = pool - self._explicit_pool = True - self.low = pool[0] - self.high = max(pool[-1], pool[0] + 1) - - - def clone (self) -> "MelodicState": - - """An independent copy — settings, factors, pool, and history. - - Value constructors (``Motif.generate``) copy the state they are - given and walk the copy, so a module-level live state is never - mutated by building a value. - """ - - duplicate = MelodicState( - key = self.key if self._explicit_key else None, - mode = self.mode if self._explicit_mode else None, - low = self.low, - high = self.high, - nir_strength = self.nir_strength, - chord_weight = self.chord_weight, - rest_probability = self.rest_probability, - pitch_diversity = self.pitch_diversity, - tessitura_strength = self.tessitura_strength, - ) - - duplicate.key = self.key - duplicate.mode = self.mode - duplicate._rebuild_pool() - - if self._explicit_pool: - duplicate.set_pool(self._pitch_pool) - - duplicate.factors = list(self.factors) - duplicate.history = list(self.history) - - return duplicate - - - def choose_next ( - self, - chord_tones: typing.Optional[typing.List[int]], - rng: random.Random, - beat: typing.Optional[float] = None, - position: typing.Optional[float] = None, - contour_target: typing.Optional[float] = None, - ) -> typing.Optional[int]: - - """Score all pitch-pool candidates and return the chosen pitch, or None for a rest. - - ``beat`` (the note's beat within its cycle), ``position`` (0–1 - through a generated span), and ``contour_target`` (the envelope's - height there) thread caller context into the scoring factors. - """ - - if self.rest_probability > 0.0 and rng.random() < self.rest_probability: - return None - - if not self._pitch_pool: - return None - - # Resolve chord tones to pitch classes for fast membership testing. - chord_tone_pcs = {t % 12 for t in chord_tones} if chord_tones else set() - - scores = [ - self._score_candidate(candidate, chord_tone_pcs, beat=beat, position=position, contour_target=contour_target) - for candidate in self._pitch_pool - ] - - # Weighted random choice: select using cumulative score as a probability weight. - total = sum(scores) - - if total <= 0.0: - chosen = rng.choice(self._pitch_pool) - - else: - r = rng.uniform(0.0, total) - cumulative = 0.0 - chosen = self._pitch_pool[-1] - - for pitch, score in zip(self._pitch_pool, scores): - cumulative += score - if r <= cumulative: - chosen = pitch - break - - self.record(chosen) - - return chosen - - - def _score_candidate ( - self, - candidate: int, - chord_tone_pcs: typing.Set[int], - beat: typing.Optional[float] = None, - position: typing.Optional[float] = None, - contour_target: typing.Optional[float] = None, - ) -> float: - - """Score one candidate: the product of every factor in :attr:`factors`.""" - - ctx = ScoringContext( - candidate = candidate, - history = tuple(self.history), - chord_tone_pcs = frozenset(chord_tone_pcs), - tonic_pc = self._tonic_pc, - low = self.low, - high = self.high, - beat = beat, - position = position, - contour_target = contour_target, - ) - - score = 1.0 - - for factor in self.factors: - score *= factor(self, ctx) - - return max(0.0, score) - - def record (self, pitch: int) -> None: - - """Append a pitch to the melodic history (capped at 4 entries). - - Public so pinned notes — chosen by fiat, not by the walk — still - enter the NIR context. - """ - - self.history.append(pitch) - if len(self.history) > 4: - self.history.pop(0) + """Persistent melodic context that applies NIR scoring to single-note lines.""" + + def __init__( + self, + key: typing.Optional[str] = None, + mode: typing.Optional[str] = None, + low: int = 48, + high: int = 72, + nir_strength: float = 0.5, + chord_weight: float = 0.4, + rest_probability: float = 0.0, + pitch_diversity: float = 0.6, + tessitura_strength: float = 0.0, + ) -> None: + """Initialise a melodic state for a given key, mode, and MIDI register. + + Parameters: + key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``). + When omitted, the state adopts the **composition's** key the + first time ``p.melody()`` uses it (falling back to ``"C"``). + mode: Scale mode name. Accepts any mode registered with + :func:`~subsequence.intervals.scale_pitch_classes` (e.g. + ``"ionian"``, ``"aeolian"``, ``"dorian"``). When omitted, + adopts the composition's scale (falling back to ``"ionian"``). + low: Lowest MIDI note (inclusive) in the pitch pool. + high: Highest MIDI note (inclusive) in the pitch pool. + nir_strength: 0.0–1.0. Scales how strongly the NIR rules + influence candidate scores. 0.0 = uniform; 1.0 = full boost. + chord_weight: 0.0–1.0. Additive multiplier bonus for candidates + whose pitch class belongs to the current chord tones. + rest_probability: 0.0–1.0. Probability of producing a rest + (returning ``None``) at any given step. + pitch_diversity: 0.0–1.0. Exponential penalty per recent + repetition of the same pitch. Lower values discourage + repetition more aggressively. + tessitura_strength: 0.0–1.0. Regression pull toward the centre + of the register after the line strays (off by default; the + generate path enables it). + """ + + if nir_strength < 0 or nir_strength > 1: + raise ValueError("NIR strength must be between 0 and 1") + + if rest_probability < 0 or rest_probability > 1: + raise ValueError("Rest probability must be between 0 and 1") + + if pitch_diversity < 0 or pitch_diversity > 1: + raise ValueError("Pitch diversity must be between 0 and 1") + + if chord_weight < 0 or chord_weight > 1: + raise ValueError("Chord weight must be between 0 and 1") + + if tessitura_strength < 0 or tessitura_strength > 1: + raise ValueError("Tessitura strength must be between 0 and 1") + + if low >= high: + raise ValueError("low must be below high") + + # None defers to the composition (configure_defaults); the fallbacks + # keep a bare MelodicState() working standalone. + self._explicit_key = key is not None + self._explicit_mode = mode is not None + self._explicit_pool = False + + self.key = key if key is not None else "C" + self.mode = mode if mode is not None else "ionian" + self.low = low + self.high = high + self.nir_strength = nir_strength + self.chord_weight = chord_weight + self.rest_probability = rest_probability + self.pitch_diversity = pitch_diversity + self.tessitura_strength = tessitura_strength + + # The soft side of the CHORAL separation — replace or extend freely. + self.factors: typing.List[ScoringFactor] = list(DEFAULT_FACTORS) + + self._rebuild_pool() + + # History of last N absolute MIDI pitches (capped at 4, same as HarmonicState). + self.history: typing.List[int] = [] + + def _rebuild_pool(self) -> None: + """Derive the pitch pool (the one hard constraint) from key/mode/register.""" + + self._tonic_pc: int = subsequence.chords.key_name_to_pc(self.key) + + self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes( + self.key, self.mode, low=self.low, high=self.high + ) + + def configure_defaults( + self, key: typing.Optional[str], mode: typing.Optional[str] + ) -> None: + """Adopt the surrounding key/scale where this state left them unset. + + Called by ``p.melody()`` every build. It **tracks** the builder's + current key/scale (which is the section's effective key under a form), + so a state placed across sections follows each section's key — its + melodic *history* is untouched, only the pitch pool and tonic move. + An explicit constructor key/scale or an explicit pool always wins and + is never overridden. + """ + + if self._explicit_pool: + return + + changed = False + + # Re-track on every call (not just the first): a persistent state used + # across sections must follow the live key, or the first section to + # place it would freeze the key forever. + if not self._explicit_key and key is not None and key != self.key: + self.key = key + changed = True + + if not self._explicit_mode and mode is not None and mode != self.mode: + self.mode = mode + changed = True + + if changed: + self._rebuild_pool() + + def set_pool(self, pitches: typing.Sequence[int]) -> None: + """Replace the pitch pool with explicit MIDI pitches — the experimental seam. + + Admits sieve output, non-octave organisations, or any hand-picked + pool; key/mode no longer constrain candidates (the tonic pitch + class, for Rule C, stays the key's). + """ + + pool = sorted(int(p) for p in pitches) + + if not pool: + raise ValueError("set_pool() needs at least one pitch") + + self._pitch_pool = pool + self._explicit_pool = True + self.low = pool[0] + self.high = max(pool[-1], pool[0] + 1) + + def clone(self) -> "MelodicState": + """An independent copy — settings, factors, pool, and history. + + Value constructors (``Motif.generate``) copy the state they are + given and walk the copy, so a module-level live state is never + mutated by building a value. + """ + + duplicate = MelodicState( + key=self.key if self._explicit_key else None, + mode=self.mode if self._explicit_mode else None, + low=self.low, + high=self.high, + nir_strength=self.nir_strength, + chord_weight=self.chord_weight, + rest_probability=self.rest_probability, + pitch_diversity=self.pitch_diversity, + tessitura_strength=self.tessitura_strength, + ) + + duplicate.key = self.key + duplicate.mode = self.mode + duplicate._rebuild_pool() + + if self._explicit_pool: + duplicate.set_pool(self._pitch_pool) + + duplicate.factors = list(self.factors) + duplicate.history = list(self.history) + + return duplicate + + def choose_next( + self, + chord_tones: typing.Optional[typing.List[int]], + rng: random.Random, + beat: typing.Optional[float] = None, + position: typing.Optional[float] = None, + contour_target: typing.Optional[float] = None, + ) -> typing.Optional[int]: + """Score all pitch-pool candidates and return the chosen pitch, or None for a rest. + + ``beat`` (the note's beat within its cycle), ``position`` (0–1 + through a generated span), and ``contour_target`` (the envelope's + height there) thread caller context into the scoring factors. + """ + + if self.rest_probability > 0.0 and rng.random() < self.rest_probability: + return None + + if not self._pitch_pool: + return None + + # Resolve chord tones to pitch classes for fast membership testing. + chord_tone_pcs = {t % 12 for t in chord_tones} if chord_tones else set() + + scores = [ + self._score_candidate( + candidate, + chord_tone_pcs, + beat=beat, + position=position, + contour_target=contour_target, + ) + for candidate in self._pitch_pool + ] + + # Weighted random choice: select using cumulative score as a probability weight. + total = sum(scores) + + if total <= 0.0: + chosen = rng.choice(self._pitch_pool) + + else: + r = rng.uniform(0.0, total) + cumulative = 0.0 + chosen = self._pitch_pool[-1] + + for pitch, score in zip(self._pitch_pool, scores): + cumulative += score + if r <= cumulative: + chosen = pitch + break + + self.record(chosen) + + return chosen + + def _score_candidate( + self, + candidate: int, + chord_tone_pcs: typing.Set[int], + beat: typing.Optional[float] = None, + position: typing.Optional[float] = None, + contour_target: typing.Optional[float] = None, + ) -> float: + """Score one candidate: the product of every factor in :attr:`factors`.""" + + ctx = ScoringContext( + candidate=candidate, + history=tuple(self.history), + chord_tone_pcs=frozenset(chord_tone_pcs), + tonic_pc=self._tonic_pc, + low=self.low, + high=self.high, + beat=beat, + position=position, + contour_target=contour_target, + ) + + score = 1.0 + + for factor in self.factors: + score *= factor(self, ctx) + + return max(0.0, score) + + def record(self, pitch: int) -> None: + """Append a pitch to the melodic history (capped at 4 entries). + + Public so pinned notes — chosen by fiat, not by the walk — still + enter the NIR context. + """ + + self.history.append(pitch) + if len(self.history) > 4: + self.history.pop(0) diff --git a/subsequence/midi.py b/subsequence/midi.py index 05dcf28..3b26884 100644 --- a/subsequence/midi.py +++ b/subsequence/midi.py @@ -19,79 +19,76 @@ import mido -def cc ( - control: int, - value: int, - channel: int = 0, +def cc( + control: int, + value: int, + channel: int = 0, ) -> mido.Message: + """Create a MIDI Control Change message. - """Create a MIDI Control Change message. + Parameters: + control: CC number (0–127). + value: CC value (0–127). + channel: MIDI channel (0-indexed, 0–15). Defaults to 0. - Parameters: - control: CC number (0–127). - value: CC value (0–127). - channel: MIDI channel (0-indexed, 0–15). Defaults to 0. + Returns: + A ``mido.Message`` of type ``control_change``. - Returns: - A ``mido.Message`` of type ``control_change``. + Example: + ```python + import subsequence.midi as midi - Example: - ```python - import subsequence.midi as midi + # Forward CC 1 to CC 74, scaling range to 40–100 + composition.cc_forward(1, + lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch) + ) + ``` + """ - # Forward CC 1 to CC 74, scaling range to 40–100 - composition.cc_forward(1, - lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch) - ) - ``` - """ + return mido.Message("control_change", channel=channel, control=control, value=value) - return mido.Message('control_change', channel=channel, control=control, value=value) - -def pitchwheel ( - pitch: int, - channel: int = 0, +def pitchwheel( + pitch: int, + channel: int = 0, ) -> mido.Message: + """Create a MIDI Pitch Wheel message. - """Create a MIDI Pitch Wheel message. - - Parameters: - pitch: Pitch bend value (-8192 to 8191). 0 is centre (no bend). - Out-of-range values are clamped to the valid range. - channel: MIDI channel (0-indexed, 0–15). Defaults to 0. + Parameters: + pitch: Pitch bend value (-8192 to 8191). 0 is centre (no bend). + Out-of-range values are clamped to the valid range. + channel: MIDI channel (0-indexed, 0–15). Defaults to 0. - Returns: - A ``mido.Message`` of type ``pitchwheel``. + Returns: + A ``mido.Message`` of type ``pitchwheel``. - Example: - ```python - import subsequence.midi as midi + Example: + ```python + import subsequence.midi as midi - # Forward CC 1 as pitch bend, scaled to upper half only (0 to +8191) - composition.cc_forward(1, - lambda v, ch: midi.pitchwheel(int(v / 127 * 8191), channel=ch) - ) - ``` - """ + # Forward CC 1 as pitch bend, scaled to upper half only (0 to +8191) + composition.cc_forward(1, + lambda v, ch: midi.pitchwheel(int(v / 127 * 8191), channel=ch) + ) + ``` + """ - pitch = max(-8192, min(8191, pitch)) - return mido.Message('pitchwheel', channel=channel, pitch=pitch) + pitch = max(-8192, min(8191, pitch)) + return mido.Message("pitchwheel", channel=channel, pitch=pitch) -def program_change ( - program: int, - channel: int = 0, +def program_change( + program: int, + channel: int = 0, ) -> mido.Message: + """Create a MIDI Program Change message. - """Create a MIDI Program Change message. - - Parameters: - program: Program number (0–127). - channel: MIDI channel (0-indexed, 0–15). Defaults to 0. + Parameters: + program: Program number (0–127). + channel: MIDI channel (0-indexed, 0–15). Defaults to 0. - Returns: - A ``mido.Message`` of type ``program_change``. - """ + Returns: + A ``mido.Message`` of type ``program_change``. + """ - return mido.Message('program_change', channel=channel, program=program) + return mido.Message("program_change", channel=channel, program=program) diff --git a/subsequence/midi_utils.py b/subsequence/midi_utils.py index 8f99039..74d8c85 100644 --- a/subsequence/midi_utils.py +++ b/subsequence/midi_utils.py @@ -18,312 +18,305 @@ class MidiDeviceRegistry: - - """Ordered registry of named MIDI ports (output or input). - - Devices are stored in insertion order. Index 0 is always the first - (or only) device — the default for all APIs that do not specify a device. - Devices can be looked up by integer index or by name string. - ``None`` always resolves to index 0. - - The registry is intended to be append-only once playback has started. - All registered port objects must already be open. - """ - - def __init__ (self) -> None: - - """ - Create an empty registry; populate it with add(). - """ - - self._ports: typing.List[typing.Tuple[str, typing.Any]] = [] - self._name_to_index: typing.Dict[str, int] = {} - # Per-device physical output latency in milliseconds, parallel to - # self._ports by index. Kept separate from the (name, port) tuple so - # replace() can swap the port object without disturbing latency. - self._latencies: typing.List[float] = [] - - def add (self, name: str, port: typing.Any, latency_ms: float = 0.0) -> int: - - """Register a port under *name*. Returns the assigned integer index. - - *latency_ms* is the device's physical output latency (non-negative); - see :meth:`set_latency`. - """ - - idx = len(self._ports) - self._ports.append((name, port)) - self._latencies.append(max(0.0, float(latency_ms))) - # First registration wins for name collisions. - if name not in self._name_to_index: - self._name_to_index[name] = idx - return idx - - def get (self, device: DeviceId = None) -> typing.Optional[typing.Any]: - - """Return the port for *device*, or ``None`` if the registry is empty. - - ``None`` → index 0. ``int`` → direct index. ``str`` → name lookup. - Returns ``None`` if the device cannot be resolved (empty registry, - out-of-range index, unknown name). - """ - - if not self._ports: - return None - idx = self.index_of(device) - if idx < 0 or idx >= len(self._ports): - return None - return self._ports[idx][1] - - def index_of (self, device: DeviceId = None) -> int: - - """Resolve *device* to an integer index. Returns 0 for ``None``. - Returns -1 if the name is unknown or the index is out of range.""" - - if device is None: - return 0 - if isinstance(device, int): - if 0 <= device < len(self._ports): - return device - return -1 - # str - return self._name_to_index.get(device, -1) - - def replace (self, index: int, port: typing.Any) -> None: - - """Replace the port object at *index* without changing the name or index mapping. - - Used by the backward-compat ``midi_out``/``midi_in`` setters to allow - test code to inject a fake port after the registry has been populated. - Raises ``IndexError`` if *index* is out of range. - """ - - if index < 0 or index >= len(self._ports): - raise IndexError(f"MidiDeviceRegistry: index {index} out of range (size {len(self._ports)})") - name = self._ports[index][0] - self._ports[index] = (name, port) - # Latency is intentionally preserved — replace() is a pure port swap. - - def set_latency (self, device: DeviceId, latency_ms: float) -> None: - - """Set the physical output latency (milliseconds) for *device*. - - *latency_ms* must be non-negative — a device cannot sound before it is - triggered, so a negative output latency is meaningless. Raises - ``ValueError`` for a negative value or an unknown device. - """ - - if latency_ms < 0: - raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") - idx = self.index_of(device) - if idx < 0: - raise ValueError(f"Unknown output device: {device!r}") - self._latencies[idx] = float(latency_ms) - - def latency_of (self, device: DeviceId = None) -> float: - - """Return the latency (ms) for *device*, or 0.0 if it cannot be resolved. - - Defensive on the hot dispatch path: an unknown device yields 0.0 rather - than raising, so a stray event can never crash the send loop. - """ - - idx = self.index_of(device) - if idx < 0 or idx >= len(self._latencies): - return 0.0 - return self._latencies[idx] - - def max_latency (self) -> float: - - """Return the largest latency across all registered devices (0.0 if empty).""" - - return max(self._latencies, default=0.0) - - def close_all (self) -> None: - - """Close every registered port and clear the registry.""" - - for name, port in self._ports: - try: - port.close() - except (OSError, RuntimeError, AttributeError): - # Shutdown path: a failure on one port must not prevent closing the rest. - logger.exception(f"Error closing MIDI port '{name}'") - self._ports.clear() - self._name_to_index.clear() - self._latencies.clear() - - def __len__ (self) -> int: - - """ - Number of registered devices. - """ - - return len(self._ports) - - def __iter__ (self) -> typing.Iterator[typing.Any]: - """Iterate over port objects (not names).""" - return (port for _, port in self._ports) - - def __bool__ (self) -> bool: - - """ - True if at least one device is registered. - """ - - return bool(self._ports) - - -def bank_select (bank: int) -> typing.Tuple[int, int]: - - """ - Convert a 14-bit MIDI bank number to (MSB, LSB) for use with - ``p.program_change()``. - - MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and - CC 32 (Bank LSB). Together they encode a 14-bit bank number in the - range 0–16,383: - - MSB = bank // 128 (upper 7 bits, sent on CC 0) - LSB = bank % 128 (lower 7 bits, sent on CC 32) - - Args: - bank: Integer bank number, 0–16,383. Values outside this range are - clamped. - - Returns: - ``(msb, lsb)`` tuple, each value in 0–127. - - Example: - ```python - msb, lsb = subsequence.bank_select(128) # → (1, 0) - p.program_change(48, bank_msb=msb, bank_lsb=lsb) - ``` - """ - - bank = max(0, min(16383, bank)) - return bank >> 7, bank & 0x7F - -def select_output_device (device_name: typing.Optional[str] = None) -> typing.Tuple[typing.Optional[str], typing.Optional[typing.Any]]: - - """ - Select and open a MIDI output device. - - If ``device_name`` is provided, attempts to open that specific device. - If ``device_name`` is None, auto-discovers available devices: - - - If exactly one device exists, it is selected automatically. - - If multiple devices exist, prompts the user to choose one from the console. - - If no devices exist, logs an error and returns None. - - Returns: - A tuple of (device_name, midi_out_object) or (None, None) on failure. - """ - - try: - outputs = mido.get_output_names() - logger.info(f"Available MIDI outputs: {outputs}") - - if not outputs: - logger.error("No MIDI output devices found.") - return None, None - - # Explicit device requested - if device_name is not None: - if device_name in outputs: - midi_out = mido.open_output(device_name) - logger.info(f"Opened MIDI output: {device_name}") - return device_name, midi_out - else: - logger.error( - f"MIDI output device '{device_name}' not found. " - f"Available devices: {outputs}" - ) - return None, None - - # Auto-discover: one device - use it - if len(outputs) == 1: - selected_name = outputs[0] - midi_out = mido.open_output(selected_name) - logger.info(f"One MIDI output found - using '{selected_name}'") - return selected_name, midi_out - - # Auto-discover: multiple devices - prompt user - print("\nAvailable MIDI output devices:\n") - for i, name in enumerate(outputs, 1): - print(f" {i}. {name}") - print() - - while True: - try: - choice = int(input(f"Select a device (1-{len(outputs)}): ")) - if 1 <= choice <= len(outputs): - break - except ValueError: - pass - except EOFError: - # stdin is closed (headless/redirected run) — the prompt can - # never be answered, so retrying would spin forever. - raise RuntimeError( - "No interactive terminal to choose between multiple MIDI outputs — " - f"pass output_device= with one of: {outputs}" - ) from None - print(f"Enter a number between 1 and {len(outputs)}.") - - selected_name = outputs[choice - 1] - midi_out = mido.open_output(selected_name) - logger.info(f"Opened MIDI output: {selected_name}") - - print(f"\nTip: To skip this prompt, pass the device name directly:\n") - print(f" Sequencer(output_device_name=\"{selected_name}\")") - print(f" Composition(output_device=\"{selected_name}\")\n") - - return selected_name, midi_out - - except (OSError, RuntimeError) as e: - logger.error(f"Failed to open MIDI output: {e}") - return None, None - - -def select_input_device (device_name: typing.Optional[str] = None, callback: typing.Optional[typing.Callable] = None) -> typing.Tuple[typing.Optional[str], typing.Optional[typing.Any]]: - - """ - Select and open a MIDI input device. - - If ``device_name`` is provided, attempts to open exactly that device. - If ``device_name`` is None, returns None without prompting (input is optional/advanced). - To enforce input, the caller should check the return value. - - A named device that is not present raises ValueError rather than falling - back to another input: MIDI input drives clock-follow and live note - capture, so silently listening to the wrong device would desynchronise - or mis-record a performance. - - Returns: - A tuple of (device_name, midi_in_object), or (None, None) when no - name was given or the device failed to open. - - Raises: - ValueError: If *device_name* is not among the available inputs. - """ - - if device_name is None: - return None, None - - try: - inputs = mido.get_input_names() - logger.info(f"Available MIDI inputs: {inputs}") - - if device_name not in inputs: - raise ValueError( - f"MIDI input device '{device_name}' not found. " - f"Available devices: {inputs}" - ) - - midi_in = mido.open_input(device_name, callback=callback) - logger.info(f"Opened MIDI input: {device_name}") - return device_name, midi_in - - except (OSError, RuntimeError) as e: - logger.error(f"Failed to open MIDI input: {e}") - return None, None + """Ordered registry of named MIDI ports (output or input). + + Devices are stored in insertion order. Index 0 is always the first + (or only) device — the default for all APIs that do not specify a device. + Devices can be looked up by integer index or by name string. + ``None`` always resolves to index 0. + + The registry is intended to be append-only once playback has started. + All registered port objects must already be open. + """ + + def __init__(self) -> None: + """ + Create an empty registry; populate it with add(). + """ + + self._ports: typing.List[typing.Tuple[str, typing.Any]] = [] + self._name_to_index: typing.Dict[str, int] = {} + # Per-device physical output latency in milliseconds, parallel to + # self._ports by index. Kept separate from the (name, port) tuple so + # replace() can swap the port object without disturbing latency. + self._latencies: typing.List[float] = [] + + def add(self, name: str, port: typing.Any, latency_ms: float = 0.0) -> int: + """Register a port under *name*. Returns the assigned integer index. + + *latency_ms* is the device's physical output latency (non-negative); + see :meth:`set_latency`. + """ + + idx = len(self._ports) + self._ports.append((name, port)) + self._latencies.append(max(0.0, float(latency_ms))) + # First registration wins for name collisions. + if name not in self._name_to_index: + self._name_to_index[name] = idx + return idx + + def get(self, device: DeviceId = None) -> typing.Optional[typing.Any]: + """Return the port for *device*, or ``None`` if the registry is empty. + + ``None`` → index 0. ``int`` → direct index. ``str`` → name lookup. + Returns ``None`` if the device cannot be resolved (empty registry, + out-of-range index, unknown name). + """ + + if not self._ports: + return None + idx = self.index_of(device) + if idx < 0 or idx >= len(self._ports): + return None + return self._ports[idx][1] + + def index_of(self, device: DeviceId = None) -> int: + """Resolve *device* to an integer index. Returns 0 for ``None``. + Returns -1 if the name is unknown or the index is out of range.""" + + if device is None: + return 0 + if isinstance(device, int): + if 0 <= device < len(self._ports): + return device + return -1 + # str + return self._name_to_index.get(device, -1) + + def replace(self, index: int, port: typing.Any) -> None: + """Replace the port object at *index* without changing the name or index mapping. + + Used by the backward-compat ``midi_out``/``midi_in`` setters to allow + test code to inject a fake port after the registry has been populated. + Raises ``IndexError`` if *index* is out of range. + """ + + if index < 0 or index >= len(self._ports): + raise IndexError( + f"MidiDeviceRegistry: index {index} out of range (size {len(self._ports)})" + ) + name = self._ports[index][0] + self._ports[index] = (name, port) + # Latency is intentionally preserved — replace() is a pure port swap. + + def set_latency(self, device: DeviceId, latency_ms: float) -> None: + """Set the physical output latency (milliseconds) for *device*. + + *latency_ms* must be non-negative — a device cannot sound before it is + triggered, so a negative output latency is meaningless. Raises + ``ValueError`` for a negative value or an unknown device. + """ + + if latency_ms < 0: + raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") + idx = self.index_of(device) + if idx < 0: + raise ValueError(f"Unknown output device: {device!r}") + self._latencies[idx] = float(latency_ms) + + def latency_of(self, device: DeviceId = None) -> float: + """Return the latency (ms) for *device*, or 0.0 if it cannot be resolved. + + Defensive on the hot dispatch path: an unknown device yields 0.0 rather + than raising, so a stray event can never crash the send loop. + """ + + idx = self.index_of(device) + if idx < 0 or idx >= len(self._latencies): + return 0.0 + return self._latencies[idx] + + def max_latency(self) -> float: + """Return the largest latency across all registered devices (0.0 if empty).""" + + return max(self._latencies, default=0.0) + + def close_all(self) -> None: + """Close every registered port and clear the registry.""" + + for name, port in self._ports: + try: + port.close() + except (OSError, RuntimeError, AttributeError): + # Shutdown path: a failure on one port must not prevent closing the rest. + logger.exception(f"Error closing MIDI port '{name}'") + self._ports.clear() + self._name_to_index.clear() + self._latencies.clear() + + def __len__(self) -> int: + """ + Number of registered devices. + """ + + return len(self._ports) + + def __iter__(self) -> typing.Iterator[typing.Any]: + """Iterate over port objects (not names).""" + return (port for _, port in self._ports) + + def __bool__(self) -> bool: + """ + True if at least one device is registered. + """ + + return bool(self._ports) + + +def bank_select(bank: int) -> typing.Tuple[int, int]: + """ + Convert a 14-bit MIDI bank number to (MSB, LSB) for use with + ``p.program_change()``. + + MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and + CC 32 (Bank LSB). Together they encode a 14-bit bank number in the + range 0–16,383: + + MSB = bank // 128 (upper 7 bits, sent on CC 0) + LSB = bank % 128 (lower 7 bits, sent on CC 32) + + Args: + bank: Integer bank number, 0–16,383. Values outside this range are + clamped. + + Returns: + ``(msb, lsb)`` tuple, each value in 0–127. + + Example: + ```python + msb, lsb = subsequence.bank_select(128) # → (1, 0) + p.program_change(48, bank_msb=msb, bank_lsb=lsb) + ``` + """ + + bank = max(0, min(16383, bank)) + return bank >> 7, bank & 0x7F + + +def select_output_device( + device_name: typing.Optional[str] = None, +) -> typing.Tuple[typing.Optional[str], typing.Optional[typing.Any]]: + """ + Select and open a MIDI output device. + + If ``device_name`` is provided, attempts to open that specific device. + If ``device_name`` is None, auto-discovers available devices: + + - If exactly one device exists, it is selected automatically. + - If multiple devices exist, prompts the user to choose one from the console. + - If no devices exist, logs an error and returns None. + + Returns: + A tuple of (device_name, midi_out_object) or (None, None) on failure. + """ + + try: + outputs = mido.get_output_names() + logger.info(f"Available MIDI outputs: {outputs}") + + if not outputs: + logger.error("No MIDI output devices found.") + return None, None + + # Explicit device requested + if device_name is not None: + if device_name in outputs: + midi_out = mido.open_output(device_name) + logger.info(f"Opened MIDI output: {device_name}") + return device_name, midi_out + else: + logger.error( + f"MIDI output device '{device_name}' not found. " + f"Available devices: {outputs}" + ) + return None, None + + # Auto-discover: one device - use it + if len(outputs) == 1: + selected_name = outputs[0] + midi_out = mido.open_output(selected_name) + logger.info(f"One MIDI output found - using '{selected_name}'") + return selected_name, midi_out + + # Auto-discover: multiple devices - prompt user + print("\nAvailable MIDI output devices:\n") + for i, name in enumerate(outputs, 1): + print(f" {i}. {name}") + print() + + while True: + try: + choice = int(input(f"Select a device (1-{len(outputs)}): ")) + if 1 <= choice <= len(outputs): + break + except ValueError: + pass + except EOFError: + # stdin is closed (headless/redirected run) — the prompt can + # never be answered, so retrying would spin forever. + raise RuntimeError( + "No interactive terminal to choose between multiple MIDI outputs — " + f"pass output_device= with one of: {outputs}" + ) from None + print(f"Enter a number between 1 and {len(outputs)}.") + + selected_name = outputs[choice - 1] + midi_out = mido.open_output(selected_name) + logger.info(f"Opened MIDI output: {selected_name}") + + print(f"\nTip: To skip this prompt, pass the device name directly:\n") + print(f' Sequencer(output_device_name="{selected_name}")') + print(f' Composition(output_device="{selected_name}")\n') + + return selected_name, midi_out + + except (OSError, RuntimeError) as e: + logger.error(f"Failed to open MIDI output: {e}") + return None, None + + +def select_input_device( + device_name: typing.Optional[str] = None, + callback: typing.Optional[typing.Callable] = None, +) -> typing.Tuple[typing.Optional[str], typing.Optional[typing.Any]]: + """ + Select and open a MIDI input device. + + If ``device_name`` is provided, attempts to open exactly that device. + If ``device_name`` is None, returns None without prompting (input is optional/advanced). + To enforce input, the caller should check the return value. + + A named device that is not present raises ValueError rather than falling + back to another input: MIDI input drives clock-follow and live note + capture, so silently listening to the wrong device would desynchronise + or mis-record a performance. + + Returns: + A tuple of (device_name, midi_in_object), or (None, None) when no + name was given or the device failed to open. + + Raises: + ValueError: If *device_name* is not among the available inputs. + """ + + if device_name is None: + return None, None + + try: + inputs = mido.get_input_names() + logger.info(f"Available MIDI inputs: {inputs}") + + if device_name not in inputs: + raise ValueError( + f"MIDI input device '{device_name}' not found. " + f"Available devices: {inputs}" + ) + + midi_in = mido.open_input(device_name, callback=callback) + logger.info(f"Opened MIDI input: {device_name}") + return device_name, midi_in + + except (OSError, RuntimeError) as e: + logger.error(f"Failed to open MIDI input: {e}") + return None, None diff --git a/subsequence/mini_notation.py b/subsequence/mini_notation.py index 3f1cbe9..8973127 100644 --- a/subsequence/mini_notation.py +++ b/subsequence/mini_notation.py @@ -4,161 +4,164 @@ @dataclasses.dataclass class ParsedEvent: + """ + Represents a single event parsed from mini-notation. + """ - """ - Represents a single event parsed from mini-notation. - """ - - time: float - duration: float - symbol: str - probability: float = 1.0 + time: float + duration: float + symbol: str + probability: float = 1.0 class MiniNotationError(Exception): - pass - - -def parse (notation: str, total_duration: float = 4.0) -> typing.List[ParsedEvent]: - - """ - Parse a mini-notation string into a list of timed events. - - Mini-notation is a concise way to express rhythmic and melodic phrases. - It distributes events evenly across the specified duration. - - **Syntax:** - - - ``x y z``: Items separated by spaces are distributed across the total duration. - - ``[a b]``: Groups items into a single subdivided step. - - ``~`` or ``.``: A rest. - - ``_``: Extends the previous note (sustain). - - ``x?0.6``: Probability suffix — fires with the given probability (0.0–1.0). - - Parameters: - notation: The string to parse. - total_duration: The duration (in beats) to distribute the - events over (default 4.0). - - Returns: - A list of ``ParsedEvent`` objects with calculated times and durations. - - Example: - ```python - # Distributes kick on beats 1 and 3, snare on 2 and 4 - parse("kick snare kick snare", 4.0) - - # Subdivisions: kick on 1, snare on 2.1 and 2.2 - parse("kick [snare snare]", 2.0) - ``` - """ - - if total_duration <= 0: - raise ValueError("total_duration must be positive") - - tokens = _tokenize(notation) - - events = _parse_recursive(tokens, 0.0, total_duration) - - return _post_process_sustains(events) - - -def _tokenize (text: str) -> typing.List[typing.Union[str, list]]: - - """ - Convert string into nested lists of tokens. - "a [b c]" -> ["a", ["b", "c"]] - """ - - # Add spaces around brackets to make splitting easier - text = text.replace("[", " [ ").replace("]", " ] ") - raw_tokens = text.split() - - stack: typing.List[list] = [[]] - - for token in raw_tokens: - - if token == "[": - new_group: typing.List[typing.Any] = [] - stack[-1].append(new_group) - stack.append(new_group) - - elif token == "]": - if len(stack) <= 1: - raise MiniNotationError("Unexpected closing bracket") - stack.pop() - - else: - stack[-1].append(token) - - if len(stack) > 1: - raise MiniNotationError("Missing closing bracket") - - return stack[0] - - -def _parse_recursive (tokens: list, start_time: float, duration: float) -> typing.List[ParsedEvent]: - - """ - Recursively distribute tokens over the given duration. - """ - - events: typing.List[ParsedEvent] = [] - step_duration = duration / len(tokens) if tokens else 0 - - for i, token in enumerate(tokens): - - current_time = start_time + (i * step_duration) - - if isinstance(token, list): - # Recursively parse sub-group - events.extend(_parse_recursive(token, current_time, step_duration)) - - elif isinstance(token, str): - # Parse optional probability suffix: "kick?0.6" → symbol="kick", probability=0.6 - if "?" in token and token[0] != "?": - symbol, prob_str = token.rsplit("?", 1) - try: - probability = float(prob_str) - except ValueError: - raise MiniNotationError(f"Invalid probability suffix in {token!r} - expected a number after '?'") - - if not 0.0 <= probability <= 1.0: - raise MiniNotationError(f"Probability in {token!r} must be between 0.0 and 1.0") - else: - symbol = token - probability = 1.0 - - if symbol in ("~", "."): - continue - events.append(ParsedEvent(current_time, step_duration, symbol, probability)) - - return events - - -def _post_process_sustains (events: typing.List[ParsedEvent]) -> typing.List[ParsedEvent]: - - """ - Merge ``_`` events into the previous event's duration. - """ - - if not events: - return [] - - processed: typing.List[ParsedEvent] = [] - last_event = None - - for event in events: - - if event.symbol == "_": - # Extend only when this sustain slot is contiguous with the end - # of the previous note: after a rest, `_` keeps the silence - # instead of stretching the note through the gap. - if last_event and abs((last_event.time + last_event.duration) - event.time) < 1e-9: - last_event.duration += event.duration - - else: - processed.append(event) - last_event = event - - return processed + pass + + +def parse(notation: str, total_duration: float = 4.0) -> typing.List[ParsedEvent]: + """ + Parse a mini-notation string into a list of timed events. + + Mini-notation is a concise way to express rhythmic and melodic phrases. + It distributes events evenly across the specified duration. + + **Syntax:** + + - ``x y z``: Items separated by spaces are distributed across the total duration. + - ``[a b]``: Groups items into a single subdivided step. + - ``~`` or ``.``: A rest. + - ``_``: Extends the previous note (sustain). + - ``x?0.6``: Probability suffix — fires with the given probability (0.0–1.0). + + Parameters: + notation: The string to parse. + total_duration: The duration (in beats) to distribute the + events over (default 4.0). + + Returns: + A list of ``ParsedEvent`` objects with calculated times and durations. + + Example: + ```python + # Distributes kick on beats 1 and 3, snare on 2 and 4 + parse("kick snare kick snare", 4.0) + + # Subdivisions: kick on 1, snare on 2.1 and 2.2 + parse("kick [snare snare]", 2.0) + ``` + """ + + if total_duration <= 0: + raise ValueError("total_duration must be positive") + + tokens = _tokenize(notation) + + events = _parse_recursive(tokens, 0.0, total_duration) + + return _post_process_sustains(events) + + +def _tokenize(text: str) -> typing.List[typing.Union[str, list]]: + """ + Convert string into nested lists of tokens. + "a [b c]" -> ["a", ["b", "c"]] + """ + + # Add spaces around brackets to make splitting easier + text = text.replace("[", " [ ").replace("]", " ] ") + raw_tokens = text.split() + + stack: typing.List[list] = [[]] + + for token in raw_tokens: + if token == "[": + new_group: typing.List[typing.Any] = [] + stack[-1].append(new_group) + stack.append(new_group) + + elif token == "]": + if len(stack) <= 1: + raise MiniNotationError("Unexpected closing bracket") + stack.pop() + + else: + stack[-1].append(token) + + if len(stack) > 1: + raise MiniNotationError("Missing closing bracket") + + return stack[0] + + +def _parse_recursive( + tokens: list, start_time: float, duration: float +) -> typing.List[ParsedEvent]: + """ + Recursively distribute tokens over the given duration. + """ + + events: typing.List[ParsedEvent] = [] + step_duration = duration / len(tokens) if tokens else 0 + + for i, token in enumerate(tokens): + current_time = start_time + (i * step_duration) + + if isinstance(token, list): + # Recursively parse sub-group + events.extend(_parse_recursive(token, current_time, step_duration)) + + elif isinstance(token, str): + # Parse optional probability suffix: "kick?0.6" → symbol="kick", probability=0.6 + if "?" in token and token[0] != "?": + symbol, prob_str = token.rsplit("?", 1) + try: + probability = float(prob_str) + except ValueError: + raise MiniNotationError( + f"Invalid probability suffix in {token!r} - expected a number after '?'" + ) + + if not 0.0 <= probability <= 1.0: + raise MiniNotationError( + f"Probability in {token!r} must be between 0.0 and 1.0" + ) + else: + symbol = token + probability = 1.0 + + if symbol in ("~", "."): + continue + events.append(ParsedEvent(current_time, step_duration, symbol, probability)) + + return events + + +def _post_process_sustains( + events: typing.List[ParsedEvent], +) -> typing.List[ParsedEvent]: + """ + Merge ``_`` events into the previous event's duration. + """ + + if not events: + return [] + + processed: typing.List[ParsedEvent] = [] + last_event = None + + for event in events: + if event.symbol == "_": + # Extend only when this sustain slot is contiguous with the end + # of the previous note: after a rest, `_` keeps the silence + # instead of stretching the note through the gap. + if ( + last_event + and abs((last_event.time + last_event.duration) - event.time) < 1e-9 + ): + last_event.duration += event.duration + + else: + processed.append(event) + last_event = event + + return processed diff --git a/subsequence/motifs.py b/subsequence/motifs.py index b6acef9..44d24c0 100644 --- a/subsequence/motifs.py +++ b/subsequence/motifs.py @@ -61,30 +61,30 @@ # (so a preset with no pitch= sounds against the standard GM drum map); # override with pitch= for any other kit. _WORLD_RHYTHMS: typing.Dict[str, typing.Tuple[typing.Tuple[int, ...], int, str]] = { - # Cuban clave family (16-pulse bar). - "son_clave_3_2": ((0, 3, 6, 10, 12), 16, "claves"), - "son_clave_2_3": ((2, 4, 8, 11, 14), 16, "claves"), - "rumba_clave_3_2": ((0, 3, 7, 10, 12), 16, "claves"), - "rumba_clave_2_3": ((2, 4, 8, 11, 15), 16, "claves"), - "bossa_nova_clave": ((0, 3, 6, 10, 13), 16, "side_stick"), - # Tresillo / cinquillo (the 3-3-2 family). - "tresillo": ((0, 3, 6), 8, "low_conga"), - "tresillo_16": ((0, 6, 12), 16, "low_conga"), - "cinquillo": ((0, 2, 3, 5, 6), 8, "low_conga"), - # West-African / Cuban 4-4 bell timelines (16-pulse). - "shiko": ((0, 4, 6, 10, 12), 16, "cowbell"), - "soukous": ((0, 3, 6, 10, 11), 16, "cowbell"), - "gahu": ((0, 3, 6, 10, 14), 16, "cowbell"), - "samba_necklace": ((0, 3, 5, 7, 10, 12, 14), 16, "side_stick"), - # The "standard pattern" / bembé bell on a 12-pulse cycle. - "bembe": ((0, 2, 4, 5, 7, 9, 11), 12, "cowbell"), - # bembe_euclidean is the specific Toussaint-catalogued Euclidean rotation - # of the bembé necklace (intervals 2-1-2-2-1-2-2); it differs from this - # library's own generate_euclidean_sequence(12, 7) default rotation. - "bembe_euclidean": ((0, 2, 3, 5, 7, 8, 10), 12, "cowbell"), - # Fume-fume is the FIVE-onset Ghanaian bell — Toussaint catalogues it as - # a rotation of E(5,12), not the seven-onset standard pattern above. - "fume_fume": ((0, 2, 4, 7, 9), 12, "cowbell"), + # Cuban clave family (16-pulse bar). + "son_clave_3_2": ((0, 3, 6, 10, 12), 16, "claves"), + "son_clave_2_3": ((2, 4, 8, 11, 14), 16, "claves"), + "rumba_clave_3_2": ((0, 3, 7, 10, 12), 16, "claves"), + "rumba_clave_2_3": ((2, 4, 8, 11, 15), 16, "claves"), + "bossa_nova_clave": ((0, 3, 6, 10, 13), 16, "side_stick"), + # Tresillo / cinquillo (the 3-3-2 family). + "tresillo": ((0, 3, 6), 8, "low_conga"), + "tresillo_16": ((0, 6, 12), 16, "low_conga"), + "cinquillo": ((0, 2, 3, 5, 6), 8, "low_conga"), + # West-African / Cuban 4-4 bell timelines (16-pulse). + "shiko": ((0, 4, 6, 10, 12), 16, "cowbell"), + "soukous": ((0, 3, 6, 10, 11), 16, "cowbell"), + "gahu": ((0, 3, 6, 10, 14), 16, "cowbell"), + "samba_necklace": ((0, 3, 5, 7, 10, 12, 14), 16, "side_stick"), + # The "standard pattern" / bembé bell on a 12-pulse cycle. + "bembe": ((0, 2, 4, 5, 7, 9, 11), 12, "cowbell"), + # bembe_euclidean is the specific Toussaint-catalogued Euclidean rotation + # of the bembé necklace (intervals 2-1-2-2-1-2-2); it differs from this + # library's own generate_euclidean_sequence(12, 7) default rotation. + "bembe_euclidean": ((0, 2, 3, 5, 7, 8, 10), 12, "cowbell"), + # Fume-fume is the FIVE-onset Ghanaian bell — Toussaint catalogues it as + # a rotation of E(5,12), not the seven-onset standard pattern above. + "fume_fume": ((0, 2, 4, 7, 9), 12, "cowbell"), } _CHORD_TONE_NAMES = {"root": 1, "third": 2, "fifth": 3, "seventh": 4} @@ -92,120 +92,112 @@ # ── Pitch specifications ──────────────────────────────────────────────────── + @dataclasses.dataclass(frozen=True) class Degree: + """ + A scale degree — 1-based, resolved against key + scale at placement. - """ - A scale degree — 1-based, resolved against key + scale at placement. - - Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the - scale length and resolve into higher octaves). ``octave`` shifts whole - octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened). - """ - - step: int - octave: int = 0 - chroma: int = 0 + Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the + scale length and resolve into higher octaves). ``octave`` shifts whole + octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened). + """ - def __post_init__ (self) -> None: + step: int + octave: int = 0 + chroma: int = 0 - """Validate that the degree is 1-based and plausibly a degree.""" + def __post_init__(self) -> None: + """Validate that the degree is 1-based and plausibly a degree.""" - if self.step < 1: - raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}") + if self.step < 1: + raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}") @dataclasses.dataclass(frozen=True) class ChordTone: + """ + An index into the current chord's tones — 1-based, resolved at placement. - """ - An index into the current chord's tones — 1-based, resolved at placement. - - Accepts an int (1 = root, 2 = third, ...) or one of the names - ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``. ``octave`` - shifts whole octaves. - """ - - index: int - octave: int = 0 + Accepts an int (1 = root, 2 = third, ...) or one of the names + ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``. ``octave`` + shifts whole octaves. + """ - def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: + index: int + octave: int = 0 - """Normalize a tone name to its 1-based index.""" + def __init__(self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: + """Normalize a tone name to its 1-based index.""" - if isinstance(index_or_name, str): - if index_or_name not in _CHORD_TONE_NAMES: - raise ValueError( - f"Unknown chord tone name '{index_or_name}' — " - f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" - ) - index = _CHORD_TONE_NAMES[index_or_name] - else: - index = index_or_name + if isinstance(index_or_name, str): + if index_or_name not in _CHORD_TONE_NAMES: + raise ValueError( + f"Unknown chord tone name '{index_or_name}' — " + f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" + ) + index = _CHORD_TONE_NAMES[index_or_name] + else: + index = index_or_name - if index < 1: - raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") + if index < 1: + raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") - object.__setattr__(self, "index", index) - object.__setattr__(self, "octave", octave) + object.__setattr__(self, "index", index) + object.__setattr__(self, "octave", octave) @dataclasses.dataclass(frozen=True) class Approach: + """ + A half-step approach into a target pitch at the next chord boundary. - """ - A half-step approach into a target pitch at the next chord boundary. + Resolves at placement, one semitone below its target (the leading-tone + approach); a ``ChordTone`` target reads the NEXT chord through the + harmony window, so the approach lands as the harmony arrives. + """ - Resolves at placement, one semitone below its target (the leading-tone - approach); a ``ChordTone`` target reads the NEXT chord through the - harmony window, so the approach lands as the harmony arrives. - """ - - target: typing.Union[int, Degree, ChordTone] + target: typing.Union[int, Degree, ChordTone] # ── Control signals ───────────────────────────────────────────────────────── + @dataclasses.dataclass(frozen=True) class CC: + """A MIDI CC signal — number, or a name resolved at placement via the pattern's ``cc_name_map``.""" - """A MIDI CC signal — number, or a name resolved at placement via the pattern's ``cc_name_map``.""" - - control: typing.Union[int, str] + control: typing.Union[int, str] @dataclasses.dataclass(frozen=True) class PitchBend: - - """The channel pitch-bend wheel; values are normalised -1.0 to 1.0.""" + """The channel pitch-bend wheel; values are normalised -1.0 to 1.0.""" @dataclasses.dataclass(frozen=True) class NRPN: + """An NRPN parameter — number, or a name resolved at placement via the pattern's ``nrpn_name_map``.""" - """An NRPN parameter — number, or a name resolved at placement via the pattern's ``nrpn_name_map``.""" - - parameter: typing.Union[int, str] - fine: bool = False - null_reset: bool = True + parameter: typing.Union[int, str] + fine: bool = False + null_reset: bool = True @dataclasses.dataclass(frozen=True) class RPN: + """An RPN parameter — number, or one of the standard RPN names (resolved at placement).""" - """An RPN parameter — number, or one of the standard RPN names (resolved at placement).""" - - parameter: typing.Union[int, str] - fine: bool = False - null_reset: bool = True + parameter: typing.Union[int, str] + fine: bool = False + null_reset: bool = True @dataclasses.dataclass(frozen=True) class OSC: + """An OSC address; values are sent as the single float argument.""" - """An OSC address; values are sent as the single float argument.""" - - address: str + address: str ControlSignal = typing.Union[CC, PitchBend, NRPN, RPN, OSC] @@ -215,2142 +207,2493 @@ class OSC: _SIGNAL_RANK = {CC: 0, PitchBend: 1, NRPN: 2, RPN: 3, OSC: 4} -def _pitch_sort_key (pitch: PitchSpec) -> tuple: - - """A total order over heterogeneous pitch specs (for canonical event order).""" - - rank = _SPEC_RANK[type(pitch)] +def _pitch_sort_key(pitch: PitchSpec) -> tuple: + """A total order over heterogeneous pitch specs (for canonical event order).""" - if isinstance(pitch, (int, str)): - return (rank, pitch) - if isinstance(pitch, Degree): - return (rank, pitch.step, pitch.octave, pitch.chroma) - if isinstance(pitch, ChordTone): - return (rank, pitch.index, pitch.octave) - if isinstance(pitch, Approach): - return (rank,) + _pitch_sort_key(pitch.target) + rank = _SPEC_RANK[type(pitch)] - return (rank,) + if isinstance(pitch, (int, str)): + return (rank, pitch) + if isinstance(pitch, Degree): + return (rank, pitch.step, pitch.octave, pitch.chroma) + if isinstance(pitch, ChordTone): + return (rank, pitch.index, pitch.octave) + if isinstance(pitch, Approach): + return (rank,) + _pitch_sort_key(pitch.target) + return (rank,) -def _signal_sort_key (signal: ControlSignal) -> tuple: - """A total order over control signals (for canonical event order).""" +def _signal_sort_key(signal: ControlSignal) -> tuple: + """A total order over control signals (for canonical event order).""" - rank = _SIGNAL_RANK[type(signal)] + rank = _SIGNAL_RANK[type(signal)] - if isinstance(signal, CC): - return (rank, str(signal.control)) - if isinstance(signal, (NRPN, RPN)): - return (rank, str(signal.parameter), signal.fine, signal.null_reset) - if isinstance(signal, OSC): - return (rank, signal.address) + if isinstance(signal, CC): + return (rank, str(signal.control)) + if isinstance(signal, (NRPN, RPN)): + return (rank, str(signal.parameter), signal.fine, signal.null_reset) + if isinstance(signal, OSC): + return (rank, signal.address) - return (rank,) + return (rank,) -def _velocity_key (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Tuple[int, int]: +def _velocity_key( + velocity: typing.Union[int, typing.Tuple[int, int]], +) -> typing.Tuple[int, int]: + """Normalise scalar-or-range velocity to a sortable pair.""" - """Normalise scalar-or-range velocity to a sortable pair.""" + if isinstance(velocity, tuple): + return (velocity[0], velocity[1]) - if isinstance(velocity, tuple): - return (velocity[0], velocity[1]) - - return (velocity, velocity) + return (velocity, velocity) # ── Events ────────────────────────────────────────────────────────────────── + @dataclasses.dataclass(frozen=True) class MotifEvent: - - """ - One timed note event inside a Motif. - - ``pitch`` is a specification: an absolute MIDI int, a drum name string, - a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None - for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which - must be re-pitched via :meth:`Motif.pitched` before placement. - ``velocity`` is an int or a ``(low, high)`` random-range tuple. - """ - - beat: float - pitch: PitchSpec - velocity: typing.Union[int, typing.Tuple[int, int]] = _DEFAULT_VELOCITY - duration: float = 0.25 - probability: float = 1.0 - - def __post_init__ (self) -> None: - - """Validate ranges that are wrong at any placement.""" - - if self.duration <= 0: - raise ValueError(f"Event duration must be positive — got {self.duration}") - if not 0.0 <= self.probability <= 1.0: - raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") - - def _sort_key (self) -> tuple: - - """Canonical ordering key — makes parallel merge order-independent.""" - - return (self.beat, _pitch_sort_key(self.pitch), _velocity_key(self.velocity), self.duration, self.probability) + """ + One timed note event inside a Motif. + + ``pitch`` is a specification: an absolute MIDI int, a drum name string, + a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None + for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which + must be re-pitched via :meth:`Motif.pitched` before placement. + ``velocity`` is an int or a ``(low, high)`` random-range tuple. + """ + + beat: float + pitch: PitchSpec + velocity: typing.Union[int, typing.Tuple[int, int]] = _DEFAULT_VELOCITY + duration: float = 0.25 + probability: float = 1.0 + + def __post_init__(self) -> None: + """Validate ranges that are wrong at any placement.""" + + if self.duration <= 0: + raise ValueError(f"Event duration must be positive — got {self.duration}") + if not 0.0 <= self.probability <= 1.0: + raise ValueError( + f"Event probability must be 0.0–1.0 — got {self.probability}" + ) + + def _sort_key(self) -> tuple: + """Canonical ordering key — makes parallel merge order-independent.""" + + return ( + self.beat, + _pitch_sort_key(self.pitch), + _velocity_key(self.velocity), + self.duration, + self.probability, + ) @dataclasses.dataclass(frozen=True) class ControlEvent: - - """ - One timed control gesture inside a Motif: a discrete write or a shaped ramp. - - A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates - ``start`` → ``end`` over ``span`` beats through the easing ``shape``. - Pulse density (``resolution=``) is deliberately not stored here — beats - and shapes are music; MIDI traffic density is set at the placement call. - """ - - beat: float - signal: ControlSignal - start: float - end: typing.Optional[float] = None - span: float = 0.0 - shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear" - probability: float = 1.0 - - def __post_init__ (self) -> None: - - """Validate the discrete/ramp invariants.""" - - if (self.end is None) != (self.span == 0.0): - raise ValueError("A ramp needs both end= and span= (a discrete write has neither)") - if self.span < 0: - raise ValueError(f"Ramp span must be non-negative — got {self.span}") - if not 0.0 <= self.probability <= 1.0: - raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") - - def _sort_key (self) -> tuple: - - """Canonical ordering key — makes parallel merge order-independent.""" - - end = self.start if self.end is None else self.end - return (self.beat, _signal_sort_key(self.signal), self.start, end, self.span, self.probability) - - def _value_at (self, fraction: float) -> float: - - """The interpolated value at a 0–1 fraction through the ramp.""" - - if self.end is None: - return self.start - - easing_fn = self.shape if callable(self.shape) else subsequence.easing.get_easing(self.shape) - return self.start + (self.end - self.start) * easing_fn(max(0.0, min(1.0, fraction))) - - -def _expand (name: str, value: typing.Any, n: int) -> list: - - """Expand a scalar parameter to n values, or validate a per-event list.""" - - if isinstance(value, (int, float, str)) or value is None or isinstance(value, tuple): - return [value] * n - - result = list(value) - - if len(result) != n: - raise ValueError(f"{name} has {len(result)} values for {n} events — parallel lists must match") - - return result - - -def _computed_length (events: typing.Iterable[MotifEvent], controls: typing.Iterable[ControlEvent]) -> float: - - """Default length: the next whole beat at or after the last sounding moment.""" - - ends = [e.beat + e.duration for e in events] + [c.beat + c.span for c in controls] - return float(math.ceil(max(ends))) if ends else 0.0 + """ + One timed control gesture inside a Motif: a discrete write or a shaped ramp. + + A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates + ``start`` → ``end`` over ``span`` beats through the easing ``shape``. + Pulse density (``resolution=``) is deliberately not stored here — beats + and shapes are music; MIDI traffic density is set at the placement call. + """ + + beat: float + signal: ControlSignal + start: float + end: typing.Optional[float] = None + span: float = 0.0 + shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear" + probability: float = 1.0 + + def __post_init__(self) -> None: + """Validate the discrete/ramp invariants.""" + + if (self.end is None) != (self.span == 0.0): + raise ValueError( + "A ramp needs both end= and span= (a discrete write has neither)" + ) + if self.span < 0: + raise ValueError(f"Ramp span must be non-negative — got {self.span}") + if not 0.0 <= self.probability <= 1.0: + raise ValueError( + f"Event probability must be 0.0–1.0 — got {self.probability}" + ) + + def _sort_key(self) -> tuple: + """Canonical ordering key — makes parallel merge order-independent.""" + + end = self.start if self.end is None else self.end + return ( + self.beat, + _signal_sort_key(self.signal), + self.start, + end, + self.span, + self.probability, + ) + + def _value_at(self, fraction: float) -> float: + """The interpolated value at a 0–1 fraction through the ramp.""" + + if self.end is None: + return self.start + + easing_fn = ( + self.shape + if callable(self.shape) + else subsequence.easing.get_easing(self.shape) + ) + return self.start + (self.end - self.start) * easing_fn( + max(0.0, min(1.0, fraction)) + ) + + +def _expand(name: str, value: typing.Any, n: int) -> list: + """Expand a scalar parameter to n values, or validate a per-event list.""" + + if ( + isinstance(value, (int, float, str)) + or value is None + or isinstance(value, tuple) + ): + return [value] * n + + result = list(value) + + if len(result) != n: + raise ValueError( + f"{name} has {len(result)} values for {n} events — parallel lists must match" + ) + + return result + + +def _computed_length( + events: typing.Iterable[MotifEvent], controls: typing.Iterable[ControlEvent] +) -> float: + """Default length: the next whole beat at or after the last sounding moment.""" + + ends = [e.beat + e.duration for e in events] + [c.beat + c.span for c in controls] + return float(math.ceil(max(ends))) if ends else 0.0 # ── Motif ─────────────────────────────────────────────────────────────────── + @dataclasses.dataclass(frozen=True) class Motif: - - """ - An immutable musical figure: timed note events + control gestures + a length in beats. - - Construct via the classmethods (:meth:`degrees`, :meth:`notes`, - :meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture - constructors, or :meth:`from_events`) rather than positionally. - ``length`` is explicit — a trailing rest is meaningful. - """ - - events: typing.Tuple[MotifEvent, ...] - length: float - controls: typing.Tuple[ControlEvent, ...] = () - fit: typing.Optional[float] = None # placement default for the fit dial; set by generate() - - def __post_init__ (self) -> None: - - """Validate, and normalise both streams to canonical order.""" - - if self.length < 0: - raise ValueError(f"Motif length must be non-negative — got {self.length}") - - object.__setattr__(self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key))) - object.__setattr__(self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key))) - - # ── constructors ──────────────────────────────────────────────────── - - @classmethod - def empty (cls) -> "Motif": - - """The empty motif (zero events, zero length) — the identity for ``then``.""" - - return cls(events=(), length=0.0) - - @classmethod - def from_events ( - cls, - events: typing.Iterable[MotifEvent], - length: typing.Optional[float] = None, - controls: typing.Iterable[ControlEvent] = (), - ) -> "Motif": - - """Build a motif from explicit events (power use; length defaults to the next whole beat).""" - - events = tuple(events) - controls = tuple(controls) - - return cls( - events = events, - length = _computed_length(events, controls) if length is None else length, - controls = controls, - ) - - @classmethod - def _from_sequence ( - cls, - pitches: typing.List[PitchSpec], - beats: typing.Optional[typing.List[float]], - velocities: typing.Any, - durations: typing.Any, - probabilities: typing.Any, - length: typing.Optional[float], - ) -> "Motif": - - """Shared core: one event per element, None = rest (slot still advances).""" - - n = len(pitches) - onsets = list(beats) if beats is not None else [float(i) for i in range(n)] - - if len(onsets) != n: - raise ValueError(f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match") - - velocity_list = _expand("velocities", velocities, n) - duration_list = _expand("durations", durations, n) - probability_list = _expand("probabilities", probabilities, n) - - events = tuple( - MotifEvent( - beat = float(onsets[i]), - pitch = pitches[i], - velocity = velocity_list[i], - duration = float(duration_list[i]), - probability = float(probability_list[i]), - ) - for i in range(n) - if pitches[i] is not None - ) - - return cls( - events = events, - length = _computed_length(events, ()) if length is None else float(length), - ) - - @classmethod - def degrees ( - cls, - degrees: typing.List[typing.Union[int, Degree, None]], - beats: typing.Optional[typing.List[float]] = None, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 1.0, - probabilities: typing.Any = 1.0, - length: typing.Optional[float] = None, - ) -> "Motif": - - """ - A melody written as 1-based scale degrees, one per beat by default. - - Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a - rest (the beat slot still advances), or :class:`Degree` for octave/ - chromatic detail. Resolved against key + scale at placement. - Durations default to a full beat (each note holds its slot). - """ - - converted: typing.List[PitchSpec] = [] - - for element in degrees: - if isinstance(element, int): - if element > _MAX_PLAUSIBLE_DEGREE: - raise ValueError( - f"Degree {element} is implausibly large — scale degrees are 1-based " - f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." - ) - converted.append(Degree(element)) - elif isinstance(element, Degree) or element is None: - converted.append(element) - else: - raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}") - - return cls._from_sequence(converted, beats, velocities, durations, probabilities, length) - - @classmethod - def notes ( - cls, - notes: typing.List[typing.Union[int, None]], - beats: typing.Optional[typing.List[float]] = None, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 1.0, - probabilities: typing.Any = 1.0, - length: typing.Optional[float] = None, - ) -> "Motif": - - """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" - - for element in notes: - # bool is a subclass of int, but True/False are never MIDI notes. - if isinstance(element, bool) or not (isinstance(element, int) or element is None): - raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}") - - return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length) - - @classmethod - def hits ( - cls, - pitch: typing.Union[int, str], - beats: typing.List[float], - length: typing.Optional[float] = None, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 0.1, - probabilities: typing.Any = 1.0, - ) -> "Motif": - - """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" - - return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length) - - @classmethod - def steps ( - cls, - steps: typing.List[int], - pitches: typing.Any, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 0.1, - probabilities: typing.Any = 1.0, - step_duration: float = 0.25, - length: typing.Optional[float] = None, - ) -> "Motif": - - """ - Grid placement — the ``sequence()`` convention: ``steps`` are 0-based - grid indices (sixteenths by default), ``pitches`` a scalar or - parallel list of MIDI ints or drum names. - """ - - n = len(steps) - pitch_list = _expand("pitches", pitches, n) - onsets = [s * step_duration for s in steps] - - if length is None and n: - length = float(math.ceil((max(steps) + 1) * step_duration)) - - return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length) - - @classmethod - def euclidean ( - cls, - pulses: int, - steps: int, - pitch: typing.Union[int, str], - length: float = 4.0, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 0.1, - probabilities: typing.Any = 1.0, - ) -> "Motif": - - """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" - - # bool is a subclass of int, but True/False are never MIDI notes. - if isinstance(pitch, bool): - raise TypeError(f"Motif.euclidean takes a MIDI int or drum name for pitch — got {pitch!r}") - - # The kernel returns one 0/1 flag per grid step; onsets are the 1s. - # It validates pulses first, so pulses > steps still raises clearly. - flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) - - if steps <= 0: - # A grid of zero steps holds no onsets — an empty motif of the given - # length, matching pulses=0 on a real grid (the empty-input policy). - return cls._from_sequence([], [], velocities, durations, probabilities, length) - - step_duration = length / steps - onsets = [i * step_duration for i, flag in enumerate(flags) if flag] - - return cls._from_sequence( - [pitch] * len(onsets), - onsets, - velocities, durations, probabilities, length, - ) - - @classmethod - def preset ( - cls, - name: str, - pitch: typing.Optional[typing.Union[int, str]] = None, - length: float = 4.0, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 0.1, - probabilities: typing.Any = 1.0, - ) -> "Motif": - - """A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``. - - Looks a curated timeline up in the world-rhythm table (clave family, - West-African bell patterns, tresillo/cinquillo, samba) and lays its - onsets across *length* beats. Onset positions are exact pulse indices - from Toussaint's "The Geometry of Musical Rhythm"; each preset declares - its own grid (16 for the clave/4-4 timelines, 12 for the bell - patterns) and a default drum voice. - - Parameters: - name: A preset name (``KeyError``-style ValueError lists them all). - pitch: The voice — a drum name or MIDI int; defaults to the - preset's General-MIDI voice (``"claves"``, ``"cowbell"``, - ``"side_stick"``, ``"low_conga"``), so it sounds against the - standard GM drum map without a ``pitch=``. - length: Total beats the cycle spans (4 = one common-time bar). - velocities / durations / probabilities: The parallel-list params. - - Returns: - A drum/pitched :class:`Motif` of the timeline's onsets. - - Raises: - ValueError: If *name* is not a known preset. - - Example: - ```python - clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves" - bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse - ``` - """ - - if name not in _WORLD_RHYTHMS: - known = ", ".join(sorted(_WORLD_RHYTHMS)) - raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.") - - steps, grid, voice = _WORLD_RHYTHMS[name] - - return cls.steps( - steps = list(steps), - pitches = pitch if pitch is not None else voice, - velocities = velocities, - durations = durations, - probabilities = probabilities, - step_duration = length / grid, - length = length, - ) - - # ── control-gesture constructors (mirror the pattern_midi verbs) ──── - - @classmethod - def _control_writes ( - cls, - signal: ControlSignal, - values: typing.List[float], - beats: typing.List[float], - length: typing.Optional[float], - probabilities: typing.Any = 1.0, - ) -> "Motif": - - """Shared core for discrete control writes.""" - - if len(values) != len(beats): - raise ValueError(f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match") - - probability_list = _expand("probabilities", probabilities, len(values)) - - controls = tuple( - ControlEvent(beat=float(beats[i]), signal=signal, start=float(values[i]), probability=float(probability_list[i])) - for i in range(len(values)) - ) - - return cls( - events = (), - length = _computed_length((), controls) if length is None else float(length), - controls = controls, - ) - - @classmethod - def _control_ramp ( - cls, - signal: ControlSignal, - start: float, - end: float, - beat_start: float, - beat_end: typing.Optional[float], - shape: typing.Union[str, "subsequence.easing.EasingFn"], - length: typing.Optional[float], - probability: float = 1.0, - ) -> "Motif": - - """Shared core for shaped control ramps.""" - - if beat_end is None: - if length is None: - raise ValueError("A ramp needs beat_end= (or length=, which beat_end defaults to)") - beat_end = float(length) - - if beat_end <= beat_start: - raise ValueError(f"beat_end ({beat_end}) must be after beat_start ({beat_start})") - - controls = ( - ControlEvent( - beat = float(beat_start), - signal = signal, - start = float(start), - end = float(end), - span = float(beat_end) - float(beat_start), - shape = shape, - probability = probability, - ), - ) - - return cls( - events = (), - length = float(math.ceil(beat_end)) if length is None else float(length), - controls = controls, - ) - - @classmethod - def cc (cls, control: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": - - """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" - - return cls._control_writes(CC(control), list(values), list(beats), length, probabilities) - - @classmethod - def cc_ramp (cls, control: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": - - """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" - - return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability) - - @classmethod - def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": - - """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" - - return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities) - - @classmethod - def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": - - """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" - - return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability) - - @classmethod - def nrpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": - - """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" - - return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) - - @classmethod - def nrpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": - - """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" - - return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) - - @classmethod - def rpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": - - """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" - - return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) - - @classmethod - def rpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": - - """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" - - return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) - - @classmethod - def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": - - """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" - - return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities) - - @classmethod - def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": - - """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" - - return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability) - - # ── the algebra ───────────────────────────────────────────────────── - - def then (self, other: "Motif") -> "Motif": - - """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" - - if not isinstance(other, Motif): - raise TypeError(f"then() takes a Motif — got {type(other).__name__}") - - return Motif( - events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events), - length = self.length + other.length, - controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls), - # fit is a dial, not content: keep ours, inherit the other's when - # we have none — join()/tiling folds from empty() (fit=None), and - # must not silently strip a generated motif's chord-snapping. - fit = self.fit if self.fit is not None else other.fit, - ) - - @classmethod - def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif": - - """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" - - result = cls.empty() - - for m in motifs: - result = result.then(m) - - return result - - @classmethod - def generate ( - cls, - rhythm: typing.Any, - length: typing.Optional[float] = None, - scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None, - contour: typing.Optional[str] = None, - end_on: typing.Optional[typing.Union[int, Degree]] = None, - cadence: typing.Optional[str] = None, - pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None, - max_pitches: typing.Optional[int] = None, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 0.25, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - state: typing.Optional[typing.Any] = None, - nir_strength: float = 0.5, - pitch_diversity: float = 0.6, - tessitura_strength: float = 0.6, - ) -> "Motif": - - """Generate a melodic motif — rhythm first, pitches walked, a value out. - - The melody engine emitting a value: you give the **rhythm** (an onset - list in beats, or another motif whose rhythm to borrow — cross-pattern - rhythm reuse is shared values); the engine walks pitches over it - through the soft scoring factors (NIR expectation, contour envelope, - tessitura regression, diversity), honouring any pins. - - The result emits **scale degrees** (resolved at placement against the - composition key/scale), so a generated hook transposes, varies, and - develops like a hand-written one. ``scale=`` constrains *candidate - choice only*: a name or interval list masks which pitches the walk - may use, spelled relative to its best-fit reference (major or minor) - — bind it in a composition whose scale matches that family and - resolution is exact. An explicit MIDI pitch pool (a list of note - numbers) switches to absolute output (the sieve/atonal path). - - Parameters: - rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif - (its onsets are borrowed). - length: Motif length in beats; defaults to the onsets rounded - up to a whole 4-beat bar. - scale: A scale name, an interval list, or an explicit MIDI - pitch pool. ``None`` = the plain seven degrees. - contour: Envelope shaping the line's height over its span — - ``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``. - end_on: Degree the line must end on — sugar for ``pins={-1: ...}``. - Degree semantics: raises with an explicit MIDI pool (pin the - exact note instead). - cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ - ``"fakeout"``) — the line closes on that cadence's melodic - degree (1 for the full closes and the fakeout, 5 for the - open half). Sugar for ``end_on=``; conflicts with it, and - raises with an explicit MIDI pool like ``end_on=``. - pins: ``{position: degree}`` — 1-based note positions (``-1`` = - the last, the Python idiom); the engine fills between. With - an explicit MIDI pool there are no degrees to read, so each - pin is the exact MIDI note to play (``Degree`` pins raise). - max_pitches: Cap on distinct pitches (a tight pool is a hook); - keeps the most central candidates. - velocities / durations: Scalar or per-note list (the parallel- - list convention). - seed: Seed for the walk (required or warned — module-level - nondeterminism breaks live reload). - rng: Explicit stream (overrides ``seed``). - state: A ``MelodicState`` whose dials, scoring factors, and - melodic history seed the walk. It is **copied** — building - a value never mutates a module-level live object. The - candidate pool is not carried over: it is always rebuilt - from ``scale=`` (pass an explicit pool there instead), - though the state's key still sets the tonic that the NIR - closure rule lands on. - nir_strength / pitch_diversity / tessitura_strength: The walk's - dials when no ``state`` is given. - - Example: - ```python - hook = subsequence.Motif.generate( - rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", - contour="arch", end_on=1, seed=7, - ) - ``` - """ - - import subsequence.melodic_state - - onsets = list(rhythm.onsets()) if hasattr(rhythm, "onsets") else [float(b) for b in rhythm] - - if cadence is not None: - if end_on is not None: - raise ValueError("cadence= already names the close degree — it conflicts with end_on=") - end_on = subsequence.cadences.cadence_formula(cadence).close_degree - - if not onsets: - raise ValueError("generate() needs at least one onset — the rhythm comes first") - if sorted(onsets) != onsets: - raise ValueError("rhythm onsets must ascend") - - if length is None: - length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0) - if onsets[-1] >= length: - raise ValueError(f"the last onset ({onsets[-1]:g}) falls outside length={length:g}") - - if rng is None: - if seed is None: - warnings.warn( - "generate() without seed= is nondeterministic — pass seed= so the " - "value survives live reload", - stacklevel = 2, - ) - rng = random.Random() - else: - rng = random.Random(seed) - - # --- The candidate pool ------------------------------------------------ - absolute_pool: typing.Optional[typing.List[int]] = None - intervals: typing.List[int] - - if scale is None: - intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian")) - elif isinstance(scale, str): - intervals = list(subsequence.intervals.scale_pitch_classes(0, scale)) - else: - values = [int(v) for v in scale] - if values and (min(values) != 0 or max(values) > 11): - absolute_pool = sorted(values) # an explicit MIDI pool: absolute output - intervals = [] - else: - intervals = sorted(set(values)) - - # Best-fit reference scale for degree spelling: whichever of major/ - # minor contains more of the pool (ties to major). Bound under a - # matching composition scale, resolution is exact. - if absolute_pool is None: - ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian")) - aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor")) - reference_name = "minor" if sum(i in aeolian for i in intervals) > sum(i in ionian for i in intervals) else "ionian" - reference = list(subsequence.intervals.scale_pitch_classes(0, reference_name)) - - # --- The walking state (copied, never mutated in place) ---------------- - if state is not None: - walker = state.clone() - walker.rest_probability = 0.0 # generate is rhythm-first: every onset gets a - # note, so the walker never rests (and never falls - # back to a stuck repeat) — rests come from the rhythm - else: - walker = subsequence.melodic_state.MelodicState( - nir_strength = nir_strength, - pitch_diversity = pitch_diversity, - tessitura_strength = tessitura_strength, - chord_weight = 0.0, # values have no chord context; fit applies at placement - ) - - if absolute_pool is not None: - walker.set_pool(absolute_pool) - else: - # Offsets over ~1.5 octaves anchored at 60 — register is decided - # at placement (root=), so the anchor is arbitrary and erased. - walker.set_pool([60 + octave * 12 + interval for octave in (0, 1) for interval in intervals if octave * 12 + interval <= 19]) - - if max_pitches is not None: - if max_pitches < 1: - raise ValueError("max_pitches must be at least 1") - pool = sorted(walker._pitch_pool) - centre = pool[len(pool) // 2] - walker.set_pool(sorted(sorted(pool, key = lambda p: (abs(p - centre), p))[:max_pitches])) - - # --- Pins --------------------------------------------------------------- - resolved_pins: typing.Dict[int, int] = {} - combined = dict(pins or {}) - - # cadence=/end_on= name scale DEGREES — meaningless against an explicit - # MIDI pool, where they would silently land as raw (sub-audio) note - # numbers. - if absolute_pool is not None and end_on is not None: - raise ValueError( - "cadence=/end_on= name scale degrees, but this motif uses an " - "explicit MIDI pool — pin the exact closing note instead: " - "pins={-1: }" - ) - - if end_on is not None: - if -1 in combined or len(onsets) in combined: - raise ValueError("end_on conflicts with a pin on the last note — they name the same position") - combined[-1] = end_on - - for pin_position, pin_spec in combined.items(): - if not isinstance(pin_position, int) or isinstance(pin_position, bool): - raise ValueError(f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}") - index = pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position - if not 0 <= index < len(onsets): - raise ValueError(f"pin position {pin_position} is outside the {len(onsets)}-note rhythm") - if absolute_pool is not None: - # A raw int pins the exact MIDI note; a Degree has no meaning - # here (the pool defines no scale to read it against). - if not isinstance(pin_spec, int) or isinstance(pin_spec, bool): - raise ValueError( - f"pin {pin_spec!r} is a scale degree, but this motif uses an " - "explicit MIDI pool — pin the exact MIDI note instead " - "(e.g. pins={-1: 52})" - ) - resolved_pins[index] = int(pin_spec) - else: - degree = pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec)) - step_index = (degree.step - 1) % len(reference) - carry = (degree.step - 1) // len(reference) - resolved_pins[index] = 60 + reference[step_index] + 12 * (carry + degree.octave) + degree.chroma - - # --- The walk ----------------------------------------------------------- - envelopes: typing.Dict[str, typing.Callable[[float], float]] = { - "arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos), - "valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos), - "ascending": lambda pos: 0.1 + 0.85 * pos, - "descending": lambda pos: 0.95 - 0.85 * pos, - } - - if contour is not None and contour not in envelopes: - known = ", ".join(sorted(envelopes)) - raise ValueError(f"unknown contour {contour!r} — expected one of: {known}") - - chosen_pitches: typing.List[int] = [] - - for index, onset in enumerate(onsets): - - if index in resolved_pins: - pitch = resolved_pins[index] - walker.record(pitch) # pins enter the NIR context like chosen notes - else: - span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0 - target = envelopes[contour](span_position) if contour is not None else None - picked = walker.choose_next(None, rng, beat = onset, position = span_position, contour_target = target) - pitch = picked if picked is not None else walker._pitch_pool[0] - - chosen_pitches.append(pitch) - - # --- Emission ------------------------------------------------------------ - velocity_values = _expand("velocities", velocities, len(onsets)) - duration_values = _expand("durations", durations, len(onsets)) - - events = [] - - for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)): - - spec: PitchSpec - - if absolute_pool is not None: - spec = pitch - else: - offset = pitch - 60 - octave, pc = divmod(offset, 12) - if pc in reference: - spec = Degree(reference.index(pc) + 1, octave = octave) - elif (pc + 1) % 12 in reference and pc + 1 <= 11: - spec = Degree(reference.index(pc + 1) + 1, octave = octave, chroma = -1) - else: - spec = Degree(reference.index(pc - 1) + 1, octave = octave, chroma = 1) - - events.append(MotifEvent( - beat = onset, - pitch = spec, - velocity = velocity_values[index], - duration = float(duration_values[index]), - )) - - return cls(events = tuple(events), length = float(length), fit = 0.7) - - def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif": - - """ - Parallel merge (the spelled form of ``&``): event union, length = max. - - No implicit tiling — a short gesture stacked under a long figure - plays once. Phrase operands flatten first. - """ - - if isinstance(other, Phrase): - merged = other.flatten() - elif isinstance(other, Motif): - merged = other - else: - raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}") - - return Motif( - events = self.events + merged.events, - length = max(self.length, merged.length), - controls = self.controls + merged.controls, - fit = self.fit, - ) - - def slice (self, start: float, end: float) -> "Motif": - - """ - A window onto the motif, on its own authority: events starting outside - are dropped; durations and ramp spans truncate at the cut (a truncated - ramp ends at its interpolated cut value). Beats shift so the window - starts at 0. - """ - - if end <= start: - raise ValueError(f"slice end ({end}) must be after start ({start})") - - events = tuple( - dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat)) - for e in self.events - if start <= e.beat < end - ) - - controls = [] - - for c in self.controls: - if not (start <= c.beat < end): - continue - if c.end is not None and c.beat + c.span > end: - kept = end - c.beat - controls.append(dataclasses.replace( - c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span), - )) - else: - controls.append(dataclasses.replace(c, beat=c.beat - start)) - - return Motif(events=events, length=end - start, controls=tuple(controls), fit=self.fit) - - def __add__ (self, other: typing.Any) -> "Phrase": - - """``a + b`` — sequential: a two-segment Phrase (segmentation preserved).""" - - if isinstance(other, Motif): - return Phrase((self, other)) - - return NotImplemented - - def __mul__ (self, count: int) -> typing.Union["Motif", "Phrase"]: - - """``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty.""" - - if not isinstance(count, int): - return NotImplemented - if count < 0: - raise ValueError(f"Repetition count must be non-negative — got {count}") - if count == 0: - return Motif.empty() - if count == 1: - return self - - return Phrase((self,) * count) - - __rmul__ = __mul__ - - def __and__ (self, other: typing.Any) -> "Motif": - - """``a & b`` — parallel merge; the spelled form is :meth:`stack`.""" - - if isinstance(other, (Motif, Phrase)): - return self.stack(other) - - return NotImplemented - - # ── transforms (pure; control gestures ride time, ignore pitch) ───── - - def reverse (self) -> "Motif": - - """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" - - events = tuple( - dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) - for e in self.events - ) - controls = tuple( - dataclasses.replace( - c, - beat = max(0.0, self.length - c.beat - c.span), - start = c.start if c.end is None else c.end, - end = c.end if c.end is None else c.start, - ) - for c in self.controls - ) - - return Motif(events=events, length=self.length, controls=controls, fit=self.fit) - - def rotate (self, beats: float) -> "Motif": - - """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" - - if self.length == 0: - return self - - events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events) - controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls) - - return Motif(events=events, length=self.length, controls=controls, fit=self.fit) - - def stretch (self, factor: float) -> "Motif": - - """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" - - if factor <= 0: - raise ValueError(f"Stretch factor must be positive — got {factor}") - - events = tuple( - dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) - for e in self.events - ) - controls = tuple( - dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) - for c in self.controls - ) - - return Motif(events=events, length=self.length * factor, controls=controls, fit=self.fit) - - def quantize (self, grid: float) -> "Motif": - - """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched). - - An onset exactly midway between grid lines snaps LATER (round half - up) — every midpoint moves the same way, the predictable behaviour - for a musician. (Python's own ``round()`` is half-to-even, which - made exact midpoints snap in alternating directions.) - """ - - if grid <= 0: - raise ValueError(f"Quantize grid must be positive — got {grid}") - - events = tuple( - dataclasses.replace(e, beat=math.floor(e.beat / grid + 0.5) * grid) - for e in self.events - ) - - return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) - - def accent (self, beat: float, amount: int = 20) -> "Motif": - - """Add *amount* velocity to every note at the given beat position (0-based beats).""" - - def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]: - # Clamp both ends: a negative amount (a de-accent) must not store - # a velocity below 1, which MIDI cannot play. - if isinstance(velocity, tuple): - return (max(1, min(127, velocity[0] + amount)), max(1, min(127, velocity[1] + amount))) - return max(1, min(127, velocity + amount)) - - events = tuple( - dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e - for e in self.events - ) - - return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) - - def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif": - - """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" - - events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) - - return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) - - def _nudged_pitch (self, pitch: PitchSpec, rng: random.Random) -> PitchSpec: - - """One varied pitch: a small melodic nudge that always changes the note. - - Degrees move by scale steps, MIDI ints by semitones, chord tones by - index; an Approach's target is nudged. Drum names raise — a varied - drum is a different instrument, not a variation. - """ - - if isinstance(pitch, Degree): - steps = [pitch.step + delta for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1] - return dataclasses.replace(pitch, step = rng.choice(steps)) - if isinstance(pitch, ChordTone): - indices = [pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1] - return ChordTone(rng.choice(indices), octave = pitch.octave) - if isinstance(pitch, Approach): - nudged = self._nudged_pitch(pitch.target, rng) - if not isinstance(nudged, (int, Degree, ChordTone)): - raise TypeError(f"cannot vary an Approach aimed at {type(nudged).__name__} content") - return Approach(nudged) - if isinstance(pitch, int): - return pitch + rng.choice((-2, -1, 1, 2)) - - raise TypeError( - f"vary() moves pitches — {type(pitch).__name__} content cannot vary " - "(a varied drum is a different instrument)" - ) - - def vary ( - self, - notes: int = 1, - position: str = "end", - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - keep_contour: bool = False, - ) -> "Motif": - - """Replace a few pitches, preserving the rhythm — the smallest variation. - - Rhythm, velocities, durations, rests, and control gestures are - untouched; only the chosen notes' pitches move (by a small melodic - nudge: scale steps for degrees, semitones for MIDI ints). - - Parameters: - notes: How many pitched notes to vary (clamped to what exists). - position: Which notes — ``"end"`` (the tail, the default), - ``"start"``, or ``"anywhere"`` (drawn from the stream). - seed: Seed for the variation. A standalone vary without a seed - warns — module-level nondeterminism breaks live reload. - rng: An explicit random stream (overrides ``seed``; used by - recipe machinery). - keep_contour: When True, the variation preserves the line's - CSEG — every varied note keeps its rank relations with - every other note, so the melodic shape is identical (the - motif-identity guard). Where no nudge can preserve the - contour, that note stays unchanged — shape wins over - motion. - - Example: - ```python - answer = call.vary(notes=1, seed=4) # same figure, new tail note - ``` - """ - - if notes < 0: - raise ValueError(f"notes must be at least 0, got {notes}") - if position not in ("end", "start", "anywhere"): - raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}') - - if rng is None: - if seed is None: - warnings.warn( - "vary() without seed= is nondeterministic — pass seed= so the " - "value survives live reload", - stacklevel = 2, - ) - rng = random.Random() - else: - rng = random.Random(seed) - - pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] - count = min(notes, len(pitched_indices)) - - if count == 0: - return self - - if position == "end": - chosen = pitched_indices[-count:] - elif position == "start": - chosen = pitched_indices[:count] - else: - chosen = sorted(rng.sample(pitched_indices, count)) - - events = list(self.events) - - for index in chosen: - if keep_contour: - replacement = self._contour_safe_nudge(events, index, pitched_indices, rng) - if replacement is not None: - events[index] = dataclasses.replace(events[index], pitch = replacement) - else: - events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng)) - - return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit) - - @staticmethod - def _rank_value (pitch: PitchSpec) -> float: - - """A comparable height for contour ranking (uniform content only).""" - - if isinstance(pitch, Degree): - return pitch.octave * 7 + pitch.step + 0.4 * pitch.chroma - if isinstance(pitch, ChordTone): - return pitch.octave * 4 + pitch.index - if isinstance(pitch, int): - return float(pitch) - - raise TypeError(f"keep_contour needs rankable pitches — {type(pitch).__name__} content has no height") - - def _contour_safe_nudge ( - self, - events: typing.List[MotifEvent], - index: int, - pitched_indices: typing.List[int], - rng: random.Random, - ) -> typing.Optional[PitchSpec]: - - """A nudge for events[index] that preserves its CSEG rank relations. - - Candidates are the usual small nudges, filtered to those keeping the - note's above/below/equal relation to every other pitched note. One - rng draw happens regardless (stream stability); ``None`` means no - candidate preserves the shape — leave the note alone. - """ - - pitch = events[index].pitch - - if isinstance(pitch, Degree): - candidates: typing.List[PitchSpec] = [ - dataclasses.replace(pitch, step = pitch.step + delta) - for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1 - ] - elif isinstance(pitch, int): - candidates = [pitch + delta for delta in (-2, -1, 1, 2)] - else: - raise TypeError(f"keep_contour cannot vary {type(pitch).__name__} content") - - original = self._rank_value(pitch) - others = [ - (self._rank_value(events[other].pitch), other) - for other in pitched_indices if other != index - ] - - def preserves (candidate: PitchSpec) -> bool: - height = self._rank_value(candidate) - for other_height, _ in others: - before = (original > other_height) - (original < other_height) - after = (height > other_height) - (height < other_height) - if before != after: - return False - return True - - surviving = [candidate for candidate in candidates if preserves(candidate)] - - # One draw either way, so adding keep_contour never shifts the stream - # consumed by the notes around this one. - draw = rng.random() - - if not surviving: - return None - - return surviving[int(draw * len(surviving)) % len(surviving)] - - def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif": - - """Call → response: re-aim the tail to a stable degree. - - The classic consequent move — the figure repeats but its last pitched - note lands home (degree 1 by default; pass ``to=5`` for a half-close, - or a full ``Degree`` for register control). Everything else — - rhythm, the other pitches, velocities, controls — is untouched. - - Degree content only: absolute MIDI has no degrees to re-aim (build - the call with ``motif([...])``), and drums raise. - """ - - target = to if isinstance(to, Degree) else Degree(int(to)) - - pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] - - if not pitched_indices: - return self - - last = self.events[pitched_indices[-1]] - - if not isinstance(last.pitch, Degree): - raise TypeError( - f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " - "content (build the call with motif([...]) for degree content)" - ) - - if isinstance(to, int): - # Keep the call's register: only the step is re-aimed. - target = dataclasses.replace(last.pitch, step = int(to), chroma = 0) - - events = list(self.events) - events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target) - - return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit) - - def pitched (self, spec: PitchSpec) -> "Motif": - - """ - Replace every pitch with one spec — a kick rhythm becomes a bass line. - - ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord - tones; any other string is a drum name; ints are MIDI; Degree / - ChordTone / Approach pass through. - """ - - if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: - spec = ChordTone(spec) - - events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events) - - return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) - - def rhythm (self) -> "Motif": - - """ - Strip pitches (and control gestures): a reusable rhythmic skeleton. - - Timing, velocities, durations, and probabilities survive; re-pitch - with :meth:`pitched` before placement (placing a skeleton raises). - """ - - events = tuple(dataclasses.replace(e, pitch=None) for e in self.events) - - return Motif(events=events, length=self.length) - - def onsets (self) -> typing.List[float]: - - """The note onset beats, in order — ready for rhythm-first generation.""" - - return [e.beat for e in self.events] - - def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif": - - """ - Transpose pitched content; the keyword names the unit. - - ``steps=`` moves scale degrees diatonically (the sequencing move) and - raises on absolute-MIDI or drum content; ``semitones=`` is the - literal chromatic form for MIDI ints and degrees. Drum motifs raise - on both — a transposed drum name is a different instrument, not a - transposition. - """ - - if (steps is None) == (semitones is None): - raise ValueError("transpose() takes exactly one of steps= or semitones=") - - def move (pitch: PitchSpec) -> PitchSpec: - - if pitch is None: - return None - - if isinstance(pitch, Approach): - moved = move(pitch.target) - if not isinstance(moved, (int, Degree, ChordTone)): - raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content") - return Approach(moved) - - if steps is not None: - if isinstance(pitch, Degree): - return dataclasses.replace(pitch, step=pitch.step + steps) - raise TypeError( - f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " - f"has no degrees (use semitones= for MIDI ints)" - ) - - assert semitones is not None # exactly one of steps/semitones is set (validated above) - - if isinstance(pitch, int): - return pitch + semitones - if isinstance(pitch, Degree): - return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) - raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content") - - events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events) - - return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) - - def invert (self, pivot: typing.Optional[int] = None) -> "Motif": - - """ - Mirror pitches around a pivot: MIDI content around a MIDI pivot, - degree content around a degree pivot (default: the first note's pitch). - Drum motifs raise. - """ - - pitched_events = [e for e in self.events if e.pitch is not None] - - if not pitched_events: - return self - - first = pitched_events[0].pitch - - if pivot is None: - if isinstance(first, int): - pivot = first - elif isinstance(first, Degree): - pivot = first.step - else: - raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content") - - def mirror (pitch: PitchSpec) -> PitchSpec: - - if pitch is None: - return None - if isinstance(pitch, int): - return 2 * pivot - pitch - if isinstance(pitch, Degree): - mirrored = 2 * pivot - pitch.step - if mirrored < 1: - raise ValueError( - f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " - f"raise the pivot or use Degree octaves" - ) - # Reflection around the pivot (read at octave 0) is an isometry, so a - # note's register flips too: a degree an octave above the pivot lands an - # octave below it. Negating octave needs no scale length and leaves - # octave-0 content unchanged. - return dataclasses.replace(pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma) - raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") - - events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events) - - return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) - - # ── description ───────────────────────────────────────────────────── - - def describe (self) -> str: - - """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" - - notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events) - parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"] - - if self.controls: - gestures = ", ".join(_control_label(c) for c in self.controls) - parts.append(f"controls [{gestures}]") - - return " ".join(parts) - - def __str__ (self) -> str: - - """Printable form (same as :meth:`describe`).""" - - return self.describe() - - -def _pitch_label (pitch: PitchSpec) -> str: - - """Compact label for a pitch spec in describe() output.""" - - if pitch is None: - return "·" - if isinstance(pitch, Degree): - marks = ("+" * pitch.octave if pitch.octave > 0 else "-" * -pitch.octave) - chroma = (f"#{pitch.chroma}" if pitch.chroma > 0 else f"b{-pitch.chroma}" if pitch.chroma < 0 else "") - return f"^{pitch.step}{marks}{chroma}" - if isinstance(pitch, ChordTone): - return f"tone{pitch.index}" - if isinstance(pitch, Approach): - return f">{_pitch_label(pitch.target)}" - - return str(pitch) - - -def _control_label (c: ControlEvent) -> str: - - """Compact label for a control event in describe() output.""" - - if isinstance(c.signal, CC): - name = f"CC{c.signal.control}" if isinstance(c.signal.control, int) else f"CC:{c.signal.control}" - elif isinstance(c.signal, PitchBend): - name = "bend" - elif isinstance(c.signal, NRPN): - name = f"NRPN{c.signal.parameter}" - elif isinstance(c.signal, RPN): - name = f"RPN{c.signal.parameter}" - else: - name = c.signal.address - - if c.end is None: - return f"{name}={c.start:g}@{c.beat:g}" - - return f"{name} {c.start:g}→{c.end:g} over {c.beat:g}–{c.beat + c.span:g}" + """ + An immutable musical figure: timed note events + control gestures + a length in beats. + + Construct via the classmethods (:meth:`degrees`, :meth:`notes`, + :meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture + constructors, or :meth:`from_events`) rather than positionally. + ``length`` is explicit — a trailing rest is meaningful. + """ + + events: typing.Tuple[MotifEvent, ...] + length: float + controls: typing.Tuple[ControlEvent, ...] = () + fit: typing.Optional[float] = ( + None # placement default for the fit dial; set by generate() + ) + + def __post_init__(self) -> None: + """Validate, and normalise both streams to canonical order.""" + + if self.length < 0: + raise ValueError(f"Motif length must be non-negative — got {self.length}") + + object.__setattr__( + self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key)) + ) + object.__setattr__( + self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key)) + ) + + # ── constructors ──────────────────────────────────────────────────── + + @classmethod + def empty(cls) -> "Motif": + """The empty motif (zero events, zero length) — the identity for ``then``.""" + + return cls(events=(), length=0.0) + + @classmethod + def from_events( + cls, + events: typing.Iterable[MotifEvent], + length: typing.Optional[float] = None, + controls: typing.Iterable[ControlEvent] = (), + ) -> "Motif": + """Build a motif from explicit events (power use; length defaults to the next whole beat).""" + + events = tuple(events) + controls = tuple(controls) + + return cls( + events=events, + length=_computed_length(events, controls) if length is None else length, + controls=controls, + ) + + @classmethod + def _from_sequence( + cls, + pitches: typing.List[PitchSpec], + beats: typing.Optional[typing.List[float]], + velocities: typing.Any, + durations: typing.Any, + probabilities: typing.Any, + length: typing.Optional[float], + ) -> "Motif": + """Shared core: one event per element, None = rest (slot still advances).""" + + n = len(pitches) + onsets = list(beats) if beats is not None else [float(i) for i in range(n)] + + if len(onsets) != n: + raise ValueError( + f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match" + ) + + velocity_list = _expand("velocities", velocities, n) + duration_list = _expand("durations", durations, n) + probability_list = _expand("probabilities", probabilities, n) + + events = tuple( + MotifEvent( + beat=float(onsets[i]), + pitch=pitches[i], + velocity=velocity_list[i], + duration=float(duration_list[i]), + probability=float(probability_list[i]), + ) + for i in range(n) + if pitches[i] is not None + ) + + return cls( + events=events, + length=_computed_length(events, ()) if length is None else float(length), + ) + + @classmethod + def degrees( + cls, + degrees: typing.List[typing.Union[int, Degree, None]], + beats: typing.Optional[typing.List[float]] = None, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 1.0, + probabilities: typing.Any = 1.0, + length: typing.Optional[float] = None, + ) -> "Motif": + """ + A melody written as 1-based scale degrees, one per beat by default. + + Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a + rest (the beat slot still advances), or :class:`Degree` for octave/ + chromatic detail. Resolved against key + scale at placement. + Durations default to a full beat (each note holds its slot). + """ + + converted: typing.List[PitchSpec] = [] + + for element in degrees: + if isinstance(element, int): + if element > _MAX_PLAUSIBLE_DEGREE: + raise ValueError( + f"Degree {element} is implausibly large — scale degrees are 1-based " + f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." + ) + converted.append(Degree(element)) + elif isinstance(element, Degree) or element is None: + converted.append(element) + else: + raise TypeError( + f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}" + ) + + return cls._from_sequence( + converted, beats, velocities, durations, probabilities, length + ) + + @classmethod + def notes( + cls, + notes: typing.List[typing.Union[int, None]], + beats: typing.Optional[typing.List[float]] = None, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 1.0, + probabilities: typing.Any = 1.0, + length: typing.Optional[float] = None, + ) -> "Motif": + """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" + + for element in notes: + # bool is a subclass of int, but True/False are never MIDI notes. + if isinstance(element, bool) or not ( + isinstance(element, int) or element is None + ): + raise TypeError( + f"Motif.notes takes MIDI ints or None — got {type(element).__name__}" + ) + + return cls._from_sequence( + list(notes), beats, velocities, durations, probabilities, length + ) + + @classmethod + def hits( + cls, + pitch: typing.Union[int, str], + beats: typing.List[float], + length: typing.Optional[float] = None, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 0.1, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" + + return cls._from_sequence( + [pitch] * len(beats), + list(beats), + velocities, + durations, + probabilities, + length, + ) + + @classmethod + def steps( + cls, + steps: typing.List[int], + pitches: typing.Any, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 0.1, + probabilities: typing.Any = 1.0, + step_duration: float = 0.25, + length: typing.Optional[float] = None, + ) -> "Motif": + """ + Grid placement — the ``sequence()`` convention: ``steps`` are 0-based + grid indices (sixteenths by default), ``pitches`` a scalar or + parallel list of MIDI ints or drum names. + """ + + n = len(steps) + pitch_list = _expand("pitches", pitches, n) + onsets = [s * step_duration for s in steps] + + if length is None and n: + length = float(math.ceil((max(steps) + 1) * step_duration)) + + return cls._from_sequence( + pitch_list, onsets, velocities, durations, probabilities, length + ) + + @classmethod + def euclidean( + cls, + pulses: int, + steps: int, + pitch: typing.Union[int, str], + length: float = 4.0, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 0.1, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" + + # bool is a subclass of int, but True/False are never MIDI notes. + if isinstance(pitch, bool): + raise TypeError( + f"Motif.euclidean takes a MIDI int or drum name for pitch — got {pitch!r}" + ) + + # The kernel returns one 0/1 flag per grid step; onsets are the 1s. + # It validates pulses first, so pulses > steps still raises clearly. + flags = subsequence.sequence_utils.generate_euclidean_sequence( + steps=steps, pulses=pulses + ) + + if steps <= 0: + # A grid of zero steps holds no onsets — an empty motif of the given + # length, matching pulses=0 on a real grid (the empty-input policy). + return cls._from_sequence( + [], [], velocities, durations, probabilities, length + ) + + step_duration = length / steps + onsets = [i * step_duration for i, flag in enumerate(flags) if flag] + + return cls._from_sequence( + [pitch] * len(onsets), + onsets, + velocities, + durations, + probabilities, + length, + ) + + @classmethod + def preset( + cls, + name: str, + pitch: typing.Optional[typing.Union[int, str]] = None, + length: float = 4.0, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 0.1, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``. + + Looks a curated timeline up in the world-rhythm table (clave family, + West-African bell patterns, tresillo/cinquillo, samba) and lays its + onsets across *length* beats. Onset positions are exact pulse indices + from Toussaint's "The Geometry of Musical Rhythm"; each preset declares + its own grid (16 for the clave/4-4 timelines, 12 for the bell + patterns) and a default drum voice. + + Parameters: + name: A preset name (``KeyError``-style ValueError lists them all). + pitch: The voice — a drum name or MIDI int; defaults to the + preset's General-MIDI voice (``"claves"``, ``"cowbell"``, + ``"side_stick"``, ``"low_conga"``), so it sounds against the + standard GM drum map without a ``pitch=``. + length: Total beats the cycle spans (4 = one common-time bar). + velocities / durations / probabilities: The parallel-list params. + + Returns: + A drum/pitched :class:`Motif` of the timeline's onsets. + + Raises: + ValueError: If *name* is not a known preset. + + Example: + ```python + clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves" + bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse + ``` + """ + + if name not in _WORLD_RHYTHMS: + known = ", ".join(sorted(_WORLD_RHYTHMS)) + raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.") + + steps, grid, voice = _WORLD_RHYTHMS[name] + + return cls.steps( + steps=list(steps), + pitches=pitch if pitch is not None else voice, + velocities=velocities, + durations=durations, + probabilities=probabilities, + step_duration=length / grid, + length=length, + ) + + # ── control-gesture constructors (mirror the pattern_midi verbs) ──── + + @classmethod + def _control_writes( + cls, + signal: ControlSignal, + values: typing.List[float], + beats: typing.List[float], + length: typing.Optional[float], + probabilities: typing.Any = 1.0, + ) -> "Motif": + """Shared core for discrete control writes.""" + + if len(values) != len(beats): + raise ValueError( + f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match" + ) + + probability_list = _expand("probabilities", probabilities, len(values)) + + controls = tuple( + ControlEvent( + beat=float(beats[i]), + signal=signal, + start=float(values[i]), + probability=float(probability_list[i]), + ) + for i in range(len(values)) + ) + + return cls( + events=(), + length=_computed_length((), controls) if length is None else float(length), + controls=controls, + ) + + @classmethod + def _control_ramp( + cls, + signal: ControlSignal, + start: float, + end: float, + beat_start: float, + beat_end: typing.Optional[float], + shape: typing.Union[str, "subsequence.easing.EasingFn"], + length: typing.Optional[float], + probability: float = 1.0, + ) -> "Motif": + """Shared core for shaped control ramps.""" + + if beat_end is None: + if length is None: + raise ValueError( + "A ramp needs beat_end= (or length=, which beat_end defaults to)" + ) + beat_end = float(length) + + if beat_end <= beat_start: + raise ValueError( + f"beat_end ({beat_end}) must be after beat_start ({beat_start})" + ) + + controls = ( + ControlEvent( + beat=float(beat_start), + signal=signal, + start=float(start), + end=float(end), + span=float(beat_end) - float(beat_start), + shape=shape, + probability=probability, + ), + ) + + return cls( + events=(), + length=float(math.ceil(beat_end)) if length is None else float(length), + controls=controls, + ) + + @classmethod + def cc( + cls, + control: typing.Union[int, str], + values: typing.List[int], + beats: typing.List[float], + length: typing.Optional[float] = None, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" + + return cls._control_writes( + CC(control), list(values), list(beats), length, probabilities + ) + + @classmethod + def cc_ramp( + cls, + control: typing.Union[int, str], + start: int, + end: int, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", + length: typing.Optional[float] = None, + probability: float = 1.0, + ) -> "Motif": + """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" + + return cls._control_ramp( + CC(control), start, end, beat_start, beat_end, shape, length, probability + ) + + @classmethod + def pitch_bend( + cls, + values: typing.List[float], + beats: typing.List[float], + length: typing.Optional[float] = None, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" + + return cls._control_writes( + PitchBend(), list(values), list(beats), length, probabilities + ) + + @classmethod + def pitch_bend_ramp( + cls, + start: float, + end: float, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", + length: typing.Optional[float] = None, + probability: float = 1.0, + ) -> "Motif": + """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" + + return cls._control_ramp( + PitchBend(), start, end, beat_start, beat_end, shape, length, probability + ) + + @classmethod + def nrpn( + cls, + parameter: typing.Union[int, str], + values: typing.List[int], + beats: typing.List[float], + fine: bool = False, + null_reset: bool = True, + length: typing.Optional[float] = None, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" + + return cls._control_writes( + NRPN(parameter, fine=fine, null_reset=null_reset), + list(values), + list(beats), + length, + probabilities, + ) + + @classmethod + def nrpn_ramp( + cls, + parameter: typing.Union[int, str], + start: int, + end: int, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", + fine: bool = True, + null_reset: bool = True, + length: typing.Optional[float] = None, + probability: float = 1.0, + ) -> "Motif": + """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" + + return cls._control_ramp( + NRPN(parameter, fine=fine, null_reset=null_reset), + start, + end, + beat_start, + beat_end, + shape, + length, + probability, + ) + + @classmethod + def rpn( + cls, + parameter: typing.Union[int, str], + values: typing.List[int], + beats: typing.List[float], + fine: bool = False, + null_reset: bool = True, + length: typing.Optional[float] = None, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" + + return cls._control_writes( + RPN(parameter, fine=fine, null_reset=null_reset), + list(values), + list(beats), + length, + probabilities, + ) + + @classmethod + def rpn_ramp( + cls, + parameter: typing.Union[int, str], + start: int, + end: int, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", + fine: bool = True, + null_reset: bool = True, + length: typing.Optional[float] = None, + probability: float = 1.0, + ) -> "Motif": + """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" + + return cls._control_ramp( + RPN(parameter, fine=fine, null_reset=null_reset), + start, + end, + beat_start, + beat_end, + shape, + length, + probability, + ) + + @classmethod + def osc( + cls, + address: str, + values: typing.List[float], + beats: typing.List[float], + length: typing.Optional[float] = None, + probabilities: typing.Any = 1.0, + ) -> "Motif": + """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" + + return cls._control_writes( + OSC(address), list(values), list(beats), length, probabilities + ) + + @classmethod + def osc_ramp( + cls, + address: str, + start: float, + end: float, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", + length: typing.Optional[float] = None, + probability: float = 1.0, + ) -> "Motif": + """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" + + return cls._control_ramp( + OSC(address), start, end, beat_start, beat_end, shape, length, probability + ) + + # ── the algebra ───────────────────────────────────────────────────── + + def then(self, other: "Motif") -> "Motif": + """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" + + if not isinstance(other, Motif): + raise TypeError(f"then() takes a Motif — got {type(other).__name__}") + + return Motif( + events=self.events + + tuple( + dataclasses.replace(e, beat=e.beat + self.length) for e in other.events + ), + length=self.length + other.length, + controls=self.controls + + tuple( + dataclasses.replace(c, beat=c.beat + self.length) + for c in other.controls + ), + # fit is a dial, not content: keep ours, inherit the other's when + # we have none — join()/tiling folds from empty() (fit=None), and + # must not silently strip a generated motif's chord-snapping. + fit=self.fit if self.fit is not None else other.fit, + ) + + @classmethod + def join(cls, motifs: typing.Iterable["Motif"]) -> "Motif": + """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" + + result = cls.empty() + + for m in motifs: + result = result.then(m) + + return result + + @classmethod + def generate( + cls, + rhythm: typing.Any, + length: typing.Optional[float] = None, + scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None, + contour: typing.Optional[str] = None, + end_on: typing.Optional[typing.Union[int, Degree]] = None, + cadence: typing.Optional[str] = None, + pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None, + max_pitches: typing.Optional[int] = None, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 0.25, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + state: typing.Optional[typing.Any] = None, + nir_strength: float = 0.5, + pitch_diversity: float = 0.6, + tessitura_strength: float = 0.6, + ) -> "Motif": + """Generate a melodic motif — rhythm first, pitches walked, a value out. + + The melody engine emitting a value: you give the **rhythm** (an onset + list in beats, or another motif whose rhythm to borrow — cross-pattern + rhythm reuse is shared values); the engine walks pitches over it + through the soft scoring factors (NIR expectation, contour envelope, + tessitura regression, diversity), honouring any pins. + + The result emits **scale degrees** (resolved at placement against the + composition key/scale), so a generated hook transposes, varies, and + develops like a hand-written one. ``scale=`` constrains *candidate + choice only*: a name or interval list masks which pitches the walk + may use, spelled relative to its best-fit reference (major or minor) + — bind it in a composition whose scale matches that family and + resolution is exact. An explicit MIDI pitch pool (a list of note + numbers) switches to absolute output (the sieve/atonal path). + + Parameters: + rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif + (its onsets are borrowed). + length: Motif length in beats; defaults to the onsets rounded + up to a whole 4-beat bar. + scale: A scale name, an interval list, or an explicit MIDI + pitch pool. ``None`` = the plain seven degrees. + contour: Envelope shaping the line's height over its span — + ``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``. + end_on: Degree the line must end on — sugar for ``pins={-1: ...}``. + Degree semantics: raises with an explicit MIDI pool (pin the + exact note instead). + cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ + ``"fakeout"``) — the line closes on that cadence's melodic + degree (1 for the full closes and the fakeout, 5 for the + open half). Sugar for ``end_on=``; conflicts with it, and + raises with an explicit MIDI pool like ``end_on=``. + pins: ``{position: degree}`` — 1-based note positions (``-1`` = + the last, the Python idiom); the engine fills between. With + an explicit MIDI pool there are no degrees to read, so each + pin is the exact MIDI note to play (``Degree`` pins raise). + max_pitches: Cap on distinct pitches (a tight pool is a hook); + keeps the most central candidates. + velocities / durations: Scalar or per-note list (the parallel- + list convention). + seed: Seed for the walk (required or warned — module-level + nondeterminism breaks live reload). + rng: Explicit stream (overrides ``seed``). + state: A ``MelodicState`` whose dials, scoring factors, and + melodic history seed the walk. It is **copied** — building + a value never mutates a module-level live object. The + candidate pool is not carried over: it is always rebuilt + from ``scale=`` (pass an explicit pool there instead), + though the state's key still sets the tonic that the NIR + closure rule lands on. + nir_strength / pitch_diversity / tessitura_strength: The walk's + dials when no ``state`` is given. + + Example: + ```python + hook = subsequence.Motif.generate( + rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", + contour="arch", end_on=1, seed=7, + ) + ``` + """ + + import subsequence.melodic_state + + onsets = ( + list(rhythm.onsets()) + if hasattr(rhythm, "onsets") + else [float(b) for b in rhythm] + ) + + if cadence is not None: + if end_on is not None: + raise ValueError( + "cadence= already names the close degree — it conflicts with end_on=" + ) + end_on = subsequence.cadences.cadence_formula(cadence).close_degree + + if not onsets: + raise ValueError( + "generate() needs at least one onset — the rhythm comes first" + ) + if sorted(onsets) != onsets: + raise ValueError("rhythm onsets must ascend") + + if length is None: + length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0) + if onsets[-1] >= length: + raise ValueError( + f"the last onset ({onsets[-1]:g}) falls outside length={length:g}" + ) + + if rng is None: + if seed is None: + warnings.warn( + "generate() without seed= is nondeterministic — pass seed= so the " + "value survives live reload", + stacklevel=2, + ) + rng = random.Random() + else: + rng = random.Random(seed) + + # --- The candidate pool ------------------------------------------------ + absolute_pool: typing.Optional[typing.List[int]] = None + intervals: typing.List[int] + + if scale is None: + intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian")) + elif isinstance(scale, str): + intervals = list(subsequence.intervals.scale_pitch_classes(0, scale)) + else: + values = [int(v) for v in scale] + if values and (min(values) != 0 or max(values) > 11): + absolute_pool = sorted(values) # an explicit MIDI pool: absolute output + intervals = [] + else: + intervals = sorted(set(values)) + + # Best-fit reference scale for degree spelling: whichever of major/ + # minor contains more of the pool (ties to major). Bound under a + # matching composition scale, resolution is exact. + if absolute_pool is None: + ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian")) + aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor")) + reference_name = ( + "minor" + if sum(i in aeolian for i in intervals) + > sum(i in ionian for i in intervals) + else "ionian" + ) + reference = list( + subsequence.intervals.scale_pitch_classes(0, reference_name) + ) + + # --- The walking state (copied, never mutated in place) ---------------- + if state is not None: + walker = state.clone() + walker.rest_probability = ( + 0.0 # generate is rhythm-first: every onset gets a + ) + # note, so the walker never rests (and never falls + # back to a stuck repeat) — rests come from the rhythm + else: + walker = subsequence.melodic_state.MelodicState( + nir_strength=nir_strength, + pitch_diversity=pitch_diversity, + tessitura_strength=tessitura_strength, + chord_weight=0.0, # values have no chord context; fit applies at placement + ) + + if absolute_pool is not None: + walker.set_pool(absolute_pool) + else: + # Offsets over ~1.5 octaves anchored at 60 — register is decided + # at placement (root=), so the anchor is arbitrary and erased. + walker.set_pool( + [ + 60 + octave * 12 + interval + for octave in (0, 1) + for interval in intervals + if octave * 12 + interval <= 19 + ] + ) + + if max_pitches is not None: + if max_pitches < 1: + raise ValueError("max_pitches must be at least 1") + pool = sorted(walker._pitch_pool) + centre = pool[len(pool) // 2] + walker.set_pool( + sorted(sorted(pool, key=lambda p: (abs(p - centre), p))[:max_pitches]) + ) + + # --- Pins --------------------------------------------------------------- + resolved_pins: typing.Dict[int, int] = {} + combined = dict(pins or {}) + + # cadence=/end_on= name scale DEGREES — meaningless against an explicit + # MIDI pool, where they would silently land as raw (sub-audio) note + # numbers. + if absolute_pool is not None and end_on is not None: + raise ValueError( + "cadence=/end_on= name scale degrees, but this motif uses an " + "explicit MIDI pool — pin the exact closing note instead: " + "pins={-1: }" + ) + + if end_on is not None: + if -1 in combined or len(onsets) in combined: + raise ValueError( + "end_on conflicts with a pin on the last note — they name the same position" + ) + combined[-1] = end_on + + for pin_position, pin_spec in combined.items(): + if not isinstance(pin_position, int) or isinstance(pin_position, bool): + raise ValueError( + f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}" + ) + index = ( + pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position + ) + if not 0 <= index < len(onsets): + raise ValueError( + f"pin position {pin_position} is outside the {len(onsets)}-note rhythm" + ) + if absolute_pool is not None: + # A raw int pins the exact MIDI note; a Degree has no meaning + # here (the pool defines no scale to read it against). + if not isinstance(pin_spec, int) or isinstance(pin_spec, bool): + raise ValueError( + f"pin {pin_spec!r} is a scale degree, but this motif uses an " + "explicit MIDI pool — pin the exact MIDI note instead " + "(e.g. pins={-1: 52})" + ) + resolved_pins[index] = int(pin_spec) + else: + degree = ( + pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec)) + ) + step_index = (degree.step - 1) % len(reference) + carry = (degree.step - 1) // len(reference) + resolved_pins[index] = ( + 60 + + reference[step_index] + + 12 * (carry + degree.octave) + + degree.chroma + ) + + # --- The walk ----------------------------------------------------------- + envelopes: typing.Dict[str, typing.Callable[[float], float]] = { + "arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos), + "valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos), + "ascending": lambda pos: 0.1 + 0.85 * pos, + "descending": lambda pos: 0.95 - 0.85 * pos, + } + + if contour is not None and contour not in envelopes: + known = ", ".join(sorted(envelopes)) + raise ValueError(f"unknown contour {contour!r} — expected one of: {known}") + + chosen_pitches: typing.List[int] = [] + + for index, onset in enumerate(onsets): + if index in resolved_pins: + pitch = resolved_pins[index] + walker.record(pitch) # pins enter the NIR context like chosen notes + else: + span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0 + target = ( + envelopes[contour](span_position) if contour is not None else None + ) + picked = walker.choose_next( + None, rng, beat=onset, position=span_position, contour_target=target + ) + pitch = picked if picked is not None else walker._pitch_pool[0] + + chosen_pitches.append(pitch) + + # --- Emission ------------------------------------------------------------ + velocity_values = _expand("velocities", velocities, len(onsets)) + duration_values = _expand("durations", durations, len(onsets)) + + events = [] + + for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)): + spec: PitchSpec + + if absolute_pool is not None: + spec = pitch + else: + offset = pitch - 60 + octave, pc = divmod(offset, 12) + if pc in reference: + spec = Degree(reference.index(pc) + 1, octave=octave) + elif (pc + 1) % 12 in reference and pc + 1 <= 11: + spec = Degree(reference.index(pc + 1) + 1, octave=octave, chroma=-1) + else: + spec = Degree(reference.index(pc - 1) + 1, octave=octave, chroma=1) + + events.append( + MotifEvent( + beat=onset, + pitch=spec, + velocity=velocity_values[index], + duration=float(duration_values[index]), + ) + ) + + return cls(events=tuple(events), length=float(length), fit=0.7) + + def stack(self, other: typing.Union["Motif", "Phrase"]) -> "Motif": + """ + Parallel merge (the spelled form of ``&``): event union, length = max. + + No implicit tiling — a short gesture stacked under a long figure + plays once. Phrase operands flatten first. + """ + + if isinstance(other, Phrase): + merged = other.flatten() + elif isinstance(other, Motif): + merged = other + else: + raise TypeError( + f"stack() takes a Motif or Phrase — got {type(other).__name__}" + ) + + return Motif( + events=self.events + merged.events, + length=max(self.length, merged.length), + controls=self.controls + merged.controls, + fit=self.fit, + ) + + def slice(self, start: float, end: float) -> "Motif": + """ + A window onto the motif, on its own authority: events starting outside + are dropped; durations and ramp spans truncate at the cut (a truncated + ramp ends at its interpolated cut value). Beats shift so the window + starts at 0. + """ + + if end <= start: + raise ValueError(f"slice end ({end}) must be after start ({start})") + + events = tuple( + dataclasses.replace( + e, beat=e.beat - start, duration=min(e.duration, end - e.beat) + ) + for e in self.events + if start <= e.beat < end + ) + + controls = [] + + for c in self.controls: + if not (start <= c.beat < end): + continue + if c.end is not None and c.beat + c.span > end: + kept = end - c.beat + controls.append( + dataclasses.replace( + c, + beat=c.beat - start, + span=kept, + end=c._value_at(kept / c.span), + ) + ) + else: + controls.append(dataclasses.replace(c, beat=c.beat - start)) + + return Motif( + events=events, length=end - start, controls=tuple(controls), fit=self.fit + ) + + def __add__(self, other: typing.Any) -> "Phrase": + """``a + b`` — sequential: a two-segment Phrase (segmentation preserved).""" + + if isinstance(other, Motif): + return Phrase((self, other)) + + return NotImplemented + + def __mul__(self, count: int) -> typing.Union["Motif", "Phrase"]: + """``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty.""" + + if not isinstance(count, int): + return NotImplemented + if count < 0: + raise ValueError(f"Repetition count must be non-negative — got {count}") + if count == 0: + return Motif.empty() + if count == 1: + return self + + return Phrase((self,) * count) + + __rmul__ = __mul__ + + def __and__(self, other: typing.Any) -> "Motif": + """``a & b`` — parallel merge; the spelled form is :meth:`stack`.""" + + if isinstance(other, (Motif, Phrase)): + return self.stack(other) + + return NotImplemented + + # ── transforms (pure; control gestures ride time, ignore pitch) ───── + + def reverse(self) -> "Motif": + """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" + + events = tuple( + dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) + for e in self.events + ) + controls = tuple( + dataclasses.replace( + c, + beat=max(0.0, self.length - c.beat - c.span), + start=c.start if c.end is None else c.end, + end=c.end if c.end is None else c.start, + ) + for c in self.controls + ) + + return Motif(events=events, length=self.length, controls=controls, fit=self.fit) + + def rotate(self, beats: float) -> "Motif": + """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" + + if self.length == 0: + return self + + events = tuple( + dataclasses.replace(e, beat=(e.beat + beats) % self.length) + for e in self.events + ) + controls = tuple( + dataclasses.replace(c, beat=(c.beat + beats) % self.length) + for c in self.controls + ) + + return Motif(events=events, length=self.length, controls=controls, fit=self.fit) + + def stretch(self, factor: float) -> "Motif": + """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" + + if factor <= 0: + raise ValueError(f"Stretch factor must be positive — got {factor}") + + events = tuple( + dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) + for e in self.events + ) + controls = tuple( + dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) + for c in self.controls + ) + + return Motif( + events=events, length=self.length * factor, controls=controls, fit=self.fit + ) + + def quantize(self, grid: float) -> "Motif": + """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched). + + An onset exactly midway between grid lines snaps LATER (round half + up) — every midpoint moves the same way, the predictable behaviour + for a musician. (Python's own ``round()`` is half-to-even, which + made exact midpoints snap in alternating directions.) + """ + + if grid <= 0: + raise ValueError(f"Quantize grid must be positive — got {grid}") + + events = tuple( + dataclasses.replace(e, beat=math.floor(e.beat / grid + 0.5) * grid) + for e in self.events + ) + + return Motif( + events=events, length=self.length, controls=self.controls, fit=self.fit + ) + + def accent(self, beat: float, amount: int = 20) -> "Motif": + """Add *amount* velocity to every note at the given beat position (0-based beats).""" + + def boost( + velocity: typing.Union[int, typing.Tuple[int, int]], + ) -> typing.Union[int, typing.Tuple[int, int]]: + # Clamp both ends: a negative amount (a de-accent) must not store + # a velocity below 1, which MIDI cannot play. + if isinstance(velocity, tuple): + return ( + max(1, min(127, velocity[0] + amount)), + max(1, min(127, velocity[1] + amount)), + ) + return max(1, min(127, velocity + amount)) + + events = tuple( + dataclasses.replace(e, velocity=boost(e.velocity)) + if abs(e.beat - beat) < 1e-9 + else e + for e in self.events + ) + + return Motif( + events=events, length=self.length, controls=self.controls, fit=self.fit + ) + + def with_velocity( + self, velocity: typing.Union[int, typing.Tuple[int, int]] + ) -> "Motif": + """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" + + events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) + + return Motif( + events=events, length=self.length, controls=self.controls, fit=self.fit + ) + + def _nudged_pitch(self, pitch: PitchSpec, rng: random.Random) -> PitchSpec: + """One varied pitch: a small melodic nudge that always changes the note. + + Degrees move by scale steps, MIDI ints by semitones, chord tones by + index; an Approach's target is nudged. Drum names raise — a varied + drum is a different instrument, not a variation. + """ + + if isinstance(pitch, Degree): + steps = [ + pitch.step + delta + for delta in (-2, -1, 1, 2) + if pitch.step + delta >= 1 + ] + return dataclasses.replace(pitch, step=rng.choice(steps)) + if isinstance(pitch, ChordTone): + indices = [ + pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1 + ] + return ChordTone(rng.choice(indices), octave=pitch.octave) + if isinstance(pitch, Approach): + nudged = self._nudged_pitch(pitch.target, rng) + if not isinstance(nudged, (int, Degree, ChordTone)): + raise TypeError( + f"cannot vary an Approach aimed at {type(nudged).__name__} content" + ) + return Approach(nudged) + if isinstance(pitch, int): + return pitch + rng.choice((-2, -1, 1, 2)) + + raise TypeError( + f"vary() moves pitches — {type(pitch).__name__} content cannot vary " + "(a varied drum is a different instrument)" + ) + + def vary( + self, + notes: int = 1, + position: str = "end", + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + keep_contour: bool = False, + ) -> "Motif": + """Replace a few pitches, preserving the rhythm — the smallest variation. + + Rhythm, velocities, durations, rests, and control gestures are + untouched; only the chosen notes' pitches move (by a small melodic + nudge: scale steps for degrees, semitones for MIDI ints). + + Parameters: + notes: How many pitched notes to vary (clamped to what exists). + position: Which notes — ``"end"`` (the tail, the default), + ``"start"``, or ``"anywhere"`` (drawn from the stream). + seed: Seed for the variation. A standalone vary without a seed + warns — module-level nondeterminism breaks live reload. + rng: An explicit random stream (overrides ``seed``; used by + recipe machinery). + keep_contour: When True, the variation preserves the line's + CSEG — every varied note keeps its rank relations with + every other note, so the melodic shape is identical (the + motif-identity guard). Where no nudge can preserve the + contour, that note stays unchanged — shape wins over + motion. + + Example: + ```python + answer = call.vary(notes=1, seed=4) # same figure, new tail note + ``` + """ + + if notes < 0: + raise ValueError(f"notes must be at least 0, got {notes}") + if position not in ("end", "start", "anywhere"): + raise ValueError( + f'position must be "end", "start", or "anywhere" — got {position!r}' + ) + + if rng is None: + if seed is None: + warnings.warn( + "vary() without seed= is nondeterministic — pass seed= so the " + "value survives live reload", + stacklevel=2, + ) + rng = random.Random() + else: + rng = random.Random(seed) + + pitched_indices = [ + index for index, event in enumerate(self.events) if event.pitch is not None + ] + count = min(notes, len(pitched_indices)) + + if count == 0: + return self + + if position == "end": + chosen = pitched_indices[-count:] + elif position == "start": + chosen = pitched_indices[:count] + else: + chosen = sorted(rng.sample(pitched_indices, count)) + + events = list(self.events) + + for index in chosen: + if keep_contour: + replacement = self._contour_safe_nudge( + events, index, pitched_indices, rng + ) + if replacement is not None: + events[index] = dataclasses.replace( + events[index], pitch=replacement + ) + else: + events[index] = dataclasses.replace( + events[index], pitch=self._nudged_pitch(events[index].pitch, rng) + ) + + return Motif( + events=tuple(events), + length=self.length, + controls=self.controls, + fit=self.fit, + ) + + @staticmethod + def _rank_value(pitch: PitchSpec) -> float: + """A comparable height for contour ranking (uniform content only).""" + + if isinstance(pitch, Degree): + return pitch.octave * 7 + pitch.step + 0.4 * pitch.chroma + if isinstance(pitch, ChordTone): + return pitch.octave * 4 + pitch.index + if isinstance(pitch, int): + return float(pitch) + + raise TypeError( + f"keep_contour needs rankable pitches — {type(pitch).__name__} content has no height" + ) + + def _contour_safe_nudge( + self, + events: typing.List[MotifEvent], + index: int, + pitched_indices: typing.List[int], + rng: random.Random, + ) -> typing.Optional[PitchSpec]: + """A nudge for events[index] that preserves its CSEG rank relations. + + Candidates are the usual small nudges, filtered to those keeping the + note's above/below/equal relation to every other pitched note. One + rng draw happens regardless (stream stability); ``None`` means no + candidate preserves the shape — leave the note alone. + """ + + pitch = events[index].pitch + + if isinstance(pitch, Degree): + candidates: typing.List[PitchSpec] = [ + dataclasses.replace(pitch, step=pitch.step + delta) + for delta in (-2, -1, 1, 2) + if pitch.step + delta >= 1 + ] + elif isinstance(pitch, int): + candidates = [pitch + delta for delta in (-2, -1, 1, 2)] + else: + raise TypeError(f"keep_contour cannot vary {type(pitch).__name__} content") + + original = self._rank_value(pitch) + others = [ + (self._rank_value(events[other].pitch), other) + for other in pitched_indices + if other != index + ] + + def preserves(candidate: PitchSpec) -> bool: + height = self._rank_value(candidate) + for other_height, _ in others: + before = (original > other_height) - (original < other_height) + after = (height > other_height) - (height < other_height) + if before != after: + return False + return True + + surviving = [candidate for candidate in candidates if preserves(candidate)] + + # One draw either way, so adding keep_contour never shifts the stream + # consumed by the notes around this one. + draw = rng.random() + + if not surviving: + return None + + return surviving[int(draw * len(surviving)) % len(surviving)] + + def answer(self, to: typing.Union[int, Degree] = 1) -> "Motif": + """Call → response: re-aim the tail to a stable degree. + + The classic consequent move — the figure repeats but its last pitched + note lands home (degree 1 by default; pass ``to=5`` for a half-close, + or a full ``Degree`` for register control). Everything else — + rhythm, the other pitches, velocities, controls — is untouched. + + Degree content only: absolute MIDI has no degrees to re-aim (build + the call with ``motif([...])``), and drums raise. + """ + + target = to if isinstance(to, Degree) else Degree(int(to)) + + pitched_indices = [ + index for index, event in enumerate(self.events) if event.pitch is not None + ] + + if not pitched_indices: + return self + + last = self.events[pitched_indices[-1]] + + if not isinstance(last.pitch, Degree): + raise TypeError( + f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " + "content (build the call with motif([...]) for degree content)" + ) + + if isinstance(to, int): + # Keep the call's register: only the step is re-aimed. + target = dataclasses.replace(last.pitch, step=int(to), chroma=0) + + events = list(self.events) + events[pitched_indices[-1]] = dataclasses.replace(last, pitch=target) + + return Motif( + events=tuple(events), + length=self.length, + controls=self.controls, + fit=self.fit, + ) + + def pitched(self, spec: PitchSpec) -> "Motif": + """ + Replace every pitch with one spec — a kick rhythm becomes a bass line. + + ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord + tones; any other string is a drum name; ints are MIDI; Degree / + ChordTone / Approach pass through. + """ + + if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: + spec = ChordTone(spec) + + events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events) + + return Motif( + events=events, length=self.length, controls=self.controls, fit=self.fit + ) + + def rhythm(self) -> "Motif": + """ + Strip pitches (and control gestures): a reusable rhythmic skeleton. + + Timing, velocities, durations, and probabilities survive; re-pitch + with :meth:`pitched` before placement (placing a skeleton raises). + """ + + events = tuple(dataclasses.replace(e, pitch=None) for e in self.events) + + return Motif(events=events, length=self.length) + + def onsets(self) -> typing.List[float]: + """The note onset beats, in order — ready for rhythm-first generation.""" + + return [e.beat for e in self.events] + + def transpose( + self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None + ) -> "Motif": + """ + Transpose pitched content; the keyword names the unit. + + ``steps=`` moves scale degrees diatonically (the sequencing move) and + raises on absolute-MIDI or drum content; ``semitones=`` is the + literal chromatic form for MIDI ints and degrees. Drum motifs raise + on both — a transposed drum name is a different instrument, not a + transposition. + """ + + if (steps is None) == (semitones is None): + raise ValueError("transpose() takes exactly one of steps= or semitones=") + + def move(pitch: PitchSpec) -> PitchSpec: + if pitch is None: + return None + + if isinstance(pitch, Approach): + moved = move(pitch.target) + if not isinstance(moved, (int, Degree, ChordTone)): + raise TypeError( + f"transpose cannot aim an Approach at {type(moved).__name__} content" + ) + return Approach(moved) + + if steps is not None: + if isinstance(pitch, Degree): + return dataclasses.replace(pitch, step=pitch.step + steps) + raise TypeError( + f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " + f"has no degrees (use semitones= for MIDI ints)" + ) + + assert ( + semitones is not None + ) # exactly one of steps/semitones is set (validated above) + + if isinstance(pitch, int): + return pitch + semitones + if isinstance(pitch, Degree): + return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) + raise TypeError( + f"transpose(semitones=) cannot move {type(pitch).__name__} content" + ) + + events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events) + + return Motif( + events=events, length=self.length, controls=self.controls, fit=self.fit + ) + + def invert(self, pivot: typing.Optional[int] = None) -> "Motif": + """ + Mirror pitches around a pivot: MIDI content around a MIDI pivot, + degree content around a degree pivot (default: the first note's pitch). + Drum motifs raise. + """ + + pitched_events = [e for e in self.events if e.pitch is not None] + + if not pitched_events: + return self + + first = pitched_events[0].pitch + + if pivot is None: + if isinstance(first, int): + pivot = first + elif isinstance(first, Degree): + pivot = first.step + else: + raise TypeError( + f"invert() cannot derive a pivot from {type(first).__name__} content" + ) + + def mirror(pitch: PitchSpec) -> PitchSpec: + if pitch is None: + return None + if isinstance(pitch, int): + return 2 * pivot - pitch + if isinstance(pitch, Degree): + mirrored = 2 * pivot - pitch.step + if mirrored < 1: + raise ValueError( + f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " + f"raise the pivot or use Degree octaves" + ) + # Reflection around the pivot (read at octave 0) is an isometry, so a + # note's register flips too: a degree an octave above the pivot lands an + # octave below it. Negating octave needs no scale length and leaves + # octave-0 content unchanged. + return dataclasses.replace( + pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma + ) + raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") + + events = tuple( + dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events + ) + + return Motif( + events=events, length=self.length, controls=self.controls, fit=self.fit + ) + + # ── description ───────────────────────────────────────────────────── + + def describe(self) -> str: + """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" + + notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events) + parts = [ + f"Motif {self.length:g} beats", + f"[{notes}]" if notes else "[no notes]", + ] + + if self.controls: + gestures = ", ".join(_control_label(c) for c in self.controls) + parts.append(f"controls [{gestures}]") + + return " ".join(parts) + + def __str__(self) -> str: + """Printable form (same as :meth:`describe`).""" + + return self.describe() + + +def _pitch_label(pitch: PitchSpec) -> str: + """Compact label for a pitch spec in describe() output.""" + + if pitch is None: + return "·" + if isinstance(pitch, Degree): + marks = "+" * pitch.octave if pitch.octave > 0 else "-" * -pitch.octave + chroma = ( + f"#{pitch.chroma}" + if pitch.chroma > 0 + else f"b{-pitch.chroma}" + if pitch.chroma < 0 + else "" + ) + return f"^{pitch.step}{marks}{chroma}" + if isinstance(pitch, ChordTone): + return f"tone{pitch.index}" + if isinstance(pitch, Approach): + return f">{_pitch_label(pitch.target)}" + + return str(pitch) + + +def _control_label(c: ControlEvent) -> str: + """Compact label for a control event in describe() output.""" + + if isinstance(c.signal, CC): + name = ( + f"CC{c.signal.control}" + if isinstance(c.signal.control, int) + else f"CC:{c.signal.control}" + ) + elif isinstance(c.signal, PitchBend): + name = "bend" + elif isinstance(c.signal, NRPN): + name = f"NRPN{c.signal.parameter}" + elif isinstance(c.signal, RPN): + name = f"RPN{c.signal.parameter}" + else: + name = c.signal.address + + if c.end is None: + return f"{name}={c.start:g}@{c.beat:g}" + + return f"{name} {c.start:g}→{c.end:g} over {c.beat:g}–{c.beat + c.span:g}" # ── Phrase ────────────────────────────────────────────────────────────────── + @dataclasses.dataclass(frozen=True) class _PhraseRecipe: + """Provenance of a generated phrase — what reroll() regenerates from. - """Provenance of a generated phrase — what reroll() regenerates from. - - Generated values carry their recipe (the generator spec and seed) so - per-region regeneration is possible; a hand-written or transformed - phrase has none, and rerolling it raises loudly. + Generated values carry their recipe (the generator spec and seed) so + per-region regeneration is possible; a hand-written or transformed + phrase has none, and rerolling it raises loudly. - Attributes: - source: The motif the phrase was developed from. - plan: The unit-label tuple, or the recipe name. - bars: The phrase length in bars. - seed: The development seed (None = the unseeded warning path). - beats_per_bar: The bar size the plan was spread against. - cadence: The cadence name the phrase closes on (``sentence()``/ - ``period()`` record it; ``develop()`` leaves it ``None``). - """ + Attributes: + source: The motif the phrase was developed from. + plan: The unit-label tuple, or the recipe name. + bars: The phrase length in bars. + seed: The development seed (None = the unseeded warning path). + beats_per_bar: The bar size the plan was spread against. + cadence: The cadence name the phrase closes on (``sentence()``/ + ``period()`` record it; ``develop()`` leaves it ``None``). + """ - source: Motif - plan: typing.Union[typing.Tuple[str, ...], str] - bars: int - seed: typing.Optional[int] - beats_per_bar: float = 4.0 - cadence: typing.Optional[str] = None + source: Motif + plan: typing.Union[typing.Tuple[str, ...], str] + bars: int + seed: typing.Optional[int] + beats_per_bar: float = 4.0 + cadence: typing.Optional[str] = None -def _contrast_unit (source: Motif, rng: random.Random) -> Motif: +def _contrast_unit(source: Motif, rng: random.Random) -> Motif: + """A generated contrast unit: the source's rhythm, freshly re-pitched. - """A generated contrast unit: the source's rhythm, freshly re-pitched. + Roughly half the pitched notes move (small melodic nudges), so the unit + is recognisably related but melodically new. The richer rhythm-first + generator arrives with the melody engine stage. + """ - Roughly half the pitched notes move (small melodic nudges), so the unit - is recognisably related but melodically new. The richer rhythm-first - generator arrives with the melody engine stage. - """ + pitched = sum(1 for event in source.events if event.pitch is not None) - pitched = sum(1 for event in source.events if event.pitch is not None) + return source.vary(notes=max(1, pitched // 2), position="anywhere", rng=rng) - return source.vary(notes = max(1, pitched // 2), position = "anywhere", rng = rng) +def _call_response_units(call: Motif, seed: typing.Optional[int]) -> typing.List[Motif]: + """The call_response recipe: call, answer, call, varied answer.""" -def _call_response_units (call: Motif, seed: typing.Optional[int]) -> typing.List[Motif]: + response = call.answer() + varied = response.vary( + notes=1, position="end", rng=random.Random(f"{seed}:cr:vary") + ) - """The call_response recipe: call, answer, call, varied answer.""" + return [call, response, call, varied] - response = call.answer() - varied = response.vary(notes = 1, position = "end", rng = random.Random(f"{seed}:cr:vary")) - return [call, response, call, varied] - - -def _tile_source (motif: Motif, bars: int, unit_count: int, beats_per_bar: float) -> Motif: - - """Validate the bars/unit arithmetic and tile the motif up to one unit. +def _tile_source( + motif: Motif, bars: int, unit_count: int, beats_per_bar: float +) -> Motif: + """Validate the bars/unit arithmetic and tile the motif up to one unit. - A 1-bar hook in 2-bar units repeats — the unit is the tile, and - answer()/vary() act on the whole tile (its tail is the unit's tail). - """ + A 1-bar hook in 2-bar units repeats — the unit is the tile, and + answer()/vary() act on the whole tile (its tail is the unit's tail). + """ - if bars % unit_count != 0: - raise ValueError( - f"bars={bars} does not divide evenly across {unit_count} plan units — " - "each unit must fill a whole number of bars" - ) + if bars % unit_count != 0: + raise ValueError( + f"bars={bars} does not divide evenly across {unit_count} plan units — " + "each unit must fill a whole number of bars" + ) - unit_beats = bars * beats_per_bar / unit_count + unit_beats = bars * beats_per_bar / unit_count - if motif.length <= 0: - raise ValueError("cannot develop an empty motif") + if motif.length <= 0: + raise ValueError("cannot develop an empty motif") - tiling = unit_beats / motif.length + tiling = unit_beats / motif.length - if abs(tiling - round(tiling)) > 1e-9 or round(tiling) < 1: - raise ValueError( - f"the motif is {motif.length:g} beats but each of the {unit_count} plan units " - f"spans {unit_beats:g} beats ({bars} bars / {unit_count} units) — units must be " - "a whole tiling of the motif (adjust bars, the plan, or the motif's length)" - ) + if abs(tiling - round(tiling)) > 1e-9 or round(tiling) < 1: + raise ValueError( + f"the motif is {motif.length:g} beats but each of the {unit_count} plan units " + f"spans {unit_beats:g} beats ({bars} bars / {unit_count} units) — units must be " + "a whole tiling of the motif (adjust bars, the plan, or the motif's length)" + ) - return motif if round(tiling) == 1 else Motif.join([motif] * int(round(tiling))) + return motif if round(tiling) == 1 else Motif.join([motif] * int(round(tiling))) # The curated recipe table — names reserved for plans whose semantics exceed # a label skeleton. Each entry is (unit_count, builder); the builder takes # (source_motif, seed) and returns exactly unit_count units. -_PHRASE_RECIPES: typing.Dict[str, typing.Tuple[int, typing.Callable[[Motif, typing.Optional[int]], typing.List[Motif]]]] = { - "call_response": (4, _call_response_units), +_PHRASE_RECIPES: typing.Dict[ + str, + typing.Tuple[ + int, typing.Callable[[Motif, typing.Optional[int]], typing.List[Motif]] + ], +] = { + "call_response": (4, _call_response_units), } @dataclasses.dataclass(frozen=True) class Phrase: + """ + A sequence of Motifs with segmentation preserved. + + Segmentation is the unit of editing — it is what development and + per-region regeneration operate on. ``flatten()`` erases it into one + long Motif. Length is the sum of segment lengths. + + A phrase made by :meth:`develop` carries its recipe, so + :meth:`reroll` can regenerate a region; transforms and hand edits + return recipe-less phrases (their notes no longer come from the + recipe, so there is nothing honest to regenerate from). + """ + + segments: typing.Tuple[Motif, ...] + recipe: typing.Optional[_PhraseRecipe] + + def __init__( + self, + segments: typing.Iterable[Motif], + recipe: typing.Optional[_PhraseRecipe] = None, + ) -> None: + """Coerce any iterable of Motifs.""" + + segments = tuple(segments) + + for segment in segments: + if not isinstance(segment, Motif): + raise TypeError( + f"Phrase segments must be Motifs — got {type(segment).__name__}" + ) + + object.__setattr__(self, "segments", segments) + object.__setattr__(self, "recipe", recipe) + + @property + def length(self) -> float: + """Total length in beats (sum of segment lengths).""" + + return sum(segment.length for segment in self.segments) + + @classmethod + def develop( + cls, + motif: Motif, + bars: int = 8, + plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, + seed: typing.Optional[int] = None, + beats_per_bar: float = 4.0, + ) -> "Phrase": + """Grow a motif into a phrase by a plan — the phrase generator. + + ``plan`` follows the standard form. The literal form is a **list of + unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently + ``["a"] * 3 + ["b"]``: the first label is the given motif, each new + label is a generated contrast unit (the source's rhythm, freshly + re-pitched), a repeated label is a restatement, and *bars* spreads + evenly across the units. A bare string is a **recipe name** from + the curated table — ``plan="call_response"`` (call, answer, call, + varied answer) — reserved for plans whose semantics exceed a label + skeleton. A letter string is not a plan: a sequence of labels is a + sequence, so it is a list. + + The result carries its recipe, so :meth:`reroll` can regenerate a + region later. + + Parameters: + motif: The source unit (its length must be ``bars / len(units)`` + bars — the plan's units tile the phrase exactly). + bars: Phrase length in bars (must divide evenly by the unit + count). + plan: A list of unit labels, or a recipe name. + seed: Seed for the generated units. Without one, develop() + warns — module-level nondeterminism breaks live reload. + beats_per_bar: Bar size in beats (the value is context-free; + 4 is the common-time default). + + Example: + ```python + call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) + lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) + ``` + """ + + if plan is None: + raise ValueError( + 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' + 'or a recipe name (plan="call_response")' + ) + + if seed is None: + warnings.warn( + "develop() without seed= is nondeterministic — pass seed= so the " + "value survives live reload", + stacklevel=2, + ) + + # How many units the plan asks for — known before any unit is built, + # so a short motif can tile up to the unit size first. + if isinstance(plan, str): + if plan not in _PHRASE_RECIPES: + known = ", ".join(sorted(_PHRASE_RECIPES)) + hint = "" + if ( + plan.isalpha() + and plan == plan.lower() + and len(set(plan)) < len(plan) + ): + spelled = ", ".join(repr(c) for c in plan) + hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." + raise ValueError( + f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}" + ) + unit_count = _PHRASE_RECIPES[plan][0] + else: + labels = list(plan) + if not labels or not all( + isinstance(label, str) and label for label in labels + ): + raise ValueError( + "plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']" + ) + unit_count = len(labels) + + source = _tile_source(motif, bars, unit_count, beats_per_bar) + + # An unseeded call draws a fresh salt so repeated calls genuinely + # differ, as the warning above promises — interpolating None gave the + # FIXED seed "None:..." and silently returned the same phrase every + # time. + salt = seed if seed is not None else random.randrange(2**32) + + if isinstance(plan, str): + units = _PHRASE_RECIPES[plan][1](source, salt) + stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan + else: + generated: typing.Dict[str, Motif] = {labels[0]: source} + for label in labels: + if label not in generated: + generated[label] = _contrast_unit( + source, random.Random(f"{salt}:unit:{label}") + ) + units = [generated[label] for label in labels] + stored_plan = tuple(labels) + + return cls( + units, + recipe=_PhraseRecipe( + source=motif, + plan=stored_plan, + bars=bars, + seed=seed, + beats_per_bar=beats_per_bar, + ), + ) + + def reroll( + self, + bar: typing.Optional[int] = None, + bars: typing.Optional[typing.Sequence[int]] = None, + seed: typing.Optional[int] = None, + ) -> "Phrase": + """Regenerate only the named bars — rhythm and boundary pitches kept. + + Within each named bar, the first and last pitched notes stay (the + boundary pins) and the interior pitches re-roll from a fresh per-bar + stream salted by ``seed=`` (an unseeded call draws a fresh salt, so + each call genuinely differs); onsets, durations, velocities, rests, + drums, and control gestures are untouched. Segmentation and the + recipe survive, so rerolls compose. + + Only a phrase that carries a recipe can reroll — a hand-written or + transformed phrase raises loudly (its notes no longer come from a + generator, so regenerating them would invent music). + + Parameters: + bar: A single 1-based bar to reroll. + bars: A list of 1-based bars (the paired plural spelling). + seed: Seed for the new pitches (salted per bar). Without one, + reroll() warns. + + Example: + ```python + lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept + ``` + """ + + if self.recipe is None: + raise ValueError( + "this phrase carries no recipe (it was written by hand, or transformed " + "since generation) — reroll() regenerates from a recipe; edit segments " + "with replace(), or rebuild with Phrase.develop()" + ) + + if (bar is None) == (bars is None): + raise ValueError( + "reroll() takes exactly one of bar= (an int) or bars= (a list)" + ) + + region = [bar] if bar is not None else list(bars or []) + beats_per_bar = self.recipe.beats_per_bar + total_bars = int(round(self.length / beats_per_bar)) + + for number in region: + if ( + not isinstance(number, int) + or isinstance(number, bool) + or not 1 <= number <= total_bars + ): + raise ValueError( + f"bar {number!r} is outside this phrase (1–{total_bars})" + ) + + if seed is None: + warnings.warn( + "reroll() without seed= is nondeterministic — pass seed= so the " + "value survives live reload", + stacklevel=2, + ) + + # Unseeded rerolls draw a fresh salt — a fixed "None:..." seed would + # "re-roll" to the identical pitches every time. + salt = seed if seed is not None else random.randrange(2**32) + + windows = [ + ( + (number - 1) * beats_per_bar, + number * beats_per_bar, + random.Random(f"{salt}:reroll:{number}"), + ) + for number in sorted(set(region)) + ] + + new_segments: typing.List[Motif] = [] + offset = 0.0 + + for segment in self.segments: + events = list(segment.events) + + for window_start, window_end, rng in windows: + inside = [ + index + for index, event in enumerate(events) + if window_start <= offset + event.beat < window_end + and event.pitch is not None + and not isinstance(event.pitch, str) + ] + + # Boundary pins: the first and last pitched notes of the bar + # stay; only the interior re-rolls. + for index in inside[1:-1]: + events[index] = dataclasses.replace( + events[index], + pitch=segment._nudged_pitch(events[index].pitch, rng), + ) + + new_segments.append( + Motif( + events=tuple(events), + length=segment.length, + controls=segment.controls, + ) + ) + offset += segment.length + + return Phrase(new_segments, recipe=self.recipe) + + def flatten(self) -> Motif: + """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" + + return Motif.join(self.segments) + + # ── algebra ───────────────────────────────────────────────────────── + + def __add__(self, other: typing.Any) -> "Phrase": + """Append a Motif segment, or concatenate another Phrase's segments.""" + + if isinstance(other, Motif): + return Phrase(self.segments + (other,)) + if isinstance(other, Phrase): + return Phrase(self.segments + other.segments) + + return NotImplemented + + def __radd__(self, other: typing.Any) -> "Phrase": + """A Motif on the left prepends as a segment.""" + + if isinstance(other, Motif): + return Phrase((other,) + self.segments) + + return NotImplemented + + def __mul__(self, count: int) -> "Phrase": + """Tile the segments *count* times.""" + + if not isinstance(count, int): + return NotImplemented + if count < 0: + raise ValueError(f"Repetition count must be non-negative — got {count}") - """ - A sequence of Motifs with segmentation preserved. - - Segmentation is the unit of editing — it is what development and - per-region regeneration operate on. ``flatten()`` erases it into one - long Motif. Length is the sum of segment lengths. - - A phrase made by :meth:`develop` carries its recipe, so - :meth:`reroll` can regenerate a region; transforms and hand edits - return recipe-less phrases (their notes no longer come from the - recipe, so there is nothing honest to regenerate from). - """ - - segments: typing.Tuple[Motif, ...] - recipe: typing.Optional[_PhraseRecipe] - - def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None: - - """Coerce any iterable of Motifs.""" - - segments = tuple(segments) - - for segment in segments: - if not isinstance(segment, Motif): - raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}") - - object.__setattr__(self, "segments", segments) - object.__setattr__(self, "recipe", recipe) - - @property - def length (self) -> float: - - """Total length in beats (sum of segment lengths).""" - - return sum(segment.length for segment in self.segments) - - @classmethod - def develop ( - cls, - motif: Motif, - bars: int = 8, - plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, - seed: typing.Optional[int] = None, - beats_per_bar: float = 4.0, - ) -> "Phrase": - - """Grow a motif into a phrase by a plan — the phrase generator. - - ``plan`` follows the standard form. The literal form is a **list of - unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently - ``["a"] * 3 + ["b"]``: the first label is the given motif, each new - label is a generated contrast unit (the source's rhythm, freshly - re-pitched), a repeated label is a restatement, and *bars* spreads - evenly across the units. A bare string is a **recipe name** from - the curated table — ``plan="call_response"`` (call, answer, call, - varied answer) — reserved for plans whose semantics exceed a label - skeleton. A letter string is not a plan: a sequence of labels is a - sequence, so it is a list. - - The result carries its recipe, so :meth:`reroll` can regenerate a - region later. - - Parameters: - motif: The source unit (its length must be ``bars / len(units)`` - bars — the plan's units tile the phrase exactly). - bars: Phrase length in bars (must divide evenly by the unit - count). - plan: A list of unit labels, or a recipe name. - seed: Seed for the generated units. Without one, develop() - warns — module-level nondeterminism breaks live reload. - beats_per_bar: Bar size in beats (the value is context-free; - 4 is the common-time default). - - Example: - ```python - call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) - lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) - ``` - """ - - if plan is None: - raise ValueError( - 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' - 'or a recipe name (plan="call_response")' - ) - - if seed is None: - warnings.warn( - "develop() without seed= is nondeterministic — pass seed= so the " - "value survives live reload", - stacklevel = 2, - ) - - # How many units the plan asks for — known before any unit is built, - # so a short motif can tile up to the unit size first. - if isinstance(plan, str): - if plan not in _PHRASE_RECIPES: - known = ", ".join(sorted(_PHRASE_RECIPES)) - hint = "" - if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan): - spelled = ", ".join(repr(c) for c in plan) - hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." - raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}") - unit_count = _PHRASE_RECIPES[plan][0] - else: - labels = list(plan) - if not labels or not all(isinstance(label, str) and label for label in labels): - raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']") - unit_count = len(labels) - - source = _tile_source(motif, bars, unit_count, beats_per_bar) - - # An unseeded call draws a fresh salt so repeated calls genuinely - # differ, as the warning above promises — interpolating None gave the - # FIXED seed "None:..." and silently returned the same phrase every - # time. - salt = seed if seed is not None else random.randrange(2 ** 32) - - if isinstance(plan, str): - units = _PHRASE_RECIPES[plan][1](source, salt) - stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan - else: - generated: typing.Dict[str, Motif] = {labels[0]: source} - for label in labels: - if label not in generated: - generated[label] = _contrast_unit(source, random.Random(f"{salt}:unit:{label}")) - units = [generated[label] for label in labels] - stored_plan = tuple(labels) - - return cls(units, recipe = _PhraseRecipe( - source = motif, - plan = stored_plan, - bars = bars, - seed = seed, - beats_per_bar = beats_per_bar, - )) - - def reroll ( - self, - bar: typing.Optional[int] = None, - bars: typing.Optional[typing.Sequence[int]] = None, - seed: typing.Optional[int] = None, - ) -> "Phrase": - - """Regenerate only the named bars — rhythm and boundary pitches kept. - - Within each named bar, the first and last pitched notes stay (the - boundary pins) and the interior pitches re-roll from a fresh per-bar - stream salted by ``seed=`` (an unseeded call draws a fresh salt, so - each call genuinely differs); onsets, durations, velocities, rests, - drums, and control gestures are untouched. Segmentation and the - recipe survive, so rerolls compose. - - Only a phrase that carries a recipe can reroll — a hand-written or - transformed phrase raises loudly (its notes no longer come from a - generator, so regenerating them would invent music). - - Parameters: - bar: A single 1-based bar to reroll. - bars: A list of 1-based bars (the paired plural spelling). - seed: Seed for the new pitches (salted per bar). Without one, - reroll() warns. - - Example: - ```python - lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept - ``` - """ - - if self.recipe is None: - raise ValueError( - "this phrase carries no recipe (it was written by hand, or transformed " - "since generation) — reroll() regenerates from a recipe; edit segments " - "with replace(), or rebuild with Phrase.develop()" - ) - - if (bar is None) == (bars is None): - raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)") - - region = [bar] if bar is not None else list(bars or []) - beats_per_bar = self.recipe.beats_per_bar - total_bars = int(round(self.length / beats_per_bar)) - - for number in region: - if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars: - raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})") - - if seed is None: - warnings.warn( - "reroll() without seed= is nondeterministic — pass seed= so the " - "value survives live reload", - stacklevel = 2, - ) - - # Unseeded rerolls draw a fresh salt — a fixed "None:..." seed would - # "re-roll" to the identical pitches every time. - salt = seed if seed is not None else random.randrange(2 ** 32) - - windows = [ - ((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{salt}:reroll:{number}")) - for number in sorted(set(region)) - ] - - new_segments: typing.List[Motif] = [] - offset = 0.0 - - for segment in self.segments: - - events = list(segment.events) - - for window_start, window_end, rng in windows: - - inside = [ - index for index, event in enumerate(events) - if window_start <= offset + event.beat < window_end - and event.pitch is not None and not isinstance(event.pitch, str) - ] - - # Boundary pins: the first and last pitched notes of the bar - # stay; only the interior re-rolls. - for index in inside[1:-1]: - events[index] = dataclasses.replace( - events[index], - pitch = segment._nudged_pitch(events[index].pitch, rng), - ) - - new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls)) - offset += segment.length - - return Phrase(new_segments, recipe = self.recipe) - - def flatten (self) -> Motif: - - """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" - - return Motif.join(self.segments) - - # ── algebra ───────────────────────────────────────────────────────── - - def __add__ (self, other: typing.Any) -> "Phrase": - - """Append a Motif segment, or concatenate another Phrase's segments.""" - - if isinstance(other, Motif): - return Phrase(self.segments + (other,)) - if isinstance(other, Phrase): - return Phrase(self.segments + other.segments) - - return NotImplemented - - def __radd__ (self, other: typing.Any) -> "Phrase": - - """A Motif on the left prepends as a segment.""" - - if isinstance(other, Motif): - return Phrase((other,) + self.segments) - - return NotImplemented - - def __mul__ (self, count: int) -> "Phrase": - - """Tile the segments *count* times.""" + return Phrase(self.segments * count) - if not isinstance(count, int): - return NotImplemented - if count < 0: - raise ValueError(f"Repetition count must be non-negative — got {count}") + __rmul__ = __mul__ - return Phrase(self.segments * count) + def __and__(self, other: typing.Any) -> Motif: + """Parallel merge is vertical: Phrase operands flatten to Motif first.""" - __rmul__ = __mul__ + if isinstance(other, (Motif, Phrase)): + return self.flatten().stack(other) - def __and__ (self, other: typing.Any) -> Motif: + return NotImplemented - """Parallel merge is vertical: Phrase operands flatten to Motif first.""" + def stack(self, other: typing.Union[Motif, "Phrase"]) -> Motif: + """The spelled form of ``&`` — flattens, then merges.""" - if isinstance(other, (Motif, Phrase)): - return self.flatten().stack(other) + return self.flatten().stack(other) - return NotImplemented + def slice(self, start: float, end: float) -> "Phrase": + """A window; re-segments at the cut points (partial segments are sliced).""" - def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif: + segments = [] + offset = 0.0 - """The spelled form of ``&`` — flattens, then merges.""" + for segment in self.segments: + seg_start, seg_end = offset, offset + segment.length + lo, hi = max(start, seg_start), min(end, seg_end) + if lo < hi: + segments.append(segment.slice(lo - seg_start, hi - seg_start)) + offset = seg_end - return self.flatten().stack(other) + return Phrase(segments) - def slice (self, start: float, end: float) -> "Phrase": + def replace(self, position: int, motif: Motif) -> "Phrase": + """Replace the segment at a 1-based position (musicians count from one).""" - """A window; re-segments at the cut points (partial segments are sliced).""" + if not 1 <= position <= len(self.segments): + raise IndexError( + f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)" + ) - segments = [] - offset = 0.0 + segments = list(self.segments) + segments[position - 1] = motif - for segment in self.segments: - seg_start, seg_end = offset, offset + segment.length - lo, hi = max(start, seg_start), min(end, seg_end) - if lo < hi: - segments.append(segment.slice(lo - seg_start, hi - seg_start)) - offset = seg_end + return Phrase(segments) - return Phrase(segments) + # ── transforms: lifted segment-wise, except time-reordering ───────── - def replace (self, position: int, motif: Motif) -> "Phrase": + def reverse(self) -> "Phrase": + """Reverse the whole timeline: segments reverse order AND each reverses internally.""" - """Replace the segment at a 1-based position (musicians count from one).""" + return Phrase(tuple(segment.reverse() for segment in reversed(self.segments))) - if not 1 <= position <= len(self.segments): - raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)") + def rotate(self, beats: float) -> "Phrase": + """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" - segments = list(self.segments) - segments[position - 1] = motif + flat = self.flatten().rotate(beats) + segments = [] + offset = 0.0 - return Phrase(segments) + # Re-segment by onset (events keep their full durations — a note may + # ring past its new segment, exactly as it does on the flat timeline). + for segment in self.segments: + lo, hi = offset, offset + segment.length + segments.append( + Motif( + events=tuple( + dataclasses.replace(e, beat=e.beat - lo) + for e in flat.events + if lo <= e.beat < hi + ), + length=segment.length, + controls=tuple( + dataclasses.replace(c, beat=c.beat - lo) + for c in flat.controls + if lo <= c.beat < hi + ), + ) + ) + offset = hi - # ── transforms: lifted segment-wise, except time-reordering ───────── + return Phrase(segments) - def reverse (self) -> "Phrase": + def _lift(self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase": + """Apply a Motif transform to every segment.""" - """Reverse the whole timeline: segments reverse order AND each reverses internally.""" + return Phrase( + tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments) + ) - return Phrase(tuple(segment.reverse() for segment in reversed(self.segments))) + def stretch(self, factor: float) -> "Phrase": + """Scale time in every segment (lengths scale with them).""" - def rotate (self, beats: float) -> "Phrase": + return self._lift("stretch", factor) - """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" + def quantize(self, grid: float) -> "Phrase": + """Snap note onsets segment-wise.""" - flat = self.flatten().rotate(beats) - segments = [] - offset = 0.0 + return self._lift("quantize", grid) - # Re-segment by onset (events keep their full durations — a note may - # ring past its new segment, exactly as it does on the flat timeline). - for segment in self.segments: - lo, hi = offset, offset + segment.length - segments.append(Motif( - events = tuple( - dataclasses.replace(e, beat=e.beat - lo) - for e in flat.events if lo <= e.beat < hi - ), - length = segment.length, - controls = tuple( - dataclasses.replace(c, beat=c.beat - lo) - for c in flat.controls if lo <= c.beat < hi - ), - )) - offset = hi + def with_velocity( + self, velocity: typing.Union[int, typing.Tuple[int, int]] + ) -> "Phrase": + """Replace every note's velocity, segment-wise.""" - return Phrase(segments) + return self._lift("with_velocity", velocity) - def _lift (self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase": + def pitched(self, spec: PitchSpec) -> "Phrase": + """Replace every pitch, segment-wise.""" - """Apply a Motif transform to every segment.""" + return self._lift("pitched", spec) - return Phrase(tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments)) + def rhythm(self) -> "Phrase": + """Strip pitches segment-wise: a phrase-shaped skeleton.""" - def stretch (self, factor: float) -> "Phrase": + return self._lift("rhythm") - """Scale time in every segment (lengths scale with them).""" + def transpose( + self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None + ) -> "Phrase": + """Transpose every segment (see :meth:`Motif.transpose`).""" - return self._lift("stretch", factor) + return self._lift("transpose", steps=steps, semitones=semitones) - def quantize (self, grid: float) -> "Phrase": + def invert(self, pivot: typing.Optional[int] = None) -> "Phrase": + """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" - """Snap note onsets segment-wise.""" + if pivot is None: + for segment in self.segments: + for event in segment.events: + if event.pitch is not None: + if isinstance(event.pitch, int): + pivot = event.pitch + elif isinstance(event.pitch, Degree): + pivot = event.pitch.step + break + if pivot is not None: + break - return self._lift("quantize", grid) + return self._lift("invert", pivot=pivot) - def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase": + def describe(self) -> str: + """A readable summary: total length and each segment on its own line.""" - """Replace every note's velocity, segment-wise.""" + header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" + lines = [ + f" {i + 1}. {segment.describe()}" + for i, segment in enumerate(self.segments) + ] - return self._lift("with_velocity", velocity) + return "\n".join([header] + lines) - def pitched (self, spec: PitchSpec) -> "Phrase": + def __str__(self) -> str: + """Printable form (same as :meth:`describe`).""" - """Replace every pitch, segment-wise.""" - - return self._lift("pitched", spec) - - def rhythm (self) -> "Phrase": - - """Strip pitches segment-wise: a phrase-shaped skeleton.""" - - return self._lift("rhythm") - - def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase": - - """Transpose every segment (see :meth:`Motif.transpose`).""" - - return self._lift("transpose", steps=steps, semitones=semitones) - - def invert (self, pivot: typing.Optional[int] = None) -> "Phrase": - - """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" - - if pivot is None: - for segment in self.segments: - for event in segment.events: - if event.pitch is not None: - if isinstance(event.pitch, int): - pivot = event.pitch - elif isinstance(event.pitch, Degree): - pivot = event.pitch.step - break - if pivot is not None: - break - - return self._lift("invert", pivot=pivot) - - def describe (self) -> str: - - """A readable summary: total length and each segment on its own line.""" - - header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" - lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)] - - return "\n".join([header] + lines) - - def __str__ (self) -> str: - - """Printable form (same as :meth:`describe`).""" - - return self.describe() - - -def motif ( - degrees: typing.List[typing.Union[int, Degree, None]], - beats: typing.Optional[typing.List[float]] = None, - velocities: typing.Any = _DEFAULT_VELOCITY, - durations: typing.Any = 1.0, - probabilities: typing.Any = 1.0, - length: typing.Optional[float] = None, + return self.describe() + + +def motif( + degrees: typing.List[typing.Union[int, Degree, None]], + beats: typing.Optional[typing.List[float]] = None, + velocities: typing.Any = _DEFAULT_VELOCITY, + durations: typing.Any = 1.0, + probabilities: typing.Any = 1.0, + length: typing.Optional[float] = None, ) -> Motif: - - """ - The lowercase shortcut: a melody as 1-based scale degrees. - - ``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` — - relative pitch is the primary form. For absolute MIDI note numbers use - ``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so - a pasted MIDI list fails loud instead of squealing octaves up. - """ - - return Motif.degrees( - degrees, - beats = beats, - velocities = velocities, - durations = durations, - probabilities = probabilities, - length = length, - ) - - -def sentence ( - motif: Motif, - bars: int = 8, - cadence: str = "strong", - seed: typing.Optional[int] = None, - beats_per_bar: float = 4.0, + """ + The lowercase shortcut: a melody as 1-based scale degrees. + + ``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` — + relative pitch is the primary form. For absolute MIDI note numbers use + ``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so + a pasted MIDI list fails loud instead of squealing octaves up. + """ + + return Motif.degrees( + degrees, + beats=beats, + velocities=velocities, + durations=durations, + probabilities=probabilities, + length=length, + ) + + +def sentence( + motif: Motif, + bars: int = 8, + cadence: str = "strong", + seed: typing.Optional[int] = None, + beats_per_bar: float = 4.0, ) -> Phrase: - - """The classical sentence, as a thin combinator — idea, idea, drive, close. - - Four units: the basic idea stated twice (the presentation), a generated - contrast unit (the continuation — the source's rhythm, freshly - re-pitched), and a second contrast unit whose tail lands on the - cadence's close degree (the cadential close). An 8-bar sentence from a - 2-bar idea is the textbook proportion; a shorter idea tiles up to the - unit size first. - - The melodic side of a cadence only — pair it with the harmonic side - (``prog.cadence()``, ``Progression.generate(cadence=)``, or - ``request_cadence()``) and the two arrive together. - - Parameters: - motif: The basic idea (degree content — the close re-aims a degree). - bars: Sentence length (must divide evenly across the 4 units). - cadence: The close — ``"strong"`` lands on 1, ``"open"`` on 5, - ``"soft"``/``"fakeout"`` on 1 (theory aliases accepted). - seed: Seed for the generated continuation units (seed-or-warn). - beats_per_bar: Bar size in beats (context-free; 4 is the default). - - Example: - ```python - idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) - verse_lead = subsequence.sentence(idea, bars=8, cadence="open", seed=11) - ``` - """ - - spec = subsequence.cadences.cadence_formula(cadence) - - if seed is None: - warnings.warn( - "sentence() without seed= is nondeterministic — pass seed= so the " - "value survives live reload", - stacklevel = 2, - ) - - source = _tile_source(motif, bars, 4, beats_per_bar) - - # Unseeded calls draw a fresh salt (a fixed "None:..." seed would return - # the same sentence every time, belying the warning above). - salt = seed if seed is not None else random.randrange(2 ** 32) - - continuation = _contrast_unit(source, random.Random(f"{salt}:sentence:continuation")) - cadential = _contrast_unit(source, random.Random(f"{salt}:sentence:cadential")).answer(to = spec.close_degree) - - return Phrase([source, source, continuation, cadential], recipe = _PhraseRecipe( - source = motif, - plan = "sentence", - bars = bars, - seed = seed, - beats_per_bar = beats_per_bar, - cadence = spec.name, - )) - - -def period ( - antecedent: typing.Union[Motif, Phrase], - cadence: str = "strong", - beats_per_bar: float = 4.0, + """The classical sentence, as a thin combinator — idea, idea, drive, close. + + Four units: the basic idea stated twice (the presentation), a generated + contrast unit (the continuation — the source's rhythm, freshly + re-pitched), and a second contrast unit whose tail lands on the + cadence's close degree (the cadential close). An 8-bar sentence from a + 2-bar idea is the textbook proportion; a shorter idea tiles up to the + unit size first. + + The melodic side of a cadence only — pair it with the harmonic side + (``prog.cadence()``, ``Progression.generate(cadence=)``, or + ``request_cadence()``) and the two arrive together. + + Parameters: + motif: The basic idea (degree content — the close re-aims a degree). + bars: Sentence length (must divide evenly across the 4 units). + cadence: The close — ``"strong"`` lands on 1, ``"open"`` on 5, + ``"soft"``/``"fakeout"`` on 1 (theory aliases accepted). + seed: Seed for the generated continuation units (seed-or-warn). + beats_per_bar: Bar size in beats (context-free; 4 is the default). + + Example: + ```python + idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) + verse_lead = subsequence.sentence(idea, bars=8, cadence="open", seed=11) + ``` + """ + + spec = subsequence.cadences.cadence_formula(cadence) + + if seed is None: + warnings.warn( + "sentence() without seed= is nondeterministic — pass seed= so the " + "value survives live reload", + stacklevel=2, + ) + + source = _tile_source(motif, bars, 4, beats_per_bar) + + # Unseeded calls draw a fresh salt (a fixed "None:..." seed would return + # the same sentence every time, belying the warning above). + salt = seed if seed is not None else random.randrange(2**32) + + continuation = _contrast_unit( + source, random.Random(f"{salt}:sentence:continuation") + ) + cadential = _contrast_unit( + source, random.Random(f"{salt}:sentence:cadential") + ).answer(to=spec.close_degree) + + return Phrase( + [source, source, continuation, cadential], + recipe=_PhraseRecipe( + source=motif, + plan="sentence", + bars=bars, + seed=seed, + beats_per_bar=beats_per_bar, + cadence=spec.name, + ), + ) + + +def period( + antecedent: typing.Union[Motif, Phrase], + cadence: str = "strong", + beats_per_bar: float = 4.0, ) -> Phrase: - - """The classical period, as a thin combinator — question, then answer. - - Two halves: the antecedent with its tail re-aimed to the open half-close - (degree 5 — the question), then the same material restated with its tail - on the cadence's close degree (the answer). The two halves differ - exactly at their closes — the open/closed contrast *is* the period. - - Deterministic: no notes are generated, only the two tail notes re-aim - (so there is no seed). Vary the consequent yourself for a looser - restatement: ``period(a).reroll(bar=7, seed=4)``. - - Parameters: - antecedent: The first half — a Motif, or a Phrase whose segmentation - is kept (only its last segment's tail re-aims). - cadence: The consequent's close — ``"strong"`` lands on 1 (theory - aliases accepted). - beats_per_bar: Bar size in beats, recorded for ``reroll()`` windows. - - Example: - ```python - idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4], length=8) - lead = subsequence.period(idea) # 16 beats: half-close, then home - ``` - """ - - spec = subsequence.cadences.cadence_formula(cadence) - open_degree = subsequence.cadences.cadence_formula("open").close_degree - - units = list(antecedent.segments) if isinstance(antecedent, Phrase) else [antecedent] - - if not units or sum(unit.length for unit in units) <= 0: - raise ValueError("cannot build a period from an empty antecedent") - - tail = units[-1] - - antecedent_units = units[:-1] + [tail.answer(to = open_degree)] - consequent_units = units[:-1] + [tail.answer(to = spec.close_degree)] - - source = antecedent.flatten() if isinstance(antecedent, Phrase) else antecedent - total_beats = 2 * sum(unit.length for unit in units) - - return Phrase(antecedent_units + consequent_units, recipe = _PhraseRecipe( - source = source, - plan = "period", - bars = int(round(total_beats / beats_per_bar)), - seed = None, - beats_per_bar = beats_per_bar, - cadence = spec.name, - )) + """The classical period, as a thin combinator — question, then answer. + + Two halves: the antecedent with its tail re-aimed to the open half-close + (degree 5 — the question), then the same material restated with its tail + on the cadence's close degree (the answer). The two halves differ + exactly at their closes — the open/closed contrast *is* the period. + + Deterministic: no notes are generated, only the two tail notes re-aim + (so there is no seed). Vary the consequent yourself for a looser + restatement: ``period(a).reroll(bar=7, seed=4)``. + + Parameters: + antecedent: The first half — a Motif, or a Phrase whose segmentation + is kept (only its last segment's tail re-aims). + cadence: The consequent's close — ``"strong"`` lands on 1 (theory + aliases accepted). + beats_per_bar: Bar size in beats, recorded for ``reroll()`` windows. + + Example: + ```python + idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4], length=8) + lead = subsequence.period(idea) # 16 beats: half-close, then home + ``` + """ + + spec = subsequence.cadences.cadence_formula(cadence) + open_degree = subsequence.cadences.cadence_formula("open").close_degree + + units = ( + list(antecedent.segments) if isinstance(antecedent, Phrase) else [antecedent] + ) + + if not units or sum(unit.length for unit in units) <= 0: + raise ValueError("cannot build a period from an empty antecedent") + + tail = units[-1] + + antecedent_units = units[:-1] + [tail.answer(to=open_degree)] + consequent_units = units[:-1] + [tail.answer(to=spec.close_degree)] + + source = antecedent.flatten() if isinstance(antecedent, Phrase) else antecedent + total_beats = 2 * sum(unit.length for unit in units) + + return Phrase( + antecedent_units + consequent_units, + recipe=_PhraseRecipe( + source=source, + plan="period", + bars=int(round(total_beats / beats_per_bar)), + seed=None, + beats_per_bar=beats_per_bar, + cadence=spec.name, + ), + ) diff --git a/subsequence/osc.py b/subsequence/osc.py index 10c0191..1a52654 100644 --- a/subsequence/osc.py +++ b/subsequence/osc.py @@ -30,165 +30,156 @@ import pythonosc.udp_client if typing.TYPE_CHECKING: - from subsequence.composition import Composition + from subsequence.composition import Composition logger = logging.getLogger(__name__) class OscServer: - - """Async OSC server/client for bi-directional communication.""" - - def __init__ ( - self, - composition: "Composition", - receive_port: int = 9000, - send_port: int = 9001, - send_host: str = "127.0.0.1", - receive_host: str = "0.0.0.0" - ) -> None: - - """ - Wire up the OSC ports and built-in control handlers; call start() to begin listening. - """ - - self._composition = composition - self._receive_port = receive_port - self._receive_host = receive_host - self._send_port = send_port - self._send_host = send_host - - self._server: typing.Optional[typing.Any] = None - self._transport: typing.Optional[asyncio.BaseTransport] = None - self._client: typing.Optional[pythonosc.udp_client.SimpleUDPClient] = None - self._dispatcher = pythonosc.dispatcher.Dispatcher() - - # Register built-in handlers - self._dispatcher.map("/bpm", self._handle_bpm) - self._dispatcher.map("/mute/*", self._handle_mute) - self._dispatcher.map("/unmute/*", self._handle_unmute) - self._dispatcher.map("/data/*", self._handle_data) - - - async def start (self) -> None: - - """Start the OSC server and client.""" - - # client for sending - self._client = pythonosc.udp_client.SimpleUDPClient(self._send_host, self._send_port) - - # server for receiving - self._server = pythonosc.osc_server.AsyncIOOSCUDPServer( - (self._receive_host, self._receive_port), - self._dispatcher, - asyncio.get_event_loop() # type: ignore[arg-type] - ) - - transport, _ = await self._server.create_serve_endpoint() - self._transport = transport - - logger.info(f"OSC listening on :{self._receive_port}, sending to {self._send_host}:{self._send_port}") - - - async def stop (self) -> None: - - """Stop the OSC server and close the outgoing client socket.""" - - if self._transport: - self._transport.close() - self._transport = None - logger.info("OSC server stopped") - - if self._client is not None: - # python-osc's SimpleUDPClient owns a raw socket; close it so a - # stopped composition doesn't keep a zombie sender alive. - try: - self._client._sock.close() - except (AttributeError, OSError): - pass - self._client = None - - - def send (self, address: str, *args: typing.Any) -> None: - - """Send an OSC message.""" - - if self._client: - try: - self._client.send_message(address, args) - except Exception as e: - logger.warning(f"OSC send error: {e}") - - - def map (self, address: str, handler: typing.Callable) -> None: - - """Register a custom OSC handler.""" - - self._dispatcher.map(address, handler) - - - # Handlers - - def _handle_bpm (self, address: str, *args: typing.Any) -> None: - - """ - Set the composition tempo from an incoming ``/bpm `` message. - """ - - if not args: - return - - try: - bpm = int(args[0]) - self._composition.set_bpm(bpm) - except (ValueError, TypeError): - logger.warning(f"Invalid OSC BPM argument: {args[0]}") - - def _handle_mute (self, address: str, *args: typing.Any) -> None: - - """ - Silence the pattern named in an incoming ``/mute/`` message. - """ - - # address is like /mute/drums - parts = address.split("/") - if len(parts) >= 3: - name = parts[2] - self._composition.mute(name) - - def _handle_unmute (self, address: str, *args: typing.Any) -> None: - - """ - Bring back the pattern named in an incoming ``/unmute/`` message. - """ - - parts = address.split("/") - if len(parts) >= 3: - name = parts[2] - self._composition.unmute(name) - - def _handle_data (self, address: str, *args: typing.Any) -> None: - - """ - Update a composition.data value from an incoming ``/data/ `` message, preserving the existing numeric type. - """ - - # address is like /data/intensity - if not args: - return - - parts = address.split("/") - if len(parts) >= 3: - key = parts[2] - val = args[0] - if key in self._composition.data: - existing = self._composition.data[key] - if isinstance(existing, (float, int)): - try: - val = float(val) if isinstance(existing, float) else int(val) - except (ValueError, TypeError): - logger.warning(f"OSC /data: failed to cast {val} to numeric for key {key}") - return - - self._composition.data[key] = val + """Async OSC server/client for bi-directional communication.""" + + def __init__( + self, + composition: "Composition", + receive_port: int = 9000, + send_port: int = 9001, + send_host: str = "127.0.0.1", + receive_host: str = "0.0.0.0", + ) -> None: + """ + Wire up the OSC ports and built-in control handlers; call start() to begin listening. + """ + + self._composition = composition + self._receive_port = receive_port + self._receive_host = receive_host + self._send_port = send_port + self._send_host = send_host + + self._server: typing.Optional[typing.Any] = None + self._transport: typing.Optional[asyncio.BaseTransport] = None + self._client: typing.Optional[pythonosc.udp_client.SimpleUDPClient] = None + self._dispatcher = pythonosc.dispatcher.Dispatcher() + + # Register built-in handlers + self._dispatcher.map("/bpm", self._handle_bpm) + self._dispatcher.map("/mute/*", self._handle_mute) + self._dispatcher.map("/unmute/*", self._handle_unmute) + self._dispatcher.map("/data/*", self._handle_data) + + async def start(self) -> None: + """Start the OSC server and client.""" + + # client for sending + self._client = pythonosc.udp_client.SimpleUDPClient( + self._send_host, self._send_port + ) + + # server for receiving + self._server = pythonosc.osc_server.AsyncIOOSCUDPServer( + (self._receive_host, self._receive_port), + self._dispatcher, + asyncio.get_event_loop(), # type: ignore[arg-type] + ) + + transport, _ = await self._server.create_serve_endpoint() + self._transport = transport + + logger.info( + f"OSC listening on :{self._receive_port}, sending to {self._send_host}:{self._send_port}" + ) + + async def stop(self) -> None: + """Stop the OSC server and close the outgoing client socket.""" + + if self._transport: + self._transport.close() + self._transport = None + logger.info("OSC server stopped") + + if self._client is not None: + # python-osc's SimpleUDPClient owns a raw socket; close it so a + # stopped composition doesn't keep a zombie sender alive. + try: + self._client._sock.close() + except (AttributeError, OSError): + pass + self._client = None + + def send(self, address: str, *args: typing.Any) -> None: + """Send an OSC message.""" + + if self._client: + try: + self._client.send_message(address, args) + except Exception as e: + logger.warning(f"OSC send error: {e}") + + def map(self, address: str, handler: typing.Callable) -> None: + """Register a custom OSC handler.""" + + self._dispatcher.map(address, handler) + + # Handlers + + def _handle_bpm(self, address: str, *args: typing.Any) -> None: + """ + Set the composition tempo from an incoming ``/bpm `` message. + """ + + if not args: + return + + try: + bpm = int(args[0]) + self._composition.set_bpm(bpm) + except (ValueError, TypeError): + logger.warning(f"Invalid OSC BPM argument: {args[0]}") + + def _handle_mute(self, address: str, *args: typing.Any) -> None: + """ + Silence the pattern named in an incoming ``/mute/`` message. + """ + + # address is like /mute/drums + parts = address.split("/") + if len(parts) >= 3: + name = parts[2] + self._composition.mute(name) + + def _handle_unmute(self, address: str, *args: typing.Any) -> None: + """ + Bring back the pattern named in an incoming ``/unmute/`` message. + """ + + parts = address.split("/") + if len(parts) >= 3: + name = parts[2] + self._composition.unmute(name) + + def _handle_data(self, address: str, *args: typing.Any) -> None: + """ + Update a composition.data value from an incoming ``/data/ `` message, preserving the existing numeric type. + """ + + # address is like /data/intensity + if not args: + return + + parts = address.split("/") + if len(parts) >= 3: + key = parts[2] + val = args[0] + if key in self._composition.data: + existing = self._composition.data[key] + if isinstance(existing, (float, int)): + try: + val = float(val) if isinstance(existing, float) else int(val) + except (ValueError, TypeError): + logger.warning( + f"OSC /data: failed to cast {val} to numeric for key {key}" + ) + return + + self._composition.data[key] = val diff --git a/subsequence/pattern.py b/subsequence/pattern.py index 5c7a615..b0c6a75 100644 --- a/subsequence/pattern.py +++ b/subsequence/pattern.py @@ -21,343 +21,388 @@ # user-facing entry points accept any of these (and lists, for JSON sources); # ``Composition._resolve_mirrors`` normalises channel numbering before storing. MirrorSpec = typing.Union[ - typing.Tuple[int, int], - typing.Tuple[int, int, typing.Optional[typing.Dict[str, int]]], + typing.Tuple[int, int], + typing.Tuple[int, int, typing.Optional[typing.Dict[str, int]]], ] @dataclasses.dataclass class Note: + """ + Represents a single MIDI note. + """ - """ - Represents a single MIDI note. - """ - - pitch: int - velocity: int - duration: int - channel: int - origin: typing.Optional[str] = None # Original drum-name string (if the pitch was named), kept so mirror destinations can re-resolve it through their own drum_note_map. None for numeric/pitched notes. - primary_unmapped: bool = False # True when origin was NOT in the pattern's own (primary) drum_note_map — the primary device has no such voice, so it stays silent; only mirror destinations whose maps contain origin sound it. pitch then holds a placeholder (a mirror's value) used only by transforms/display, never for playback. + pitch: int + velocity: int + duration: int + channel: int + origin: typing.Optional[str] = ( + None # Original drum-name string (if the pitch was named), kept so mirror destinations can re-resolve it through their own drum_note_map. None for numeric/pitched notes. + ) + primary_unmapped: bool = False # True when origin was NOT in the pattern's own (primary) drum_note_map — the primary device has no such voice, so it stays silent; only mirror destinations whose maps contain origin sound it. pitch then holds a placeholder (a mirror's value) used only by transforms/display, never for playback. @dataclasses.dataclass class CcEvent: - - """ - A MIDI non-note event (CC, pitch bend, program change, SysEx) at a pulse position. - """ - - pulse: int - message_type: str # 'control_change', 'pitchwheel', 'program_change', or 'sysex' - control: int = 0 # CC number (0–127), ignored for other types - value: int = 0 # 0–127 for CC/program_change, -8192..8191 for pitchwheel - data: typing.Optional[bytes] = None # Raw bytes payload for SysEx messages - channel: typing.Optional[int] = None # If set, overrides pattern.channel for this event - device: typing.Optional[int] = None # If set, overrides pattern.device for this event - priority: int = 0 # Same-pulse dispatch order vs notes: negative fires BEFORE note_on (tuning onset bends), 0 keeps FIFO order + """ + A MIDI non-note event (CC, pitch bend, program change, SysEx) at a pulse position. + """ + + pulse: int + message_type: str # 'control_change', 'pitchwheel', 'program_change', or 'sysex' + control: int = 0 # CC number (0–127), ignored for other types + value: int = 0 # 0–127 for CC/program_change, -8192..8191 for pitchwheel + data: typing.Optional[bytes] = None # Raw bytes payload for SysEx messages + channel: typing.Optional[int] = ( + None # If set, overrides pattern.channel for this event + ) + device: typing.Optional[int] = ( + None # If set, overrides pattern.device for this event + ) + priority: int = 0 # Same-pulse dispatch order vs notes: negative fires BEFORE note_on (tuning onset bends), 0 keeps FIFO order @dataclasses.dataclass class RawNoteEvent: + """ + An explicit Note On or Note Off event at a pulse position, ignoring durations. + Used for drones and infinite notes. + """ - """ - An explicit Note On or Note Off event at a pulse position, ignoring durations. - Used for drones and infinite notes. - """ - - pulse: int - message_type: str # 'note_on' or 'note_off' - pitch: int - velocity: int = 0 - origin: typing.Optional[str] = None # Original drum-name string, kept so mirror destinations re-resolve it through their own drum_note_map (same contract as Note.origin) - primary_unmapped: bool = False # Kept for _destination_pitch compatibility; always False for drones (an unvoiceable name is dropped at build time) + pulse: int + message_type: str # 'note_on' or 'note_off' + pitch: int + velocity: int = 0 + origin: typing.Optional[str] = ( + None # Original drum-name string, kept so mirror destinations re-resolve it through their own drum_note_map (same contract as Note.origin) + ) + primary_unmapped: bool = False # Kept for _destination_pitch compatibility; always False for drones (an unvoiceable name is dropped at build time) @dataclasses.dataclass class OscEvent: + """ + An OSC message scheduled at a pulse position within a pattern. + """ - """ - An OSC message scheduled at a pulse position within a pattern. - """ - - pulse: int - address: str - args: typing.Tuple[typing.Any, ...] = () + pulse: int + address: str + args: typing.Tuple[typing.Any, ...] = () @dataclasses.dataclass class Step: + """ + Represents a collection of notes at a single point in time. + """ - """ - Represents a collection of notes at a single point in time. - """ - - notes: typing.List[Note] = dataclasses.field(default_factory=list) + notes: typing.List[Note] = dataclasses.field(default_factory=list) class Pattern: - - """ - Allows us to define and manipulate music pattern objects. - """ - - def __init__ (self, channel: int, length: float = 16, reschedule_lookahead: float = 1, device: int = 0, mirrors: typing.Optional[typing.Iterable[MirrorSpec]] = None) -> None: - - """ - Initialize a new pattern with MIDI channel, length in beats, and reschedule lookahead. - - Parameters: - channel: The MIDI channel (0-15) this pattern will output to. - length: The duration of the pattern before it loops/rebuilds, measured - in beats (e.g., 16 = 4 bars in 4/4 time). Defaults to 16. - reschedule_lookahead: How many beats before the end of the pattern the next - cycle is built. Defaults to 1 beat. This provides a safe computational - buffer so events are queued before the clock actually needs them. - device: Output device index (0-indexed). 0 = primary device (default). - mirrors: Additional ``(device, channel)`` destinations to duplicate every - note, CC, pitch bend, program change, SysEx, NRPN/RPN burst, and - drone event onto. Both ``device`` and ``channel`` are 0-indexed in - canonical form; the user-facing entry points (decorator and runtime - API on ``Composition``) translate the user's channel-numbering - convention before storing here. An entry may carry an optional - third element — a ``drum_note_map`` — so a mirrored drum hit is - re-resolved by name to that device's own note number (see - ``Sequencer.schedule_pattern``). - """ - - self.channel = channel - self.length = length - self.reschedule_lookahead = reschedule_lookahead - self.device = device - self.mirrors: typing.List[MirrorSpec] = list(mirrors) if mirrors else [] - - # Set to True by ``Composition.unregister()`` to signal the sequencer's - # reschedule loop to stop re-adding this pattern. Lazy removal: events - # already queued in ``event_queue`` play out; sustaining notes are - # stopped by the unregister() call, but no new cycles fire. - self._removed: bool = False - - # Absolute pulse where the cycle currently being (re)built starts. - # Written by the sequencer on schedule and on every reschedule; read - # by rebuilds that place the cycle on the absolute beat axis (the - # harmony window). - self._cycle_start_pulse: int = 0 - - self.steps: typing.Dict[int, Step] = {} - self.cc_events: typing.List[CcEvent] = [] - self.osc_events: typing.List[OscEvent] = [] - self.raw_note_events: typing.List[RawNoteEvent] = [] - - # Drum names already warned about (absent from every destination map) - # so the per-cycle rebuild warns once, not every bar. A hot-reload - # builds a fresh Pattern, which resets this — re-surfacing the warning. - self._warned_drum_names: typing.Set[str] = set() - - # Likewise warn once if a positioned chord/strum (beat != 0) uses sustain=/detached=, - # which size their ring from the pattern length rather than from beat. - self._warned_positioned_articulation: bool = False - - - def add_note (self, position: int, pitch: int, velocity: int, duration: int, origin: typing.Optional[str] = None, primary_unmapped: bool = False) -> None: - - """ - Add a note to the pattern at a specific pulse position. - - ``origin`` is the original drum-name string when the pitch was named - (e.g. ``"hi_hat_closed"``), or ``None`` for numeric pitches. It is - carried on the Note so mirror destinations can re-resolve the name - through their own ``drum_note_map`` — see ``Sequencer.schedule_pattern``. - - ``primary_unmapped`` marks a named hit whose ``origin`` is absent from - this pattern's own ``drum_note_map`` but present in a mirror's — the - primary device can't voice it, so it stays silent and only the mapping - mirror(s) sound it. - """ - - if position not in self.steps: - self.steps[position] = Step() - - note = Note( - pitch = pitch, - velocity = velocity, - duration = duration, - channel = self.channel, - origin = origin, - primary_unmapped = primary_unmapped - ) - - self.steps[position].notes.append(note) - - - def add_sequence (self, sequence: typing.List[int], spacing_pulses: int, pitch: int, velocity: typing.Union[int, typing.List[int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, note_duration: int = 6) -> None: - - """ - Add a sequence of notes to the pattern. - """ - - if isinstance(velocity, int): - velocity = [velocity] * len(sequence) - - # An explicit empty velocity list with hits to place has no velocity - # to give them — say so instead of a bare ZeroDivisionError at the - # modulo below (matches the builder-level _expand_sequence_param). - if not velocity and any(sequence): - raise ValueError("add_sequence(): velocity list cannot be empty") - - for i, hit in enumerate(sequence): - - if hit: - - # Handle case where velocity list might be shorter than sequence - vel = velocity[i % len(velocity)] - - self.add_note( - position = i * spacing_pulses, - pitch = pitch, - velocity = int(vel), - duration = note_duration - ) - - def add_note_beats (self, beat_position: float, pitch: int, velocity: int, duration_beats: float, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, origin: typing.Optional[str] = None, primary_unmapped: bool = False) -> None: - - """ - Add a note to the pattern at a beat position. - - ``origin`` and ``primary_unmapped`` are forwarded to ``add_note`` so - the resulting Note carries the drum name and its primary-map status. - """ - - if beat_position < 0: - raise ValueError("Beat position cannot be negative") - - if duration_beats <= 0: - raise ValueError("Beat duration must be positive") - - if pulses_per_beat <= 0: - raise ValueError("Pulses per beat must be positive") - - position = int(beat_position * pulses_per_beat) - - # A positive duration shorter than one pulse clamps to one pulse — - # the shortest sound the clock can represent — matching the duration - # transforms (legato/detached/stretch), which clamp the same way. - duration = max(1, int(duration_beats * pulses_per_beat)) - - self.add_note( - position = position, - pitch = pitch, - velocity = velocity, - duration = duration, - origin = origin, - primary_unmapped = primary_unmapped - ) - - - def add_sequence_beats (self, sequence: typing.List[int], spacing_beats: float, pitch: int, velocity: typing.Union[int, typing.List[int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, note_duration_beats: float = 0.25, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE) -> None: - - """ - Add a sequence of notes using beat durations. - """ - - if spacing_beats <= 0: - raise ValueError("Spacing must be positive") - - if note_duration_beats <= 0: - raise ValueError("Note duration must be positive") - - if pulses_per_beat <= 0: - raise ValueError("Pulses per beat must be positive") - - spacing_pulses = int(spacing_beats * pulses_per_beat) - note_duration = int(note_duration_beats * pulses_per_beat) - - if spacing_pulses <= 0: - raise ValueError("Spacing must be at least one pulse") - - if note_duration <= 0: - raise ValueError("Note duration must be at least one pulse") - - self.add_sequence( - sequence = sequence, - spacing_pulses = spacing_pulses, - pitch = pitch, - velocity = velocity, - note_duration = note_duration - ) - - def add_arpeggio_beats (self, pitches: typing.List[int], spacing_beats: float, velocity: int = subsequence.constants.velocity.DEFAULT_VELOCITY, duration_beats: typing.Optional[float] = None, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE) -> None: - - """ - Add an arpeggio that cycles through pitches at regular intervals. - """ - - if not pitches: - raise ValueError("Pitches list cannot be empty") - - if spacing_beats <= 0: - raise ValueError("Spacing must be positive") - - if pulses_per_beat <= 0: - raise ValueError("Pulses per beat must be positive") - - if duration_beats is None: - duration_beats = spacing_beats - - if duration_beats <= 0: - raise ValueError("Note duration must be positive") - - beat = 0.0 - pitch_index = 0 - - while beat < self.length: - pitch = pitches[pitch_index % len(pitches)] - self.add_note_beats( - beat_position = beat, - pitch = pitch, - velocity = velocity, - duration_beats = duration_beats, - pulses_per_beat = pulses_per_beat - ) - beat += spacing_beats - pitch_index += 1 - - - def add_raw_note_beats (self, message_type: str, beat_position: float, pitch: int, velocity: int = 0, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, origin: typing.Optional[str] = None) -> None: - - """ - Add a raw Note On or Note Off event at a beat position (ignores duration). - - ``origin`` carries the drum-name string (if the pitch was named) so - mirror destinations can re-resolve it through their own maps. - """ - - if message_type not in ('note_on', 'note_off'): - raise ValueError("message_type must be 'note_on' or 'note_off'") - - if beat_position < 0: - raise ValueError("Beat position cannot be negative") - - if pulses_per_beat <= 0: - raise ValueError("Pulses per beat must be positive") - - position = int(beat_position * pulses_per_beat) - - self.raw_note_events.append( - RawNoteEvent( - pulse = position, - message_type = message_type, - pitch = pitch, - velocity = velocity, - origin = origin - ) - ) - - - def on_reschedule (self) -> None: - - """ - Hook called immediately before the pattern is rescheduled. - """ - - return None + """ + Allows us to define and manipulate music pattern objects. + """ + + def __init__( + self, + channel: int, + length: float = 16, + reschedule_lookahead: float = 1, + device: int = 0, + mirrors: typing.Optional[typing.Iterable[MirrorSpec]] = None, + ) -> None: + """ + Initialize a new pattern with MIDI channel, length in beats, and reschedule lookahead. + + Parameters: + channel: The MIDI channel (0-15) this pattern will output to. + length: The duration of the pattern before it loops/rebuilds, measured + in beats (e.g., 16 = 4 bars in 4/4 time). Defaults to 16. + reschedule_lookahead: How many beats before the end of the pattern the next + cycle is built. Defaults to 1 beat. This provides a safe computational + buffer so events are queued before the clock actually needs them. + device: Output device index (0-indexed). 0 = primary device (default). + mirrors: Additional ``(device, channel)`` destinations to duplicate every + note, CC, pitch bend, program change, SysEx, NRPN/RPN burst, and + drone event onto. Both ``device`` and ``channel`` are 0-indexed in + canonical form; the user-facing entry points (decorator and runtime + API on ``Composition``) translate the user's channel-numbering + convention before storing here. An entry may carry an optional + third element — a ``drum_note_map`` — so a mirrored drum hit is + re-resolved by name to that device's own note number (see + ``Sequencer.schedule_pattern``). + """ + + self.channel = channel + self.length = length + self.reschedule_lookahead = reschedule_lookahead + self.device = device + self.mirrors: typing.List[MirrorSpec] = list(mirrors) if mirrors else [] + + # Set to True by ``Composition.unregister()`` to signal the sequencer's + # reschedule loop to stop re-adding this pattern. Lazy removal: events + # already queued in ``event_queue`` play out; sustaining notes are + # stopped by the unregister() call, but no new cycles fire. + self._removed: bool = False + + # Absolute pulse where the cycle currently being (re)built starts. + # Written by the sequencer on schedule and on every reschedule; read + # by rebuilds that place the cycle on the absolute beat axis (the + # harmony window). + self._cycle_start_pulse: int = 0 + + self.steps: typing.Dict[int, Step] = {} + self.cc_events: typing.List[CcEvent] = [] + self.osc_events: typing.List[OscEvent] = [] + self.raw_note_events: typing.List[RawNoteEvent] = [] + + # Drum names already warned about (absent from every destination map) + # so the per-cycle rebuild warns once, not every bar. A hot-reload + # builds a fresh Pattern, which resets this — re-surfacing the warning. + self._warned_drum_names: typing.Set[str] = set() + + # Likewise warn once if a positioned chord/strum (beat != 0) uses sustain=/detached=, + # which size their ring from the pattern length rather than from beat. + self._warned_positioned_articulation: bool = False + + def add_note( + self, + position: int, + pitch: int, + velocity: int, + duration: int, + origin: typing.Optional[str] = None, + primary_unmapped: bool = False, + ) -> None: + """ + Add a note to the pattern at a specific pulse position. + + ``origin`` is the original drum-name string when the pitch was named + (e.g. ``"hi_hat_closed"``), or ``None`` for numeric pitches. It is + carried on the Note so mirror destinations can re-resolve the name + through their own ``drum_note_map`` — see ``Sequencer.schedule_pattern``. + + ``primary_unmapped`` marks a named hit whose ``origin`` is absent from + this pattern's own ``drum_note_map`` but present in a mirror's — the + primary device can't voice it, so it stays silent and only the mapping + mirror(s) sound it. + """ + + if position not in self.steps: + self.steps[position] = Step() + + note = Note( + pitch=pitch, + velocity=velocity, + duration=duration, + channel=self.channel, + origin=origin, + primary_unmapped=primary_unmapped, + ) + + self.steps[position].notes.append(note) + + def add_sequence( + self, + sequence: typing.List[int], + spacing_pulses: int, + pitch: int, + velocity: typing.Union[ + int, typing.List[int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + note_duration: int = 6, + ) -> None: + """ + Add a sequence of notes to the pattern. + """ + + if isinstance(velocity, int): + velocity = [velocity] * len(sequence) + + # An explicit empty velocity list with hits to place has no velocity + # to give them — say so instead of a bare ZeroDivisionError at the + # modulo below (matches the builder-level _expand_sequence_param). + if not velocity and any(sequence): + raise ValueError("add_sequence(): velocity list cannot be empty") + + for i, hit in enumerate(sequence): + if hit: + # Handle case where velocity list might be shorter than sequence + vel = velocity[i % len(velocity)] + + self.add_note( + position=i * spacing_pulses, + pitch=pitch, + velocity=int(vel), + duration=note_duration, + ) + + def add_note_beats( + self, + beat_position: float, + pitch: int, + velocity: int, + duration_beats: float, + pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, + origin: typing.Optional[str] = None, + primary_unmapped: bool = False, + ) -> None: + """ + Add a note to the pattern at a beat position. + + ``origin`` and ``primary_unmapped`` are forwarded to ``add_note`` so + the resulting Note carries the drum name and its primary-map status. + """ + + if beat_position < 0: + raise ValueError("Beat position cannot be negative") + + if duration_beats <= 0: + raise ValueError("Beat duration must be positive") + + if pulses_per_beat <= 0: + raise ValueError("Pulses per beat must be positive") + + position = int(beat_position * pulses_per_beat) + + # A positive duration shorter than one pulse clamps to one pulse — + # the shortest sound the clock can represent — matching the duration + # transforms (legato/detached/stretch), which clamp the same way. + duration = max(1, int(duration_beats * pulses_per_beat)) + + self.add_note( + position=position, + pitch=pitch, + velocity=velocity, + duration=duration, + origin=origin, + primary_unmapped=primary_unmapped, + ) + + def add_sequence_beats( + self, + sequence: typing.List[int], + spacing_beats: float, + pitch: int, + velocity: typing.Union[ + int, typing.List[int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + note_duration_beats: float = 0.25, + pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, + ) -> None: + """ + Add a sequence of notes using beat durations. + """ + + if spacing_beats <= 0: + raise ValueError("Spacing must be positive") + + if note_duration_beats <= 0: + raise ValueError("Note duration must be positive") + + if pulses_per_beat <= 0: + raise ValueError("Pulses per beat must be positive") + + spacing_pulses = int(spacing_beats * pulses_per_beat) + note_duration = int(note_duration_beats * pulses_per_beat) + + if spacing_pulses <= 0: + raise ValueError("Spacing must be at least one pulse") + + if note_duration <= 0: + raise ValueError("Note duration must be at least one pulse") + + self.add_sequence( + sequence=sequence, + spacing_pulses=spacing_pulses, + pitch=pitch, + velocity=velocity, + note_duration=note_duration, + ) + + def add_arpeggio_beats( + self, + pitches: typing.List[int], + spacing_beats: float, + velocity: int = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration_beats: typing.Optional[float] = None, + pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, + ) -> None: + """ + Add an arpeggio that cycles through pitches at regular intervals. + """ + + if not pitches: + raise ValueError("Pitches list cannot be empty") + + if spacing_beats <= 0: + raise ValueError("Spacing must be positive") + + if pulses_per_beat <= 0: + raise ValueError("Pulses per beat must be positive") + + if duration_beats is None: + duration_beats = spacing_beats + + if duration_beats <= 0: + raise ValueError("Note duration must be positive") + + beat = 0.0 + pitch_index = 0 + + while beat < self.length: + pitch = pitches[pitch_index % len(pitches)] + self.add_note_beats( + beat_position=beat, + pitch=pitch, + velocity=velocity, + duration_beats=duration_beats, + pulses_per_beat=pulses_per_beat, + ) + beat += spacing_beats + pitch_index += 1 + + def add_raw_note_beats( + self, + message_type: str, + beat_position: float, + pitch: int, + velocity: int = 0, + pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, + origin: typing.Optional[str] = None, + ) -> None: + """ + Add a raw Note On or Note Off event at a beat position (ignores duration). + + ``origin`` carries the drum-name string (if the pitch was named) so + mirror destinations can re-resolve it through their own maps. + """ + + if message_type not in ("note_on", "note_off"): + raise ValueError("message_type must be 'note_on' or 'note_off'") + + if beat_position < 0: + raise ValueError("Beat position cannot be negative") + + if pulses_per_beat <= 0: + raise ValueError("Pulses per beat must be positive") + + position = int(beat_position * pulses_per_beat) + + self.raw_note_events.append( + RawNoteEvent( + pulse=position, + message_type=message_type, + pitch=pitch, + velocity=velocity, + origin=origin, + ) + ) + + def on_reschedule(self) -> None: + """ + Hook called immediately before the pattern is rescheduled. + """ + + return None diff --git a/subsequence/pattern_algorithmic.py b/subsequence/pattern_algorithmic.py index 27aea73..350aac7 100644 --- a/subsequence/pattern_algorithmic.py +++ b/subsequence/pattern_algorithmic.py @@ -17,1994 +17,2143 @@ class PatternAlgorithmicMixin: - - """Algorithmic and generative note-placement methods for PatternBuilder. - - All methods here operate on ``self._pattern`` (a ``Pattern`` instance) - and ``self.rng`` (a ``random.Random`` instance), both of which are set - by ``PatternBuilder.__init__``. - """ - - # ── Instance attributes provided by PatternBuilder at runtime ──────── - # Declared here so mypy can type-check all methods in this mixin. - - _pattern: subsequence.pattern.Pattern - _default_grid: int - rng: random.Random - cycle: int - data: typing.Dict[str, typing.Any] - key: typing.Optional[str] - scale: typing.Optional[str] - - if typing.TYPE_CHECKING: - # Cross-mixin method stubs: implemented by PatternBuilder, - # called from methods in this mixin. - import subsequence.pattern_builder # noqa: F401 — type-checking only - def note ( - self, - pitch: typing.Union[int, str], - beat: float, - velocity: typing.Union[int, typing.Tuple[int, int]], - duration: float, - ) -> "subsequence.pattern_builder.PatternBuilder": ... - def _resolve_pitch (self, pitch: typing.Union[int, str]) -> int: ... - def _resolve_pitch_lenient (self, pitch: typing.Union[int, str]) -> typing.Optional[int]: ... - def _has_pitch_at_beat (self, pitch: typing.Union[int, str], beat: float) -> bool: ... - - def _rng_from (self, seed: typing.Optional[int], rng: typing.Optional[random.Random]) -> random.Random: - - """Resolve the effective random generator for a generative call. - - Determinism has one friendly knob — ``seed=`` (an int) — and one advanced - form — ``rng=`` (a ``random.Random`` instance). Precedence, most explicit - first: - - 1. ``rng=`` — an explicit generator you supplied (wins; warns if ``seed=`` was also given). - 2. ``seed=`` — a fresh ``random.Random(seed)``, fixed for this call. - 3. ``self.rng`` — the pattern's own generator (the default; reproducible under the composition seed). - """ - - if rng is not None: - if seed is not None: - warnings.warn("seed= and rng= were both given — rng= wins; pass only one", stacklevel=3) - return rng - - if seed is not None: - return random.Random(seed) - - return self.rng - - def _resolve_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]], rng: typing.Optional[random.Random] = None) -> int: - - """Resolve a velocity argument to a single integer. - - Accepts a plain ``int`` (returned unchanged) or a ``(low, high)`` - tuple from which a random value is drawn via ``rng.randint``. - Centralised here so every note-placement method offers the same - idiom — ``velocity=(60, 90)`` works wherever ``velocity=`` is - accepted. - - Raises ``TypeError`` for any other shape so a typo surfaces at - the call site rather than as a malformed MIDI event later in - the sequencer dispatch loop. - - Parameters: - velocity: An int 0-127 or a ``(low, high)`` 2-tuple - describing an inclusive random range. - rng: Random generator. Defaults to ``self.rng``. - - Returns: - A single integer velocity. - """ - - if isinstance(velocity, tuple): - if len(velocity) != 2: - raise ValueError(f"velocity tuple must be (low, high), got {velocity!r}") - - low, high = int(velocity[0]), int(velocity[1]) - if low > high: - raise ValueError(f"velocity range must be (low, high) with low <= high, got {velocity!r}") - - if rng is None: - rng = self.rng - - return rng.randint(low, high) - if isinstance(velocity, bool): - raise TypeError(f"velocity must be int or (low, high) tuple, got bool: {velocity!r}") - if isinstance(velocity, (int, float)): - return int(velocity) - raise TypeError(f"velocity must be int or (low, high) tuple, got {type(velocity).__name__}: {velocity!r}") - - def _place_gated_sequence ( - self, - sequence: typing.Sequence[typing.Any], - event_for: typing.Callable[[int, typing.Any], typing.Optional[typing.Tuple[typing.Union[int, str], typing.Union[int, typing.Tuple[int, int]], float]]], - probability: float, - rng: random.Random, - no_overlap: bool = False, - ) -> None: - - """Place per-step events from a sequence, gated by probability and overlap. - - The shared placement kernel: steps are evenly spaced across the pattern - length; for each step, ``event_for(index, value)`` returns either - ``None`` (a silent step — no probability draw is consumed) or a - ``(pitch, velocity, duration)`` event. Surviving events pass the - probability gate and, when ``no_overlap`` is set, the same-pitch check, - then land via ``self.note()`` (which resolves ``(low, high)`` velocity - tuples per hit). - """ - - if not sequence: - return # nothing to place (e.g. a pattern shorter than one step) - - step_duration = self._pattern.length / len(sequence) - - for i, value in enumerate(sequence): - - event = event_for(i, value) - - if event is None: - continue - - if probability < 1.0 and rng.random() < (1.0 - probability): - continue - - pitch, velocity, duration = event - - if no_overlap and self._has_pitch_at_beat(pitch, i * step_duration): - continue - - self.note(pitch=pitch, beat=i * step_duration, velocity=velocity, duration=duration) - - def _place_rhythm_sequence ( - self, - sequence: typing.List[int], - pitch: typing.Union[int, str], - velocity: typing.Union[int, typing.Tuple[int, int]], - duration: float, - probability: float, - rng: random.Random, - no_overlap: bool = False - ) -> None: - - """Place hits from a binary sequence into the pattern. - - Shared implementation for ``euclidean()``, ``bresenham()``, and the - other single-voice binary-rhythm verbs. Each active step (1) becomes - a note; zeros are rests. - """ - - def _event (i: int, hit_value: typing.Any) -> typing.Optional[typing.Tuple[typing.Union[int, str], typing.Union[int, typing.Tuple[int, int]], float]]: - if hit_value == 0: - return None - return (pitch, velocity, duration) - - self._place_gated_sequence(sequence, _event, probability, rng, no_overlap=no_overlap) - - def euclidean (self, pitch: typing.Union[int, str], pulses: int, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, duration: float = 0.1, probability: float = 1.0, no_overlap: bool = False, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Generate a Euclidean rhythm. - - This distributes a fixed number of 'pulses' as evenly as possible - across the pattern. This produces many of the world's most - common musical rhythms. - - Parameters: - pitch: MIDI note or drum name. - pulses: Total number of notes to place. - velocity: MIDI velocity, or a ``(low, high)`` tuple for a - fresh random draw per hit. - duration: Note duration. - probability: Chance (0.0–1.0) that each pulse plays — 1.0 places them all, lower thins the rhythm. - seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - no_overlap: If True, skip steps where a note of the same pitch - already exists. Useful for layering ghost notes around - hand-placed anchors. - - Example: - ```python - # A classic 3-against-16 rhythm - p.euclidean("kick", pulses=3) - ``` - """ - rng = self._rng_from(seed, rng) - - steps = self._default_grid - sequence = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) - self._place_rhythm_sequence(sequence, pitch, velocity, duration, probability, rng, no_overlap=no_overlap) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def bresenham (self, pitch: typing.Union[int, str], pulses: int, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, duration: float = 0.1, probability: float = 1.0, no_overlap: bool = False, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Generate a rhythm using the Bresenham line algorithm. - - This is an alternative to Euclidean rhythms that often results in - slightly different (but still mathematically even) distributions. - - Parameters: - pitch: MIDI note or drum name. - pulses: Total number of notes to place. - velocity: MIDI velocity, or a ``(low, high)`` tuple for a - fresh random draw per hit. - duration: Note duration. - probability: Chance (0.0–1.0) that each pulse plays — 1.0 places them all, lower thins the rhythm. - seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - no_overlap: If True, skip steps where a note of the same pitch - already exists. Useful for layering ghost notes around - hand-placed anchors. - """ - rng = self._rng_from(seed, rng) - - steps = self._default_grid - sequence = subsequence.sequence_utils.generate_bresenham_sequence(steps=steps, pulses=pulses) - self._place_rhythm_sequence(sequence, pitch, velocity, duration, probability, rng, no_overlap=no_overlap) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def bresenham_poly ( - self, - parts: typing.Dict[typing.Union[int, str], float], - velocity: typing.Union[int, typing.Dict[typing.Union[int, str], int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, - duration: float = 0.1, - grid: typing.Optional[int] = None, - probability: float = 1.0, - no_overlap: bool = False, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Distribute multiple drum voices across the pattern using weighted Bresenham. - - Each step is assigned to exactly one voice - voices never overlap, producing - interlocking rhythmic patterns. Density weights control how frequently each - voice fires. If the weights sum to less than 1.0, the remainder becomes - evenly-distributed rests (silent steps). - - Because notes are placed via ``self.note()``, all post-placement transforms - (``groove``, ``randomize``, ``velocity_shape``, ``rotate``, etc.) work normally. - - Parameters: - parts: Mapping of pitch (MIDI note or drum name) to density weight. - Higher weight means more hits per bar. Weights in the range (0, 1] - are typical; a weight of 0.5 targets roughly one hit every two steps. - velocity: Either a single MIDI velocity applied to all voices, or a dict - mapping each pitch to its own velocity. Pitches absent from the dict - fall back to the default velocity (100). - duration: Note duration in beats (default 0.1). - grid: Number of steps to divide the pattern into. Defaults to the - pattern's ``default_grid``. - probability: Chance (0.0–1.0) that each hit plays — 1.0 places them all, lower thins. - seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. - no_overlap: If True, skip steps where a note of the same pitch already - exists. Useful for layering ghost notes around hand-placed anchors. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - p.bresenham_poly( - parts={"kick_1": 0.25, "snare_1": 0.125, "hi_hat_closed": 0.5}, - velocity={"kick_1": 100, "snare_1": 90, "hi_hat_closed": 70}, - ) - ``` - - Layering with hand-placed hits: - ```python - # Algorithmic base - interlocking texture, no overlaps within this layer - p.bresenham_poly( - parts={"hi_hat_closed": 0.5, "snare_2": 0.1}, - velocity={"hi_hat_closed": 65, "snare_2": 40}, - ) - # Hand-placed anchors on top - these CAN overlap the algorithmic layer - p.hit_steps("kick_1", [0, 8], velocity=110) - p.hit_steps("snare_1", [4, 12], velocity=100) - ``` - - Stable vs shifting patterns: - Because the algorithm redistributes all positions when weights change, - a single voice with a continuously ramping density will shift positions - every bar. This is great for background texture (hats, shakers) but - can sound jarring for prominent, distinctive sounds (claps, cowbells). - - **For stable patterns** - use ``bresenham()`` with integer pulses. - Positions stay fixed until the pulse count steps up:: - - pulses = max(1, round(density * 16)) - p.bresenham("hand_clap", pulses=pulses, velocity=95) - - **For shifting texture** - use ``bresenham_poly()`` with continuous - density. Positions evolve every bar:: - - p.bresenham_poly(parts={"hi_hat_closed": density}, velocity=70) - - **To stabilise a solo voice** - pair it with a second voice. More - voices in a single call means less positional shift per voice:: - - p.bresenham_poly( - parts={"hand_clap": 0.12, "snare_2": 0.08}, - velocity={"hand_clap": 95, "snare_2": 40}, - ) - """ - - if not parts: - raise ValueError("parts dict cannot be empty") - - if any(w < 0 for w in parts.values()): - raise ValueError("All density weights must be non-negative") - rng = self._rng_from(seed, rng) - - if grid is None: - grid = self._default_grid - - voice_names = list(parts.keys()) - weights = [parts[name] for name in voice_names] - - # If weights don't fill the bar, add an implicit rest voice. - weight_sum = sum(weights) - rest_index: typing.Optional[int] = None - if weight_sum < 1.0: - rest_index = len(voice_names) - weights.append(1.0 - weight_sum) - - sequence = subsequence.sequence_utils.generate_bresenham_sequence_weighted( - steps=grid, weights=weights - ) - - def _event (step_idx: int, voice_idx: typing.Any) -> typing.Optional[typing.Tuple[typing.Union[int, str], typing.Union[int, typing.Tuple[int, int]], float]]: - if voice_idx == rest_index: - return None - - pitch = voice_names[voice_idx] - - if isinstance(velocity, dict): - vel = velocity.get(pitch, subsequence.constants.velocity.DEFAULT_VELOCITY) - else: - vel = velocity - - return (pitch, vel, duration) - - self._place_gated_sequence(sequence, _event, probability, rng, no_overlap=no_overlap) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - @staticmethod - def build_ghost_bias (grid: int, bias: str) -> typing.List[float]: - - """Build probability weights for ghost notes or other generative functions. - - Generates a list of probability weights (values between 0.0 and 1.0) spanning - a given grid size. These curves shape probability over a beat, - assigning higher or lower chances of an event occurring based on the rhythmic - position within the beat (downbeat, offbeat, syncopated 16th note, etc). - - This is a public escape hatch: call it yourself, manipulate the returned list, - then pass the result as ``bias=`` to :meth:`ghost_fill()`. This lets you pin - specific steps, boost a weak position, or combine two named curves. - - Parameters: - grid: The total number of steps in the sequence (usually 16 or 32). - bias: The probability distribution shape to generate: - - - ``"uniform"`` - 1.0 everywhere. - - ``"offbeat"`` - 1.0 on 8th note off-beats (&), 0.3 on 16ths (e/a), 0.05 on downbeats. - - ``"sixteenths"`` - 1.0 on 16th notes (e/a), 0.3 on 8th off-beats (&), 0.05 on downbeats. - - ``"before"`` - 1.0 preceding a beat, 0.25 on other 16ths, 0.05 on beats. - - ``"after"`` - 1.0 following a beat, 0.25 on other 16ths, 0.05 on beats. - - ``"downbeat"`` - 1.0 on downbeats, 0.15 on 8th off-beats, 0.05 on other 16ths. - - ``"upbeat"`` - 1.0 on 8th note off-beats only, 0.05 everywhere else. - - ``"e_and_a"`` - 1.0 on all non-downbeat 16th positions, 0.05 on downbeats. - - Returns: - A ``List[float]`` of length ``grid`` where each value is a probability - multiplier from 0.0 to 1.0. The list is a plain Python list — modify - it freely before passing to ``ghost_fill(bias=...)``. - - Example: - ```python - # Start from a named curve, then zero out beat 3 (step 8) entirely - # and give the step before the snare (step 11) maximum weight. - weights = p.build_ghost_bias(16, "sixteenths") - weights[8] = 0.0 # silence around beat 3 - weights[11] = 1.0 # boost the "and" before beat 4 - p.ghost_fill("snare_1", density=0.25, velocity=(25, 45), - bias=weights, no_overlap=True) - ``` - """ - - steps_per_beat = max(1, grid // 4) - weights: typing.List[float] = [] - - for i in range(grid): - pos = i % steps_per_beat - - if bias == "uniform": - weights.append(1.0) - elif bias == "offbeat": - if pos == 0: - weights.append(0.05) - elif steps_per_beat > 1 and pos == steps_per_beat // 2: - weights.append(1.0) - else: - weights.append(0.3) - elif bias == "sixteenths": - if pos == 0: - weights.append(0.05) - elif steps_per_beat > 1 and pos == steps_per_beat // 2: - weights.append(0.3) - else: - weights.append(1.0) - elif bias == "before": - if pos == steps_per_beat - 1: - weights.append(1.0) - elif pos == 0: - weights.append(0.05) - else: - weights.append(0.25) - elif bias == "after": - if steps_per_beat > 1 and pos == 1: - weights.append(1.0) - elif pos == 0: - weights.append(0.05) - else: - weights.append(0.25) - elif bias == "downbeat": - if pos == 0: - weights.append(1.0) - elif steps_per_beat > 1 and pos == steps_per_beat // 2: - weights.append(0.15) - else: - weights.append(0.05) - elif bias == "upbeat": - if steps_per_beat > 1 and pos == steps_per_beat // 2: - weights.append(1.0) - else: - weights.append(0.05) - elif bias == "e_and_a": - if pos == 0: - weights.append(0.05) - else: - weights.append(1.0) - else: - raise ValueError( - f"Unknown ghost_fill bias {bias!r}. " - f"Use 'uniform', 'offbeat', 'sixteenths', 'before', 'after', " - f"'downbeat', 'upbeat', 'e_and_a', or a list of floats." - ) - - return weights - - def ghost_fill ( - self, - pitch: typing.Union[int, str], - density: float = 0.3, - velocity: typing.Union[ - int, - typing.Tuple[int, int], - typing.Sequence[typing.Union[int, float]], - typing.Callable[[int], typing.Union[int, float]] - ] = subsequence.constants.velocity.GHOST_FILL_VELOCITY, - bias: typing.Union[str, typing.List[float]] = "uniform", - no_overlap: bool = True, - grid: typing.Optional[int] = None, - duration: float = 0.1, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Fill the pattern with probability-biased ghost notes. - - A single method for generating musically-aware ghost note layers. - Combines density control, velocity randomisation, and rhythmic bias - to produce the micro-detail layering heard in dense electronic - music production. - - Parameters: - pitch: MIDI note number or drum name. - density: Overall density (0.0–1.0). How many available steps - receive ghost notes. 0.3 = roughly 30% of steps at peak bias. - velocity: Single velocity, ``(low, high)`` tuple for random range, - a list/sequence of values (indexed by step), or a callable - that takes the step index ``i`` and returns a velocity. - Allows dynamic values like Perlin noise curves. - bias: Probability distribution shape: - - - ``"uniform"`` - equal probability everywhere - - ``"offbeat"`` - prefer 8th-note off-beats (&) - - ``"sixteenths"`` - prefer 16th-note subdivisions (e/a) - - ``"before"`` - cluster just before beat positions - - ``"after"`` - cluster just after beat positions - - ``"downbeat"`` - reinforce the beat (inverse of offbeat) - - ``"upbeat"`` - strictly 8th-note off-beats only - - ``"e_and_a"`` - all non-downbeat 16th positions - - Or: a list of floats (one per grid step) for a custom field. - Use :meth:`build_ghost_bias` to generate a named curve - and then modify specific steps before passing it here. - - no_overlap: If True (default), skip where same pitch already exists. - Essential for layering ghosts around hand-placed anchors. - grid: Grid resolution. Defaults to the pattern's default grid. - duration: Note duration in beats (default 0.1). - seed: Fix the ghost layer for this call (an int); omit to use the - pattern's RNG. - - **Tip — freeze the layer each cycle:** ``seed=`` starts a fresh - random stream on every rebuild, so the same steps — and the same - ``(low, high)`` velocity draws — are chosen on every cycle: the - ghost layer is locked in place. The default ``self.rng`` - advances state across rebuilds, so placement differs every cycle. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) - p.hit_steps("snare_1", [4, 12], velocity=95) - - # Different ghost placement each cycle (default) - p.ghost_fill("kick_1", density=0.2, velocity=(30, 45), - bias="sixteenths", no_overlap=True) - - # The same ghost layer every cycle — placement frozen - p.ghost_fill("snare_1", density=0.15, velocity=(25, 40), - bias="before", seed=42) - ``` - """ - - rng = self._rng_from(seed, rng) - - if grid is None: - grid = self._default_grid - - if grid <= 0: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - if isinstance(bias, list): - weights = list(bias) - if len(weights) < grid: - weights.extend([weights[-1] if weights else 0.0] * (grid - len(weights))) - elif len(weights) > grid: - weights = weights[:grid] - else: - weights = self.build_ghost_bias(grid, bias) - - max_weight = max(weights) if weights else 1.0 - - if max_weight <= 0: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - step_duration = self._pattern.length / grid - - for i in range(grid): - prob = density * weights[i] / max_weight - - if rng.random() >= prob: - continue - - if no_overlap and self._has_pitch_at_beat(pitch, i * step_duration): - continue - - if callable(velocity): - vel = int(velocity(i)) - elif isinstance(velocity, tuple): - if len(velocity) != 2: - raise ValueError(f"ghost_fill velocity tuple must be (low, high); for a per-step sequence use a list, got {velocity!r}") - vel = rng.randint(int(velocity[0]), int(velocity[1])) - elif isinstance(velocity, list): - vel = int(velocity[i % len(velocity)]) - else: - vel = int(typing.cast(typing.Union[int, float], velocity)) - - self.note(pitch=pitch, beat=i * step_duration, velocity=vel, duration=duration) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def cellular_1d ( - self, - pitch: typing.Union[int, str], - rule: int = 30, - generation: typing.Optional[int] = None, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CA_VELOCITY, - duration: float = 0.1, - no_overlap: bool = False, - probability: float = 1.0, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate an evolving rhythm using a 1D cellular automaton. - - Uses an elementary CA (1D binary cellular automaton) to produce - rhythmic patterns that change organically each bar. The CA state - evolves by one generation per cycle, creating patterns that are - deterministic yet surprising - structured chaos. - - Rule 30 is the default: it produces quasi-random patterns with hidden - self-similarity. Rule 90 produces fractal patterns. Rule 110 is - Turing-complete. - - Parameters: - pitch: MIDI note number or drum name. - rule: Wolfram rule number (0–255). Default 30. - generation: CA generation to render. Defaults to ``self.cycle`` - so the pattern evolves each bar automatically. - velocity: MIDI velocity, or a ``(low, high)`` tuple for a - fresh random draw per hit. - duration: Note duration in beats. - no_overlap: If True, skip where same pitch already exists. - probability: Chance (0.0–1.0) that each hit plays — 1.0 places them all, lower thins. - seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - p.hit_steps("kick_1", [0, 8], velocity=100) - p.cellular_1d("kick_1", rule=30, velocity=40, no_overlap=True) - ``` - """ - - if generation is None: - generation = self.cycle - rng = self._rng_from(seed, rng) - - steps = self._default_grid - sequence = subsequence.sequence_utils.generate_cellular_automaton_1d( - steps=steps, rule=rule, generation=generation - ) - - self._place_rhythm_sequence( - sequence, pitch, velocity, duration, probability, rng, no_overlap=no_overlap - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def cellular_2d ( - self, - pitches: typing.List[typing.Union[int, str]], - rule: str = "B368/S245", - generation: typing.Optional[int] = None, - velocity: typing.Union[int, typing.Tuple[int, int], typing.List[int]] = subsequence.constants.velocity.DEFAULT_CA_VELOCITY, - duration: float = 0.1, - no_overlap: bool = False, - probability: float = 1.0, - initial_state: typing.Union[str, typing.List[typing.List[int]]] = "center", - density: float = 0.5, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate polyphonic patterns using a 2D Life-like cellular automaton. - - Evolves a 2D grid where rows map to pitches or instruments and columns - map to time steps. Live cells in the final generation become note - onsets, producing patterns with spatial structure that evolves each bar. - - The default rule B368/S245 (Morley/"Move") produces chaotic, active - patterns. B3/S23 is Conway's Life; B36/S23 is HighLife. - - Parameters: - pitches: MIDI note numbers or drum names, one per row. Row 0 - maps to the first pitch. - rule: Birth/Survival notation, e.g. ``"B3/S23"`` for Conway's - Life, ``"B368/S245"`` for Morley. - generation: CA generation to render. Defaults to ``self.cycle`` - so the grid evolves each bar automatically. - velocity: Single MIDI velocity for all rows, or a list with one - value per row. - duration: Note duration in beats. - no_overlap: If True, skip notes where same pitch already exists. - probability: Chance (0.0–1.0) that each live cell plays — 1.0 places them all, lower thins. - initial_state: The generation-0 grid. ``"center"`` (default) lights a - single cell at the centre; ``"random"`` fills cells with probability - *density* (seed it with *seed* for a reproducible fill); or pass an - explicit ``list[list[int]]`` (rows × cols) for a custom start. - density: Fill probability for ``initial_state="random"`` (0.0–1.0). - seed: RNG seed (an int) for the ``"random"`` initial grid, so it - reproduces. Ignored for ``"center"`` or an explicit grid (a - warning is emitted if passed there). - rng: Random generator for the probability thinning. Defaults to - ``self.rng``. - - Example: - ```python - pitches = [36, 38, 42, 46] # kick, snare, hihat, open hihat - p.cellular_2d(pitches, rule="B3/S23", initial_state="random", seed=7, density=0.3) - ``` - """ - - if not pitches: - raise ValueError("pitches list cannot be empty") - - if isinstance(velocity, list) and not velocity: - raise ValueError("velocity list cannot be empty") - - if generation is None: - generation = self.cycle - - if rng is None: - rng = self.rng - - # Translate (initial_state, seed) into the underlying generator's seed arg, - # which stays int-or-grid: 1 = single centre cell, any other int = an - # RNG-seeded fill at *density*, a grid = an explicit starting state. - grid_seed: typing.Union[int, typing.List[typing.List[int]]] - if isinstance(initial_state, list): - grid_seed = initial_state - elif initial_state == "center": - grid_seed = 1 - elif initial_state == "random": - if isinstance(seed, int): - # The generator reserves 1 as its centre-cell sentinel, so remap - # seed=1 to a fixed surrogate - every seed stays deterministic. - grid_seed = seed if seed != 1 else -1 - else: - grid_seed = rng.randint(2, 2_147_483_646) - else: - raise ValueError(f"cellular_2d(): initial_state must be \"center\", \"random\", or a grid — got {initial_state!r}") - - if seed is not None and initial_state != "random": - warnings.warn( - f"cellular_2d(): seed= only affects initial_state=\"random\" and is ignored for " - f"initial_state={initial_state!r} — pass initial_state=\"random\" to seed the grid", - UserWarning, - stacklevel = 2, - ) - - cols = self._default_grid - rows = len(pitches) - - grid = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=rows, - cols=cols, - rule=rule, - generation=generation, - seed=grid_seed, - density=density, - ) - - for row_idx, pitch in enumerate(pitches): - row_velocity: typing.Union[int, typing.Tuple[int, int]] - - if isinstance(velocity, list): - row_velocity = int(velocity[row_idx % len(velocity)]) - elif isinstance(velocity, tuple): - # (low, high) range - resolved per placed note downstream. - row_velocity = velocity - else: - row_velocity = int(velocity) - - self._place_rhythm_sequence( - grid[row_idx], pitch, row_velocity, duration, probability, rng, no_overlap=no_overlap - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def markov ( - self, - transitions: typing.Dict[str, typing.List[typing.Tuple[str, int]]], - pitch_map: typing.Dict[str, int], - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, - duration: float = 0.1, - spacing: float = 0.25, - start: typing.Optional[str] = None, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a sequence by walking a first-order Markov chain. - - Builds a :class:`~subsequence.weighted_graph.WeightedGraph` from - ``transitions`` and walks it, placing one note per ``spacing`` beats. - The probability of each next state depends only on the current one - - use this to generate basslines, melodies, or rhythm motifs that have - stylistic coherence without being perfectly repetitive. - - The transition dict uses the same ``(target, weight)`` pair format - as :meth:`Composition.form`, so the idiom is already familiar. - - Parameters: - transitions: Mapping of state name to a list of - ``(next_state, weight)`` tuples. Higher weight means higher - probability of that transition. - pitch_map: Mapping of state name to absolute MIDI note number. - States absent from this dict are walked but produce no note. - velocity: MIDI velocity for all placed notes (default 100), - or a ``(low, high)`` tuple for a fresh random draw per step. - duration: Note duration in beats (default 0.1). - spacing: Time between note onsets in beats (default 0.25 = 16th note). - start: Name of the starting state. Defaults to the first key - in ``transitions`` when not provided. - seed: Fix the walk for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Raises: - ValueError: If ``transitions`` or ``pitch_map`` is empty. - - Example: - ```python - # Walking bassline: root anchors, 3rd and 5th passing tones - p.markov( - transitions={ - "root": [("3rd", 3), ("5th", 2), ("root", 1)], - "3rd": [("5th", 3), ("root", 2)], - "5th": [("root", 3), ("3rd", 1)], - }, - pitch_map={"root": 52, "3rd": 56, "5th": 59}, - velocity=80, - spacing=0.5, - ) - ``` - """ - - rng = self._rng_from(seed, rng) - - if not transitions: - raise ValueError("transitions dict cannot be empty") - - if not pitch_map: - raise ValueError("pitch_map dict cannot be empty") - - graph: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - - for source, targets in transitions.items(): - for target, weight in targets: - graph.add_transition(source, target, weight) - - if start is None: - start = next(iter(transitions)) - - if spacing <= 0: - raise ValueError(f"markov() spacing is the time between notes in beats — it must be positive, got {spacing}") - - n_steps = int(self._pattern.length / spacing) - - state = start - beat = 0.0 - - for _ in range(n_steps): - - if state in pitch_map: - vel = self._resolve_velocity(velocity, rng) - self.note(pitch=pitch_map[state], beat=beat, velocity=vel, duration=duration) - - state = graph.choose_next(state, rng) - beat += spacing - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def melody ( - self, - state: subsequence.melodic_state.MelodicState, - spacing: float = 0.25, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, - duration: float = 0.2, - chord_tones: typing.Optional[typing.List[int]] = None, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a melodic line by querying a persistent :class:`~subsequence.melodic_state.MelodicState`. - - Places one note (or rest) per ``spacing`` beats for the full pattern - length. Pitch selection is guided by the NIR cognitive model inside - ``state``: after a large leap the model expects a direction reversal; - after a small step it expects continuation. Chord tones, range - gravity, and a pitch-diversity penalty further shape the output. - - Because ``state`` lives outside the pattern builder and persists - across bar rebuilds, melodic continuity is maintained automatically - - no manual history management is required. - - Parameters: - state: Persistent :class:`~subsequence.melodic_state.MelodicState` - instance created once at module level. - spacing: Time between note onsets in beats (default 0.25 = 16th note). - velocity: MIDI velocity. An ``int`` applies a fixed level; a - ``(low, high)`` tuple draws uniformly from that range each spacing. - duration: Note duration in beats (default 0.2 - slightly shorter - than a 16th note, giving a crisp attack). - chord_tones: Optional list of MIDI note numbers that are chord - tones this bar (e.g. from ``chord.tones(root)``). Chord-tone - pitch classes receive a ``chord_weight`` bonus inside ``state``. - seed: Fix the walk for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - melody_state = subsequence.MelodicState( - key="A", mode="aeolian", - low=60, high=84, - nir_strength=0.6, - chord_weight=0.4, - ) - - @composition.pattern(channel=4, beats=4) - def lead (p, chord): - tones = chord.tones(72) if chord else None - p.melody(melody_state, spacing=0.5, velocity=(70, 100), chord_tones=tones) - ``` - """ - - rng = self._rng_from(seed, rng) - - # A state built without key/mode adopts the composition's here - # (idempotent — explicit constructor arguments always win). - state.configure_defaults(self.key, self.scale) - - if spacing <= 0: - raise ValueError(f"melody() spacing is the time between notes in beats — it must be positive, got {spacing}") - - n_steps = int(self._pattern.length / spacing) - beat = 0.0 - - for _ in range(n_steps): - - pitch = state.choose_next(chord_tones, rng, beat=beat) - - if pitch is not None: - vel = self._resolve_velocity(velocity, rng) - self.note(pitch=pitch, beat=beat, velocity=vel, duration=duration) - - beat += spacing - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def lsystem ( - self, - pitch_map: typing.Dict[str, typing.Union[int, str]], - axiom: str, - rules: typing.Dict[str, typing.Union[str, typing.List[typing.Tuple[str, float]]]], - generations: int = 3, - spacing: typing.Optional[float] = None, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.2, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a note sequence using L-system string rewriting. - - Expands ``axiom`` by applying ``rules`` for ``generations`` - iterations, then walks the resulting string placing a note for - each character found in ``pitch_map``. Unmapped characters are - silent rests - they advance time but produce no note. - - The defining musical property is self-similarity: patterns repeat - at different time scales. The Fibonacci rule (``A → AB``, - ``B → A``) places hits at golden-ratio spacings. Koch and dragon - curve rules produce fractal melodic contours. - - With ``spacing=None`` (default) the entire expanded string is fitted - into the bar: each generation makes notes twice as dense while - preserving the overall shape. With a fixed ``spacing`` the string is - truncated to fit and the density stays constant. - - Parameters: - pitch_map: Maps single characters to MIDI notes or drum names. - Characters absent from the map produce rests. - axiom: Starting string (e.g. ``"A"``). - rules: Production rules. Deterministic: ``{"A": "AB"}``. - Stochastic: ``{"A": [("AB", 3), ("BA", 1)]}``. - generations: Rewriting iterations. String length grows - exponentially - keep this to 3–8 for practical use. - spacing: Time between symbols in beats. ``None`` (default) - auto-fits the full expanded string into the bar. A float - uses fixed spacing and truncates excess symbols. - velocity: MIDI velocity. An ``(low, high)`` tuple randomises - per note. - duration: Note duration in beats. - seed: Fix the stochastic-rule choices and velocity draws for this - call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # Fibonacci kick rhythm - self-similar hit spacing - p.lsystem( - pitch_map={"A": "kick_1"}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=6, - velocity=80, - ) - - # Fractal melody over scale notes - p.lsystem( - pitch_map={"F": 60, "G": 62, "+": 64, "-": 67}, - axiom="F", - rules={"F": "F+G", "G": "-F"}, - generations=4, - spacing=0.25, - velocity=(70, 100), - ) - ``` - """ - - rng = self._rng_from(seed, rng) - - expanded = subsequence.sequence_utils.lsystem_expand( - axiom=axiom, - rules=rules, - generations=generations, - rng=rng, - ) - - if not expanded: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - if spacing is None: - auto_step = self._pattern.length / len(expanded) - symbols = expanded - else: - if spacing <= 0: - raise ValueError(f"lsystem() spacing is the time between symbols in beats — it must be positive, got {spacing}") - - auto_step = spacing - n_steps = int(self._pattern.length / spacing) - symbols = expanded[:n_steps] - - beat = 0.0 - - for symbol in symbols: - if symbol in pitch_map: - vel = self._resolve_velocity(velocity, rng) - self.note( - pitch=pitch_map[symbol], - beat=beat, - velocity=vel, - duration=duration, - ) - beat += auto_step - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def thue_morse ( - self, - pitch: typing.Union[int, str], - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, - duration: float = 0.1, - pitch_b: typing.Optional[typing.Union[int, str]] = None, - velocity_b: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, - no_overlap: bool = False, - probability: float = 1.0, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, ) -> "subsequence.pattern_builder.PatternBuilder": - - """Place notes using the Thue-Morse aperiodic binary sequence. - - The Thue-Morse sequence (0 1 1 0 1 0 0 1 1 0 0 1 0 1 1 0 …) is - perfectly balanced, overlap-free, and self-similar but never periodic. - It produces rhythmic patterns that feel structured yet never settle into - a simple repeating loop - a quality distinct from Euclidean rhythms - (evenly spaced) and cellular automata (rule-driven evolution). - - In **single-pitch mode** (default), notes are placed at positions where - the sequence is 1. In **two-pitch mode** (``pitch_b`` given), ``pitch`` - flips to the 0-positions and ``pitch_b`` takes the 1-positions - useful - for alternating two drums or two chord tones. - - Parameters: - pitch: Pitch (MIDI note number or drum name). Placed at the - sequence's 1-positions in single-pitch mode; at the - 0-positions when ``pitch_b`` takes over the 1s. - velocity: MIDI velocity for ``pitch``, or a ``(low, high)`` - tuple for a fresh random draw per hit. - duration: Note duration in beats. - pitch_b: Optional second pitch placed at sequence-1 positions. - When set, all steps produce a note (no rests). - velocity_b: Velocity for ``pitch_b`` (int or ``(low, high)`` - tuple). Defaults to ``velocity``. - no_overlap: Skip steps where ``pitch`` is already sounding. - probability: Chance (0.0–1.0) that each active step plays — 1.0 places them all, lower thins. - seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # Single-pitch Thue-Morse kick - p.thue_morse("kick_1", velocity=100) - - # Two-pitch mode: alternate kick and snare - p.thue_morse("kick_1", pitch_b="snare_1", velocity=100) - ``` - """ - rng = self._rng_from(seed, rng) - - sequence = subsequence.sequence_utils.thue_morse(self._default_grid) - - if not sequence: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - if pitch_b is None: - self._place_rhythm_sequence(sequence, pitch, velocity, duration, probability, rng, no_overlap) - else: - if velocity_b is None: - velocity_b = velocity - - # Every step sounds one of the two voices (no rests), and no_overlap - # is honoured against whichever voice the step would place. - second_pitch = pitch_b - second_velocity = velocity_b - - def _event (i: int, val: typing.Any) -> typing.Optional[typing.Tuple[typing.Union[int, str], typing.Union[int, typing.Tuple[int, int]], float]]: - if val == 0: - return (pitch, velocity, duration) - return (second_pitch, second_velocity, duration) - - self._place_gated_sequence(sequence, _event, probability, rng, no_overlap=no_overlap) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def de_bruijn ( - self, - pitches: typing.List[typing.Union[int, str]], - window: int = 2, - spacing: typing.Optional[float] = None, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.2, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a melody that exhaustively traverses all pitch subsequences. - - A de Bruijn sequence B(k, n) over an alphabet of size ``k`` with window - ``n`` contains every possible subsequence of length ``n`` exactly once - (cyclically). Mapping each symbol to a pitch produces a melody that - systematically explores all possible ``n``-gram transitions - every - permutation of ``window`` consecutive pitches appears exactly once. - - With ``spacing=None`` (default) the full sequence is auto-fitted into the - bar, matching the behaviour of :meth:`lsystem`. With a fixed ``spacing`` - the sequence is truncated to fill the available beats. - - Parameters: - pitches: List of MIDI note numbers or note strings. The alphabet - size ``k`` is ``len(pitches)``. - window: Subsequence length ``n``. The output has ``len(pitches) ** window`` - notes. Keep small (2–4) for practical bar lengths. - spacing: Time between notes in beats. ``None`` auto-fits the sequence - into the bar; a float uses fixed spacing and truncates. - velocity: MIDI velocity. An ``(low, high)`` tuple randomises per note. - duration: Note duration in beats. - seed: Fix the velocity draws for this call (an int) — the note order - itself is deterministic; omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # All 2-note combinations of a pentatonic scale - p.de_bruijn([60, 62, 64, 67, 69], window=2, velocity=(60, 100)) - ``` - """ - - rng = self._rng_from(seed, rng) - - if not pitches: - raise ValueError("pitches list cannot be empty") - - k = len(pitches) - sequence = subsequence.sequence_utils.de_bruijn(k, window) - - if not sequence: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - if spacing is None: - auto_step = self._pattern.length / len(sequence) - symbols = sequence - else: - if spacing <= 0: - raise ValueError(f"de_bruijn() spacing is the time between notes in beats — it must be positive, got {spacing}") - - auto_step = spacing - n_steps = int(self._pattern.length / spacing) - symbols = sequence[:n_steps] - - beat = 0.0 - - for idx in symbols: - vel = self._resolve_velocity(velocity, rng) - self.note(pitch=pitches[idx], beat=beat, velocity=vel, duration=duration) - beat += auto_step - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def fibonacci ( - self, - pitch: typing.Union[int, str], - count: int, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.2, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, ) -> "subsequence.pattern_builder.PatternBuilder": - - """Place notes at golden-ratio-spaced beat positions (Fibonacci spiral timing). - - Uses the golden angle method - ``position_i = frac(i × φ) × bar_length``, - where ``frac`` keeps only the fractional part - to distribute ``count`` - events across the bar as a low-discrepancy (sunflower-seed) spread. - The result is sorted into ascending time order. Unlike a Euclidean rhythm (maximally even - spacing on a fixed grid), Fibonacci timing is irrational and places - events off-grid in a way that sounds organic and avoids metronomic - repetition. - - Parameters: - pitch: MIDI note number or drum name. - count: Number of notes to place. - velocity: MIDI velocity. An ``(low, high)`` tuple randomises per note. - duration: Note duration in beats. - seed: Fix the velocity draws for this call (an int) — the timing - itself is deterministic; omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # 11 hi-hat hits with golden-ratio spacing - p.fibonacci("hi_hat_closed", count=11, velocity=(60, 90)) - ``` - """ - - rng = self._rng_from(seed, rng) - - beats = subsequence.sequence_utils.fibonacci_rhythm(count, self._pattern.length) - - for beat in beats: - vel = self._resolve_velocity(velocity, rng) - self.note(pitch=pitch, beat=beat, velocity=vel, duration=duration) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def lorenz ( - self, - pitches: typing.List[typing.Union[int, str]], - spacing: float = 0.25, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.2, - dt: float = 0.01, - sigma: float = 10.0, - rho: float = 28.0, - beta: float = 8.0 / 3.0, - x0: float = 0.1, - y0: float = 0.0, - z0: float = 0.0, - mapping: typing.Optional[ - typing.Callable[ - [float, float, float], - typing.Optional[typing.Tuple[typing.Union[int, str], int, float]], - ] - ] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a note sequence driven by the Lorenz strange attractor. - - Integrates the Lorenz system to produce a trajectory of (x, y, z) points, - each normalised to [0, 1]. The three axes provide correlated but - independent modulation sources: by default x drives pitch selection, - y drives velocity, and z drives duration. - - The Lorenz attractor is deterministic but extremely sensitive to initial - conditions: changing ``x0`` by even 0.001 produces a divergent trajectory - over time. This makes it ideal for cycle-by-cycle variation - pass - ``x0=p.cycle * 0.001`` to generate a unique but slowly evolving phrase - each bar. - - A custom ``mapping`` callable can override the default x/y/z → pitch/vel/dur - assignment, or return ``None`` for a rest. - - Parameters: - pitches: Pitch pool. The x-axis selects an index: ``int(x * len(pitches)) % len(pitches)``. - spacing: Time between notes in beats. Default 0.25 (16th note). - velocity: Fixed velocity or ``(low, high)`` tuple. Overridden by ``mapping``. - duration: Maximum note duration. z is scaled to ``[0.05, duration]``. - Overridden by ``mapping``. - dt: Integration time step. Default 0.01. - sigma, rho, beta: Lorenz parameters. Defaults produce the classic - butterfly attractor (chaotic regime). - x0, y0, z0: Initial conditions. Use ``x0=p.cycle * small_delta`` - for slowly evolving variation. - mapping: Optional callable ``(x, y, z) -> (pitch, velocity, duration)`` - or ``None`` for rest. - - Example: - ```python - scale = [60, 62, 64, 65, 67, 69, 71, 72] - p.lorenz(scale, spacing=0.25, velocity=(50, 110), x0=p.cycle * 0.002) - ``` - """ - - if not pitches: - raise ValueError("pitches list cannot be empty") - - if spacing <= 0: - raise ValueError(f"lorenz() spacing is the time between notes in beats — it must be positive, got {spacing}") - - n_steps = int(self._pattern.length / spacing) - points = subsequence.sequence_utils.lorenz_attractor( - n_steps, dt=dt, sigma=sigma, rho=rho, beta=beta, x0=x0, y0=y0, z0=z0 - ) - - beat = 0.0 - - for x, y, z in points: - - if mapping is not None: - result = mapping(x, y, z) - if result is not None: - p_pitch, p_vel, p_dur = result - self.note(pitch=p_pitch, beat=beat, velocity=p_vel, duration=p_dur) - else: - pitch_idx = int(x * len(pitches)) % len(pitches) - p_pitch = pitches[pitch_idx] - if isinstance(velocity, tuple): - p_vel = int(velocity[0] + y * (velocity[1] - velocity[0])) - else: - # A fixed int means FIXED - the y axis only drives velocity - # when a (low, high) range invites it (or via mapping=). - p_vel = int(velocity) - p_dur = 0.05 + z * max(0.0, duration - 0.05) - self.note(pitch=p_pitch, beat=beat, velocity=p_vel, duration=p_dur) - - beat += spacing - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def reaction_diffusion ( - self, - pitch: typing.Union[int, str], - threshold: float = 0.5, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.1, - feed_rate: float = 0.055, - kill_rate: float = 0.062, - steps: int = 1000, - no_overlap: bool = False, - probability: float = 1.0, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a rhythm from a 1D Gray-Scott reaction-diffusion simulation. - - Simulates the Gray-Scott model on a ring of ``_default_grid`` cells, - then thresholds the final V-concentration to produce a binary hit - pattern. Cells where concentration exceeds ``threshold`` become note - events. - - Unlike cellular automata - where rules are discrete and the state is - binary - reaction-diffusion evolves a continuous concentration field - governed by diffusion rates and chemical reactions. The resulting - spatial patterns (spots, stripes, travelling waves) have an organic, - biological character that maps naturally to rhythm. - - The ``feed_rate`` and ``kill_rate`` parameters control pattern type: - typical values that produce spots (useful rhythms) range from 0.020–0.062 - for feed and 0.045–0.069 for kill. The defaults (F=0.055, k=0.062) - produce a stable spotted pattern. - - Parameters: - pitch: MIDI note number or drum name. - threshold: V-concentration threshold for note placement (0.0–1.0). - Lower values produce denser patterns. - velocity: MIDI velocity. An ``(low, high)`` tuple is NOT random: - each step's local V-concentration is mapped into the range - deterministically, so notes are louder where the pattern is - denser. - duration: Note duration in beats. - feed_rate: Rate of U replenishment. Default 0.055. - kill_rate: Rate of V removal. Default 0.062. - steps: Number of simulation iterations. More = more developed - pattern. Default 1000. - no_overlap: Skip steps where ``pitch`` is already sounding. - probability: Chance (0.0–1.0) that each active step plays — 1.0 places them all, lower thins. - seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # Organic kick pattern from reaction-diffusion - p.reaction_diffusion("kick_1", threshold=0.4, feed_rate=0.037, kill_rate=0.060) - ``` - """ - rng = self._rng_from(seed, rng) - - concentrations = subsequence.sequence_utils.reaction_diffusion_1d( - width=self._default_grid, - steps=steps, - feed_rate=feed_rate, - kill_rate=kill_rate, - ) - - sequence = [1 if c > threshold else 0 for c in concentrations] - - if isinstance(velocity, tuple): - # Map concentration to velocity range for active steps: louder - # where the pattern is denser (deterministic, not random). - midi_vel_lo, midi_vel_hi = velocity - - def _event (i: int, hit: typing.Any) -> typing.Optional[typing.Tuple[typing.Union[int, str], typing.Union[int, typing.Tuple[int, int]], float]]: - if hit == 0: - return None - - vel = int(midi_vel_lo + concentrations[i] * (midi_vel_hi - midi_vel_lo)) - return (pitch, vel, duration) - - self._place_gated_sequence(sequence, _event, probability, rng, no_overlap=no_overlap) - else: - self._place_rhythm_sequence(sequence, pitch, int(velocity), duration, probability, rng, no_overlap) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def self_avoiding_walk ( - self, - pitches: typing.List[typing.Union[int, str]], - spacing: float = 0.25, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.2, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a melody using a self-avoiding random walk. - - A self-avoiding walk moves ±1 step through a pitch index space, tracking - visited positions and refusing to revisit them. When the walk is trapped - (all neighbours visited), the visited set resets and the walk continues - from the current position - creating natural phrase boundaries. - - Compared to :func:`random_walk`, the self-avoiding variant guarantees - pitch diversity within each phrase: no pitch repeats until the walk - resets. The contiguous step motion (never skipping pitches) gives - melodies a smooth, step-wise quality with occasional direction reversals. - - Parameters: - pitches: Ordered list of MIDI note numbers or note strings. The walk - moves through indices ``[0, len(pitches) - 1]``, mapping each to - the corresponding pitch. - spacing: Time between notes in beats. Default 0.25 (16th note). - velocity: MIDI velocity. An ``(low, high)`` tuple randomises per note. - duration: Note duration in beats. - seed: Fix the walk for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - scale = subsequence.scale_notes("C", "ionian", low=60, high=72) - p.self_avoiding_walk(scale, spacing=0.25, velocity=(60, 100)) - ``` - """ - - rng = self._rng_from(seed, rng) - - if not pitches: - raise ValueError("pitches list cannot be empty") - - if spacing <= 0: - raise ValueError(f"self_avoiding_walk() spacing is the time between notes in beats — it must be positive, got {spacing}") - - n_steps = int(self._pattern.length / spacing) - indices = subsequence.sequence_utils.self_avoiding_walk( - n=n_steps, - low=0, - high=len(pitches) - 1, - rng=rng, - ) - - beat = 0.0 - - for idx in indices: - vel = self._resolve_velocity(velocity, rng) - self.note(pitch=pitches[idx], beat=beat, velocity=vel, duration=duration) - beat += spacing - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def thin ( - self, - pitch: typing.Optional[typing.Union[int, str]] = None, - strategy: typing.Union[str, typing.List[float]] = "strength", - amount: float = 0.5, - grid: typing.Optional[int] = None, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Remove notes from the pattern based on their rhythmic position. - - This is the musical inverse of :meth:`ghost_fill()`. Where ``ghost_fill`` - uses bias weights to decide where to *add* ghost notes, ``thin`` uses the - same position vocabulary to decide where to *remove* notes. A high - strategy weight on a position means that position is dropped first. - - The strategy names match those in :meth:`build_ghost_bias()` and carry the - same rhythmic meaning: - - - ``"sixteenths"`` - removes 16th-note subdivisions (e/a), keeps beats and &. - - ``"offbeat"`` - removes the & position, straightens the groove. - - ``"e_and_a"`` - removes all non-downbeat positions, keeps only beats. - - ``"downbeat"`` - removes beat positions (floating/displaced effect). - - ``"upbeat"`` - removes only the & position. - - ``"uniform"`` - removes from all positions equally (per-instrument dropout). - - ``"strength"`` - progressive thinning: weakest positions (e/a) drop first, - strongest (downbeat) last. Useful for Perlin-driven density control. - - When ``pitch`` is given, only notes of that instrument are affected - - useful for drum layers. When ``pitch`` is ``None`` (the default), all - notes regardless of pitch are candidates. This makes ``thin`` a - rhythm-aware generalisation of :meth:`dropout()`, and is ideal for - tonal patterns such as arpeggios where each step carries a different pitch. - - Position classification is **zone-based**: each grid step owns the pulse range - ``[N * step_pulses, (N + 1) * step_pulses)``, so notes shifted by swing or - groove are still classified correctly regardless of call order. - - Parameters: - pitch: Drum name or MIDI note number to target, or ``None`` to thin - all notes regardless of pitch. Defaults to ``None``. - strategy: Named strategy string or a list of per-step drop-priority - floats (0.0 = never drop, 1.0 = highest drop priority). Must have - length equal to ``grid`` when a list is provided. - amount: Overall thinning depth (0.0 = remove nothing, 1.0 = remove all - qualifying). Effective drop probability = ``priority * amount``. - Drive this with a Perlin field or section progress for smooth, - organic thinning over time. - grid: Step grid size. Defaults to the pattern's ``default_grid``. - seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example:: - - # Thin 16th ghost notes from the kick, keep anchors and off-beats - p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) - p.ghost_fill("kick_1", density=0.3, velocity=(25, 40), bias="sixteenths") - p.thin("kick_1", "sixteenths", amount=0.8) - - # Perlin-driven progressive thinning of hi-hats - sparseness = perlin_1d(p.cycle * 0.07, seed=42) - p.thin("hi_hat_closed", "strength", amount=sparseness) - - # Thin an arpeggio (all pitches) - no pitch loop needed - p.thin(strategy="strength", amount=sparseness) - """ - - rng = self._rng_from(seed, rng) - - if grid is None: - grid = self._default_grid - - if pitch is None: - midi_pitch = None - else: - midi_pitch = self._resolve_pitch_lenient(pitch) - if midi_pitch is None: - # Named voice this device lacks (already warned once): there are - # no such notes to thin, so this is a no-op rather than an error. - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - # Build the per-step drop-priority weights. - # - # Strategy names are shared with ghost_fill's bias vocabulary. The per-step - # weights from build_ghost_bias() are reused directly, with semantics inverted: - # ghost_fill: high weight → place a note here - # thin: high weight → drop a note from here - # - # "strength" is defined only for thin() - it expresses a thinning hierarchy - # (weakest positions drop first) which has no meaningful ghost_fill equivalent. - if strategy == "strength": - # Per-beat drop priorities: e/a (1.0) > & (0.6) > downbeat (0.05). - # As `amount` rises, progressively weaker positions are removed first. - steps_per_beat = max(1, grid // 4) - priorities: typing.List[float] = [] - for i in range(grid): - pos = i % steps_per_beat - if pos == 0: - priorities.append(0.05) - elif steps_per_beat > 1 and pos == steps_per_beat // 2: - priorities.append(0.6) - else: - priorities.append(1.0) - elif isinstance(strategy, list): - if len(strategy) != grid: - raise ValueError( - f"thin() custom strategy list has {len(strategy)} values " - f"but grid has {grid} steps." - ) - priorities = list(strategy) - else: - # Reuse build_ghost_bias() weights for all shared strategy names. - # The positions that ghost_fill prefers to add to are the same - # positions that thin() will prefer to remove from. - priorities = self.build_ghost_bias(grid, strategy) - - # Zone-based pulse classification. - # Zone N owns pulses in [ N * step_pulses, (N+1) * step_pulses ). - # Notes shifted by swing or groove remain in their original zone. - total_pulses = self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE - step_pulses = total_pulses / grid - - pulses_to_remove: typing.List[int] = [] - - for pulse, step in list(self._pattern.steps.items()): - - zone = int(pulse / step_pulses) - if zone >= grid: - zone = grid - 1 - - priority = priorities[zone] - if priority <= 0.0: - continue - - # Separate target notes from protected notes at this pulse. - if midi_pitch is None: - remaining = [] - targets = list(step.notes) - else: - remaining = [n for n in step.notes if n.pitch != midi_pitch] - targets = [n for n in step.notes if n.pitch == midi_pitch] - - for note in targets: - if rng.random() >= priority * amount: - remaining.append(note) - # else: note is dropped - - if not remaining: - pulses_to_remove.append(pulse) - else: - step.notes = remaining - - for pulse in pulses_to_remove: - del self._pattern.steps[pulse] - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def ratchet ( - self, - subdivisions: int = 2, - pitch: typing.Optional[typing.Union[int, str]] = None, - probability: float = 1.0, - velocity_start: float = 1.0, - velocity_end: float = 1.0, - shape: typing.Union[str, typing.Callable[[float], float]] = "linear", - gate: float = 0.5, - steps: typing.Optional[typing.List[int]] = None, - grid: typing.Optional[int] = None, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Subdivide existing notes into rapid repeated hits (rolls/ratchets). - - A post-placement transform: takes notes already in the pattern and - replaces each one with ``subdivisions`` evenly-spaced sub-hits within - the original note's duration window. The velocity of each sub-hit is - interpolated from ``velocity_start`` to ``velocity_end`` (as multipliers - on the original velocity) using the ``shape`` easing curve, so crescendo - rolls, decrescendo buzzes, and flat repeats are all one parameter apart. - - Call ``ratchet()`` after note-placement methods (``euclidean``, - ``hit_steps``, ``arpeggio``, etc.) and before ``swing`` or ``groove`` - so that the subdivisions sit inside the original note slot and swing - displacement still affects the parent position. - - Parameters: - subdivisions: Number of sub-hits replacing each note (default 2). - If the note's duration is shorter than ``subdivisions`` pulses, - subdivisions are clamped to ``note.duration`` so they never - stack on the same pulse. - pitch: Only ratchet notes matching this pitch (MIDI number or drum - name). ``None`` (default) ratchets all notes regardless of - pitch — useful for melodic patterns such as arpeggios. - probability: Chance (0.0–1.0) that each note gets ratcheted. Notes - that fail the check are left completely unchanged. Default 1.0 - (every note is ratcheted). - velocity_start: Velocity multiplier for the first sub-hit (0.0–2.0). - Default 1.0 (same as the original). - velocity_end: Velocity multiplier for the last sub-hit (0.0–2.0). - Default 1.0. Set ``velocity_start=0.3, velocity_end=1.0`` for - a crescendo roll; ``1.0, 0.2`` for a decrescendo buzz. - shape: Easing curve applied to the velocity interpolation across - sub-hits. Accepts any name from ``subsequence.easing`` (e.g. - ``"ease_in"``, ``"ease_out"``, ``"s_curve"``) or a custom - callable ``f(t) → t`` for t ∈ [0, 1]. Default ``"linear"``. - gate: Sub-note duration as a fraction of each subdivision slot - (0.0–1.0). ``1.0`` = legato (sub-hits touch), ``0.5`` = - staccato (half the slot). Default 0.5. - steps: Grid positions to ratchet (e.g. ``[0, 4, 12]``). Notes are - classified to grid zones the same way ``thin()`` works — swing- - shifted notes remain in their original zone. ``None`` (default) - applies ratchet to all eligible notes. - grid: Grid resolution used for ``steps`` zone classification. - Defaults to the pattern's ``default_grid``. - seed: Fix the probability gating for this call (an int); omit to - use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Examples: - ```python - # Subdivide every hi-hat into a triplet roll - p.euclidean("hi_hat_closed", 8).ratchet(3, pitch="hi_hat_closed") - - # Crescendo roll into a snare - p.hit_steps("snare", [12]).ratchet(4, velocity_start=0.3, - velocity_end=1.0, - shape="ease_in") - - # Probabilistic 2× ratchet on hi-hats only - p.euclidean("hi_hat_closed", 12).ratchet(2, pitch="hi_hat_closed", - probability=0.4, gate=0.3) - - # Ratchet only steps 0 and 8 (downbeats) - p.euclidean("kick_1", 6).ratchet(2, pitch="kick_1", steps=[0, 8]) - ``` - """ - - rng = self._rng_from(seed, rng) - - if grid is None: - grid = self._default_grid - - if pitch is None: - midi_pitch = None - else: - midi_pitch = self._resolve_pitch_lenient(pitch) - if midi_pitch is None: - # Named voice this device lacks (already warned once): nothing - # matches, so leave the pattern unchanged rather than raising. - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - ease_fn = subsequence.easing.get_easing(shape) - - # Build zone set for steps mask (zone-based, matching thin()'s approach). - target_zones: typing.Optional[typing.Set[int]] = None - if steps is not None: - target_zones = set(steps) - - total_pulses = self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE - step_pulses = total_pulses / grid - - new_steps: typing.Dict[int, subsequence.pattern.Step] = {} - - for pulse, step in self._pattern.steps.items(): - - # Zone classification for steps mask. - if target_zones is not None: - zone = int(pulse / step_pulses) - if zone >= grid: - zone = grid - 1 - in_zone = zone in target_zones - else: - in_zone = True - - # Separate targeted notes from passthrough notes. - if midi_pitch is None: - targets = list(step.notes) if in_zone else [] - passthrough = [] if in_zone else list(step.notes) - else: - if in_zone: - targets = [n for n in step.notes if n.pitch == midi_pitch] - passthrough = [n for n in step.notes if n.pitch != midi_pitch] - else: - targets = [] - passthrough = list(step.notes) - - # Passthrough notes keep their original pulse position. - if passthrough: - if pulse not in new_steps: - new_steps[pulse] = subsequence.pattern.Step() - new_steps[pulse].notes.extend(passthrough) - - for note in targets: - - # Probability gate — failed notes are kept unchanged. - if probability < 1.0 and rng.random() < (1.0 - probability): - if pulse not in new_steps: - new_steps[pulse] = subsequence.pattern.Step() - new_steps[pulse].notes.append(note) - continue - - # Clamp subdivisions so sub-hits never stack on the same pulse. - effective_subdivs = min(subdivisions, note.duration) - if effective_subdivs < 1: - effective_subdivs = 1 - - slot_pulses = note.duration / effective_subdivs - - for i in range(effective_subdivs): - sub_pulse = pulse + int(round(i * slot_pulses)) - - # Velocity interpolation via easing. - if effective_subdivs == 1: - t = 0.0 - else: - t = i / (effective_subdivs - 1) - eased_t = ease_fn(t) - vel_mul = velocity_start + (velocity_end - velocity_start) * eased_t - sub_velocity = max(1, min(127, int(round(note.velocity * vel_mul)))) - - sub_duration = max(1, int(round(slot_pulses * gate))) - - sub_note = subsequence.pattern.Note( - pitch=note.pitch, - velocity=sub_velocity, - duration=sub_duration, - channel=note.channel, - ) - - if sub_pulse not in new_steps: - new_steps[sub_pulse] = subsequence.pattern.Step() - new_steps[sub_pulse].notes.append(sub_note) - - self._pattern.steps = new_steps - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def evolve ( - self, - pitches: typing.List[typing.Union[int, str]], - length: typing.Optional[int] = None, - drift: float = 0.0, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.2, - spacing: float = 0.25, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, ) -> "subsequence.pattern_builder.PatternBuilder": - - """Loop a pitch sequence that gradually mutates each cycle. - - On cycle 0, the sequence is locked to the initial ``pitches`` (truncated to - ``length`` if provided). Each subsequent cycle, every step has a ``drift`` - probability of being replaced by a randomly-chosen value from the pool. - When ``drift=0.0`` the loop never changes; when ``drift=1.0`` every step - is redrawn every cycle. - - State is stored in ``p.data`` under a key derived from the pitch content, so the - buffer persists across pattern rebuilds. The buffer is reset whenever - ``cycle == 0`` so restarts produce deterministic output. - - Combine with ``p.snap_to_scale()`` to keep drifted pitches in key: - - ```python - p.evolve([60, 64, 67, 72], length=8, drift=0.12) - p.snap_to_scale("C", "minor") - ``` - - Parameters: - pitches: Initial pitch pool. The initial buffer is built from the first - ``length`` values (cycling if shorter than ``length``). Mutation - also draws replacements from this pool. - length: Number of steps in the loop. Defaults to ``len(pitches)``. - drift: Per-step mutation probability each cycle (0.0–1.0). - ``0.0`` = locked loop, ``1.0`` = fully random each cycle. - velocity: MIDI velocity. An ``(low, high)`` tuple randomises per step. - duration: Note duration in beats. - spacing: Beat interval between steps. - seed: Fix the drift mutations and velocity draws for this call (an - int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # 8-step loop that slowly diverges from its seed - p.evolve([60, 62, 64, 65, 67, 69], length=8, drift=0.1, - velocity=(70, 100), spacing=0.5) - p.snap_to_scale("C", "dorian") - ``` - """ - - rng = self._rng_from(seed, rng) - - if not pitches: - raise ValueError("pitches list cannot be empty") - - resolved_opt = [self._resolve_pitch_lenient(p) if isinstance(p, str) else p for p in pitches] - resolved = [r for r in resolved_opt if r is not None] - if not resolved: - # Every seed name was a voice this device lacks (each warned once). - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - n = length if length is not None else len(resolved) - - # Stable key derived from the seed *content* (not object identity). The - # documented idiom passes a fresh list literal every cycle; keying on - # ``id(pitches)`` gave that literal a new address each cycle, so the - # buffer re-seeded every cycle (drift never accumulated) and a dict key - # leaked per cycle. Content keying makes the buffer persist so the walk - # actually evolves, and scoping by the owning pattern's builder name keeps - # two patterns' identical seeds from sharing one walk. ``resolved`` (the - # post-lenient ints) and ``n`` are folded in so a changed step count or a - # seed whose unknown names were dropped still keys stably. - builder_fn = getattr(self._pattern, '_builder_fn', None) - scope = getattr(builder_fn, '__name__', '') if builder_fn is not None else '' - data_key = f"_evolve_{scope}_{n}_{tuple(resolved)}" - - # Initialise or reset the buffer on cycle 0. - if data_key not in self.data or self.cycle == 0: - self.data[data_key] = [resolved[i % len(resolved)] for i in range(n)] - - buffer = self.data[data_key] - - # Mutate the buffer in place for this cycle (skipped on cycle 0 — seed plays first). - if self.cycle > 0 and drift > 0.0: - for i in range(n): - if rng.random() < drift: - buffer[i] = rng.choice(resolved) - - # Place notes. - for i, pitch in enumerate(buffer): - vel = self._resolve_velocity(velocity, rng) - self.note(pitch=pitch, beat=i * spacing, velocity=vel, duration=duration) - - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def branch ( - self, - pitches: typing.List[typing.Union[int, str]], - depth: int = 2, - path: int = 0, - mutation: float = 0.0, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, - duration: float = 0.2, - spacing: float = 0.25, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Generate a melodic variation by navigating a fractal tree of transforms. - - The ``pitches`` sequence is the "trunk". At each branch level, two musical - transforms are assigned deterministically (derived from the original - sequence), and the ``path`` index selects left or right at each level. - After ``depth`` levels the result is a variation that is always - structurally related to the input ``pitches``. - - Use ``path=p.cycle`` to step through all ``2 ** depth`` variations in - order; the index wraps automatically. - - **Transforms** (assigned deterministically per level): - - - *Retrograde* — reverse the sequence. - - *Invert* — mirror each pitch around the first note. - - *Transpose* — shift all pitches by the interval between the first - two notes. - - *Rotate* — shift the starting position by one step. - - *Scale intervals* — multiply intervals from the first note by 0.5 - (compress) or 2.0 (expand), rounded to the nearest semitone. - - An optional ``mutation`` layer randomly substitutes individual notes - with other input pitches on top of the deterministic branching. - - Parameters: - pitches: Original pitch sequence. All variations are derived from this. - depth: Branching levels. ``2 ** depth`` unique variations are - available before the path wraps. - path: Which variation to play (0-based). ``path=p.cycle`` advances - automatically. Values wrap modulo ``2 ** depth``. - mutation: Probability that any step is replaced by a random input - pitch after branching (0.0 = none, 1.0 = fully random). - velocity: MIDI velocity. An ``(low, high)`` tuple randomises per step. - duration: Note duration in beats. - spacing: Beat interval between steps. - seed: Fix the mutation substitutions and velocity draws for this - call (an int) — the variation tree itself is deterministic; - omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # Cycle through 8 variations (depth=3) of a 4-note motif - p.branch([60, 64, 67, 72], depth=3, path=p.cycle, - velocity=85, spacing=0.5) - p.snap_to_scale("C", "minor") - ``` - """ - - rng = self._rng_from(seed, rng) - - if not pitches: - raise ValueError("pitches list cannot be empty") - - resolved_opt = [self._resolve_pitch_lenient(p) if isinstance(p, str) else p for p in pitches] - resolved = [r for r in resolved_opt if r is not None] - if not resolved: - # Every seed name was a voice this device lacks (each warned once). - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - # The variation tree itself lives in sequence_utils.branch_sequence — - # a reusable pure kernel (feed its output to Motif.notes() for a - # storable variation). This verb resolves drum names, derives the - # variation, and places it on the grid. - sequence = subsequence.sequence_utils.branch_sequence( - resolved, - depth = depth, - path = path, - mutation = mutation, - rng = rng, - ) - - # Place notes. - for i, pitch in enumerate(sequence): - vel = self._resolve_velocity(velocity, rng) - self.note(pitch=pitch, beat=i * spacing, velocity=vel, duration=duration) - - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + """Algorithmic and generative note-placement methods for PatternBuilder. + + All methods here operate on ``self._pattern`` (a ``Pattern`` instance) + and ``self.rng`` (a ``random.Random`` instance), both of which are set + by ``PatternBuilder.__init__``. + """ + + # ── Instance attributes provided by PatternBuilder at runtime ──────── + # Declared here so mypy can type-check all methods in this mixin. + + _pattern: subsequence.pattern.Pattern + _default_grid: int + rng: random.Random + cycle: int + data: typing.Dict[str, typing.Any] + key: typing.Optional[str] + scale: typing.Optional[str] + + if typing.TYPE_CHECKING: + # Cross-mixin method stubs: implemented by PatternBuilder, + # called from methods in this mixin. + import subsequence.pattern_builder # noqa: F401 — type-checking only + + def note( + self, + pitch: typing.Union[int, str], + beat: float, + velocity: typing.Union[int, typing.Tuple[int, int]], + duration: float, + ) -> "subsequence.pattern_builder.PatternBuilder": ... + def _resolve_pitch(self, pitch: typing.Union[int, str]) -> int: ... + def _resolve_pitch_lenient( + self, pitch: typing.Union[int, str] + ) -> typing.Optional[int]: ... + def _has_pitch_at_beat( + self, pitch: typing.Union[int, str], beat: float + ) -> bool: ... + + def _rng_from( + self, seed: typing.Optional[int], rng: typing.Optional[random.Random] + ) -> random.Random: + """Resolve the effective random generator for a generative call. + + Determinism has one friendly knob — ``seed=`` (an int) — and one advanced + form — ``rng=`` (a ``random.Random`` instance). Precedence, most explicit + first: + + 1. ``rng=`` — an explicit generator you supplied (wins; warns if ``seed=`` was also given). + 2. ``seed=`` — a fresh ``random.Random(seed)``, fixed for this call. + 3. ``self.rng`` — the pattern's own generator (the default; reproducible under the composition seed). + """ + + if rng is not None: + if seed is not None: + warnings.warn( + "seed= and rng= were both given — rng= wins; pass only one", + stacklevel=3, + ) + return rng + + if seed is not None: + return random.Random(seed) + + return self.rng + + def _resolve_velocity( + self, + velocity: typing.Union[int, typing.Tuple[int, int]], + rng: typing.Optional[random.Random] = None, + ) -> int: + """Resolve a velocity argument to a single integer. + + Accepts a plain ``int`` (returned unchanged) or a ``(low, high)`` + tuple from which a random value is drawn via ``rng.randint``. + Centralised here so every note-placement method offers the same + idiom — ``velocity=(60, 90)`` works wherever ``velocity=`` is + accepted. + + Raises ``TypeError`` for any other shape so a typo surfaces at + the call site rather than as a malformed MIDI event later in + the sequencer dispatch loop. + + Parameters: + velocity: An int 0-127 or a ``(low, high)`` 2-tuple + describing an inclusive random range. + rng: Random generator. Defaults to ``self.rng``. + + Returns: + A single integer velocity. + """ + + if isinstance(velocity, tuple): + if len(velocity) != 2: + raise ValueError( + f"velocity tuple must be (low, high), got {velocity!r}" + ) + + low, high = int(velocity[0]), int(velocity[1]) + if low > high: + raise ValueError( + f"velocity range must be (low, high) with low <= high, got {velocity!r}" + ) + + if rng is None: + rng = self.rng + + return rng.randint(low, high) + if isinstance(velocity, bool): + raise TypeError( + f"velocity must be int or (low, high) tuple, got bool: {velocity!r}" + ) + if isinstance(velocity, (int, float)): + return int(velocity) + raise TypeError( + f"velocity must be int or (low, high) tuple, got {type(velocity).__name__}: {velocity!r}" + ) + + def _place_gated_sequence( + self, + sequence: typing.Sequence[typing.Any], + event_for: typing.Callable[ + [int, typing.Any], + typing.Optional[ + typing.Tuple[ + typing.Union[int, str], + typing.Union[int, typing.Tuple[int, int]], + float, + ] + ], + ], + probability: float, + rng: random.Random, + no_overlap: bool = False, + ) -> None: + """Place per-step events from a sequence, gated by probability and overlap. + + The shared placement kernel: steps are evenly spaced across the pattern + length; for each step, ``event_for(index, value)`` returns either + ``None`` (a silent step — no probability draw is consumed) or a + ``(pitch, velocity, duration)`` event. Surviving events pass the + probability gate and, when ``no_overlap`` is set, the same-pitch check, + then land via ``self.note()`` (which resolves ``(low, high)`` velocity + tuples per hit). + """ + + if not sequence: + return # nothing to place (e.g. a pattern shorter than one step) + + step_duration = self._pattern.length / len(sequence) + + for i, value in enumerate(sequence): + event = event_for(i, value) + + if event is None: + continue + + if probability < 1.0 and rng.random() < (1.0 - probability): + continue + + pitch, velocity, duration = event + + if no_overlap and self._has_pitch_at_beat(pitch, i * step_duration): + continue + + self.note( + pitch=pitch, + beat=i * step_duration, + velocity=velocity, + duration=duration, + ) + + def _place_rhythm_sequence( + self, + sequence: typing.List[int], + pitch: typing.Union[int, str], + velocity: typing.Union[int, typing.Tuple[int, int]], + duration: float, + probability: float, + rng: random.Random, + no_overlap: bool = False, + ) -> None: + """Place hits from a binary sequence into the pattern. + + Shared implementation for ``euclidean()``, ``bresenham()``, and the + other single-voice binary-rhythm verbs. Each active step (1) becomes + a note; zeros are rests. + """ + + def _event( + i: int, hit_value: typing.Any + ) -> typing.Optional[ + typing.Tuple[ + typing.Union[int, str], typing.Union[int, typing.Tuple[int, int]], float + ] + ]: + if hit_value == 0: + return None + return (pitch, velocity, duration) + + self._place_gated_sequence( + sequence, _event, probability, rng, no_overlap=no_overlap + ) + + def euclidean( + self, + pitch: typing.Union[int, str], + pulses: int, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.1, + probability: float = 1.0, + no_overlap: bool = False, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Generate a Euclidean rhythm. + + This distributes a fixed number of 'pulses' as evenly as possible + across the pattern. This produces many of the world's most + common musical rhythms. + + Parameters: + pitch: MIDI note or drum name. + pulses: Total number of notes to place. + velocity: MIDI velocity, or a ``(low, high)`` tuple for a + fresh random draw per hit. + duration: Note duration. + probability: Chance (0.0–1.0) that each pulse plays — 1.0 places them all, lower thins the rhythm. + seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + no_overlap: If True, skip steps where a note of the same pitch + already exists. Useful for layering ghost notes around + hand-placed anchors. + + Example: + ```python + # A classic 3-against-16 rhythm + p.euclidean("kick", pulses=3) + ``` + """ + rng = self._rng_from(seed, rng) + + steps = self._default_grid + sequence = subsequence.sequence_utils.generate_euclidean_sequence( + steps=steps, pulses=pulses + ) + self._place_rhythm_sequence( + sequence, pitch, velocity, duration, probability, rng, no_overlap=no_overlap + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def bresenham( + self, + pitch: typing.Union[int, str], + pulses: int, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.1, + probability: float = 1.0, + no_overlap: bool = False, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Generate a rhythm using the Bresenham line algorithm. + + This is an alternative to Euclidean rhythms that often results in + slightly different (but still mathematically even) distributions. + + Parameters: + pitch: MIDI note or drum name. + pulses: Total number of notes to place. + velocity: MIDI velocity, or a ``(low, high)`` tuple for a + fresh random draw per hit. + duration: Note duration. + probability: Chance (0.0–1.0) that each pulse plays — 1.0 places them all, lower thins the rhythm. + seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + no_overlap: If True, skip steps where a note of the same pitch + already exists. Useful for layering ghost notes around + hand-placed anchors. + """ + rng = self._rng_from(seed, rng) + + steps = self._default_grid + sequence = subsequence.sequence_utils.generate_bresenham_sequence( + steps=steps, pulses=pulses + ) + self._place_rhythm_sequence( + sequence, pitch, velocity, duration, probability, rng, no_overlap=no_overlap + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def bresenham_poly( + self, + parts: typing.Dict[typing.Union[int, str], float], + velocity: typing.Union[ + int, typing.Dict[typing.Union[int, str], int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.1, + grid: typing.Optional[int] = None, + probability: float = 1.0, + no_overlap: bool = False, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Distribute multiple drum voices across the pattern using weighted Bresenham. + + Each step is assigned to exactly one voice - voices never overlap, producing + interlocking rhythmic patterns. Density weights control how frequently each + voice fires. If the weights sum to less than 1.0, the remainder becomes + evenly-distributed rests (silent steps). + + Because notes are placed via ``self.note()``, all post-placement transforms + (``groove``, ``randomize``, ``velocity_shape``, ``rotate``, etc.) work normally. + + Parameters: + parts: Mapping of pitch (MIDI note or drum name) to density weight. + Higher weight means more hits per bar. Weights in the range (0, 1] + are typical; a weight of 0.5 targets roughly one hit every two steps. + velocity: Either a single MIDI velocity applied to all voices, or a dict + mapping each pitch to its own velocity. Pitches absent from the dict + fall back to the default velocity (100). + duration: Note duration in beats (default 0.1). + grid: Number of steps to divide the pattern into. Defaults to the + pattern's ``default_grid``. + probability: Chance (0.0–1.0) that each hit plays — 1.0 places them all, lower thins. + seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. + no_overlap: If True, skip steps where a note of the same pitch already + exists. Useful for layering ghost notes around hand-placed anchors. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + p.bresenham_poly( + parts={"kick_1": 0.25, "snare_1": 0.125, "hi_hat_closed": 0.5}, + velocity={"kick_1": 100, "snare_1": 90, "hi_hat_closed": 70}, + ) + ``` + + Layering with hand-placed hits: + ```python + # Algorithmic base - interlocking texture, no overlaps within this layer + p.bresenham_poly( + parts={"hi_hat_closed": 0.5, "snare_2": 0.1}, + velocity={"hi_hat_closed": 65, "snare_2": 40}, + ) + # Hand-placed anchors on top - these CAN overlap the algorithmic layer + p.hit_steps("kick_1", [0, 8], velocity=110) + p.hit_steps("snare_1", [4, 12], velocity=100) + ``` + + Stable vs shifting patterns: + Because the algorithm redistributes all positions when weights change, + a single voice with a continuously ramping density will shift positions + every bar. This is great for background texture (hats, shakers) but + can sound jarring for prominent, distinctive sounds (claps, cowbells). + + **For stable patterns** - use ``bresenham()`` with integer pulses. + Positions stay fixed until the pulse count steps up:: + + pulses = max(1, round(density * 16)) + p.bresenham("hand_clap", pulses=pulses, velocity=95) + + **For shifting texture** - use ``bresenham_poly()`` with continuous + density. Positions evolve every bar:: + + p.bresenham_poly(parts={"hi_hat_closed": density}, velocity=70) + + **To stabilise a solo voice** - pair it with a second voice. More + voices in a single call means less positional shift per voice:: + + p.bresenham_poly( + parts={"hand_clap": 0.12, "snare_2": 0.08}, + velocity={"hand_clap": 95, "snare_2": 40}, + ) + """ + + if not parts: + raise ValueError("parts dict cannot be empty") + + if any(w < 0 for w in parts.values()): + raise ValueError("All density weights must be non-negative") + rng = self._rng_from(seed, rng) + + if grid is None: + grid = self._default_grid + + voice_names = list(parts.keys()) + weights = [parts[name] for name in voice_names] + + # If weights don't fill the bar, add an implicit rest voice. + weight_sum = sum(weights) + rest_index: typing.Optional[int] = None + if weight_sum < 1.0: + rest_index = len(voice_names) + weights.append(1.0 - weight_sum) + + sequence = subsequence.sequence_utils.generate_bresenham_sequence_weighted( + steps=grid, weights=weights + ) + + def _event( + step_idx: int, voice_idx: typing.Any + ) -> typing.Optional[ + typing.Tuple[ + typing.Union[int, str], typing.Union[int, typing.Tuple[int, int]], float + ] + ]: + if voice_idx == rest_index: + return None + + pitch = voice_names[voice_idx] + + if isinstance(velocity, dict): + vel = velocity.get( + pitch, subsequence.constants.velocity.DEFAULT_VELOCITY + ) + else: + vel = velocity + + return (pitch, vel, duration) + + self._place_gated_sequence( + sequence, _event, probability, rng, no_overlap=no_overlap + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + @staticmethod + def build_ghost_bias(grid: int, bias: str) -> typing.List[float]: + """Build probability weights for ghost notes or other generative functions. + + Generates a list of probability weights (values between 0.0 and 1.0) spanning + a given grid size. These curves shape probability over a beat, + assigning higher or lower chances of an event occurring based on the rhythmic + position within the beat (downbeat, offbeat, syncopated 16th note, etc). + + This is a public escape hatch: call it yourself, manipulate the returned list, + then pass the result as ``bias=`` to :meth:`ghost_fill()`. This lets you pin + specific steps, boost a weak position, or combine two named curves. + + Parameters: + grid: The total number of steps in the sequence (usually 16 or 32). + bias: The probability distribution shape to generate: + + - ``"uniform"`` - 1.0 everywhere. + - ``"offbeat"`` - 1.0 on 8th note off-beats (&), 0.3 on 16ths (e/a), 0.05 on downbeats. + - ``"sixteenths"`` - 1.0 on 16th notes (e/a), 0.3 on 8th off-beats (&), 0.05 on downbeats. + - ``"before"`` - 1.0 preceding a beat, 0.25 on other 16ths, 0.05 on beats. + - ``"after"`` - 1.0 following a beat, 0.25 on other 16ths, 0.05 on beats. + - ``"downbeat"`` - 1.0 on downbeats, 0.15 on 8th off-beats, 0.05 on other 16ths. + - ``"upbeat"`` - 1.0 on 8th note off-beats only, 0.05 everywhere else. + - ``"e_and_a"`` - 1.0 on all non-downbeat 16th positions, 0.05 on downbeats. + + Returns: + A ``List[float]`` of length ``grid`` where each value is a probability + multiplier from 0.0 to 1.0. The list is a plain Python list — modify + it freely before passing to ``ghost_fill(bias=...)``. + + Example: + ```python + # Start from a named curve, then zero out beat 3 (step 8) entirely + # and give the step before the snare (step 11) maximum weight. + weights = p.build_ghost_bias(16, "sixteenths") + weights[8] = 0.0 # silence around beat 3 + weights[11] = 1.0 # boost the "and" before beat 4 + p.ghost_fill("snare_1", density=0.25, velocity=(25, 45), + bias=weights, no_overlap=True) + ``` + """ + + steps_per_beat = max(1, grid // 4) + weights: typing.List[float] = [] + + for i in range(grid): + pos = i % steps_per_beat + + if bias == "uniform": + weights.append(1.0) + elif bias == "offbeat": + if pos == 0: + weights.append(0.05) + elif steps_per_beat > 1 and pos == steps_per_beat // 2: + weights.append(1.0) + else: + weights.append(0.3) + elif bias == "sixteenths": + if pos == 0: + weights.append(0.05) + elif steps_per_beat > 1 and pos == steps_per_beat // 2: + weights.append(0.3) + else: + weights.append(1.0) + elif bias == "before": + if pos == steps_per_beat - 1: + weights.append(1.0) + elif pos == 0: + weights.append(0.05) + else: + weights.append(0.25) + elif bias == "after": + if steps_per_beat > 1 and pos == 1: + weights.append(1.0) + elif pos == 0: + weights.append(0.05) + else: + weights.append(0.25) + elif bias == "downbeat": + if pos == 0: + weights.append(1.0) + elif steps_per_beat > 1 and pos == steps_per_beat // 2: + weights.append(0.15) + else: + weights.append(0.05) + elif bias == "upbeat": + if steps_per_beat > 1 and pos == steps_per_beat // 2: + weights.append(1.0) + else: + weights.append(0.05) + elif bias == "e_and_a": + if pos == 0: + weights.append(0.05) + else: + weights.append(1.0) + else: + raise ValueError( + f"Unknown ghost_fill bias {bias!r}. " + f"Use 'uniform', 'offbeat', 'sixteenths', 'before', 'after', " + f"'downbeat', 'upbeat', 'e_and_a', or a list of floats." + ) + + return weights + + def ghost_fill( + self, + pitch: typing.Union[int, str], + density: float = 0.3, + velocity: typing.Union[ + int, + typing.Tuple[int, int], + typing.Sequence[typing.Union[int, float]], + typing.Callable[[int], typing.Union[int, float]], + ] = subsequence.constants.velocity.GHOST_FILL_VELOCITY, + bias: typing.Union[str, typing.List[float]] = "uniform", + no_overlap: bool = True, + grid: typing.Optional[int] = None, + duration: float = 0.1, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Fill the pattern with probability-biased ghost notes. + + A single method for generating musically-aware ghost note layers. + Combines density control, velocity randomisation, and rhythmic bias + to produce the micro-detail layering heard in dense electronic + music production. + + Parameters: + pitch: MIDI note number or drum name. + density: Overall density (0.0–1.0). How many available steps + receive ghost notes. 0.3 = roughly 30% of steps at peak bias. + velocity: Single velocity, ``(low, high)`` tuple for random range, + a list/sequence of values (indexed by step), or a callable + that takes the step index ``i`` and returns a velocity. + Allows dynamic values like Perlin noise curves. + bias: Probability distribution shape: + + - ``"uniform"`` - equal probability everywhere + - ``"offbeat"`` - prefer 8th-note off-beats (&) + - ``"sixteenths"`` - prefer 16th-note subdivisions (e/a) + - ``"before"`` - cluster just before beat positions + - ``"after"`` - cluster just after beat positions + - ``"downbeat"`` - reinforce the beat (inverse of offbeat) + - ``"upbeat"`` - strictly 8th-note off-beats only + - ``"e_and_a"`` - all non-downbeat 16th positions + - Or: a list of floats (one per grid step) for a custom field. + Use :meth:`build_ghost_bias` to generate a named curve + and then modify specific steps before passing it here. + + no_overlap: If True (default), skip where same pitch already exists. + Essential for layering ghosts around hand-placed anchors. + grid: Grid resolution. Defaults to the pattern's default grid. + duration: Note duration in beats (default 0.1). + seed: Fix the ghost layer for this call (an int); omit to use the + pattern's RNG. + + **Tip — freeze the layer each cycle:** ``seed=`` starts a fresh + random stream on every rebuild, so the same steps — and the same + ``(low, high)`` velocity draws — are chosen on every cycle: the + ghost layer is locked in place. The default ``self.rng`` + advances state across rebuilds, so placement differs every cycle. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) + p.hit_steps("snare_1", [4, 12], velocity=95) + + # Different ghost placement each cycle (default) + p.ghost_fill("kick_1", density=0.2, velocity=(30, 45), + bias="sixteenths", no_overlap=True) + + # The same ghost layer every cycle — placement frozen + p.ghost_fill("snare_1", density=0.15, velocity=(25, 40), + bias="before", seed=42) + ``` + """ + + rng = self._rng_from(seed, rng) + + if grid is None: + grid = self._default_grid + + if grid <= 0: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + if isinstance(bias, list): + weights = list(bias) + if len(weights) < grid: + weights.extend( + [weights[-1] if weights else 0.0] * (grid - len(weights)) + ) + elif len(weights) > grid: + weights = weights[:grid] + else: + weights = self.build_ghost_bias(grid, bias) + + max_weight = max(weights) if weights else 1.0 + + if max_weight <= 0: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + step_duration = self._pattern.length / grid + + for i in range(grid): + prob = density * weights[i] / max_weight + + if rng.random() >= prob: + continue + + if no_overlap and self._has_pitch_at_beat(pitch, i * step_duration): + continue + + if callable(velocity): + vel = int(velocity(i)) + elif isinstance(velocity, tuple): + if len(velocity) != 2: + raise ValueError( + f"ghost_fill velocity tuple must be (low, high); for a per-step sequence use a list, got {velocity!r}" + ) + vel = rng.randint(int(velocity[0]), int(velocity[1])) + elif isinstance(velocity, list): + vel = int(velocity[i % len(velocity)]) + else: + vel = int(typing.cast(typing.Union[int, float], velocity)) + + self.note( + pitch=pitch, beat=i * step_duration, velocity=vel, duration=duration + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def cellular_1d( + self, + pitch: typing.Union[int, str], + rule: int = 30, + generation: typing.Optional[int] = None, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_CA_VELOCITY, + duration: float = 0.1, + no_overlap: bool = False, + probability: float = 1.0, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate an evolving rhythm using a 1D cellular automaton. + + Uses an elementary CA (1D binary cellular automaton) to produce + rhythmic patterns that change organically each bar. The CA state + evolves by one generation per cycle, creating patterns that are + deterministic yet surprising - structured chaos. + + Rule 30 is the default: it produces quasi-random patterns with hidden + self-similarity. Rule 90 produces fractal patterns. Rule 110 is + Turing-complete. + + Parameters: + pitch: MIDI note number or drum name. + rule: Wolfram rule number (0–255). Default 30. + generation: CA generation to render. Defaults to ``self.cycle`` + so the pattern evolves each bar automatically. + velocity: MIDI velocity, or a ``(low, high)`` tuple for a + fresh random draw per hit. + duration: Note duration in beats. + no_overlap: If True, skip where same pitch already exists. + probability: Chance (0.0–1.0) that each hit plays — 1.0 places them all, lower thins. + seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + p.hit_steps("kick_1", [0, 8], velocity=100) + p.cellular_1d("kick_1", rule=30, velocity=40, no_overlap=True) + ``` + """ + + if generation is None: + generation = self.cycle + rng = self._rng_from(seed, rng) + + steps = self._default_grid + sequence = subsequence.sequence_utils.generate_cellular_automaton_1d( + steps=steps, rule=rule, generation=generation + ) + + self._place_rhythm_sequence( + sequence, pitch, velocity, duration, probability, rng, no_overlap=no_overlap + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def cellular_2d( + self, + pitches: typing.List[typing.Union[int, str]], + rule: str = "B368/S245", + generation: typing.Optional[int] = None, + velocity: typing.Union[ + int, typing.Tuple[int, int], typing.List[int] + ] = subsequence.constants.velocity.DEFAULT_CA_VELOCITY, + duration: float = 0.1, + no_overlap: bool = False, + probability: float = 1.0, + initial_state: typing.Union[str, typing.List[typing.List[int]]] = "center", + density: float = 0.5, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate polyphonic patterns using a 2D Life-like cellular automaton. + + Evolves a 2D grid where rows map to pitches or instruments and columns + map to time steps. Live cells in the final generation become note + onsets, producing patterns with spatial structure that evolves each bar. + + The default rule B368/S245 (Morley/"Move") produces chaotic, active + patterns. B3/S23 is Conway's Life; B36/S23 is HighLife. + + Parameters: + pitches: MIDI note numbers or drum names, one per row. Row 0 + maps to the first pitch. + rule: Birth/Survival notation, e.g. ``"B3/S23"`` for Conway's + Life, ``"B368/S245"`` for Morley. + generation: CA generation to render. Defaults to ``self.cycle`` + so the grid evolves each bar automatically. + velocity: Single MIDI velocity for all rows, or a list with one + value per row. + duration: Note duration in beats. + no_overlap: If True, skip notes where same pitch already exists. + probability: Chance (0.0–1.0) that each live cell plays — 1.0 places them all, lower thins. + initial_state: The generation-0 grid. ``"center"`` (default) lights a + single cell at the centre; ``"random"`` fills cells with probability + *density* (seed it with *seed* for a reproducible fill); or pass an + explicit ``list[list[int]]`` (rows × cols) for a custom start. + density: Fill probability for ``initial_state="random"`` (0.0–1.0). + seed: RNG seed (an int) for the ``"random"`` initial grid, so it + reproduces. Ignored for ``"center"`` or an explicit grid (a + warning is emitted if passed there). + rng: Random generator for the probability thinning. Defaults to + ``self.rng``. + + Example: + ```python + pitches = [36, 38, 42, 46] # kick, snare, hihat, open hihat + p.cellular_2d(pitches, rule="B3/S23", initial_state="random", seed=7, density=0.3) + ``` + """ + + if not pitches: + raise ValueError("pitches list cannot be empty") + + if isinstance(velocity, list) and not velocity: + raise ValueError("velocity list cannot be empty") + + if generation is None: + generation = self.cycle + + if rng is None: + rng = self.rng + + # Translate (initial_state, seed) into the underlying generator's seed arg, + # which stays int-or-grid: 1 = single centre cell, any other int = an + # RNG-seeded fill at *density*, a grid = an explicit starting state. + grid_seed: typing.Union[int, typing.List[typing.List[int]]] + if isinstance(initial_state, list): + grid_seed = initial_state + elif initial_state == "center": + grid_seed = 1 + elif initial_state == "random": + if isinstance(seed, int): + # The generator reserves 1 as its centre-cell sentinel, so remap + # seed=1 to a fixed surrogate - every seed stays deterministic. + grid_seed = seed if seed != 1 else -1 + else: + grid_seed = rng.randint(2, 2_147_483_646) + else: + raise ValueError( + f'cellular_2d(): initial_state must be "center", "random", or a grid — got {initial_state!r}' + ) + + if seed is not None and initial_state != "random": + warnings.warn( + f'cellular_2d(): seed= only affects initial_state="random" and is ignored for ' + f'initial_state={initial_state!r} — pass initial_state="random" to seed the grid', + UserWarning, + stacklevel=2, + ) + + cols = self._default_grid + rows = len(pitches) + + grid = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=rows, + cols=cols, + rule=rule, + generation=generation, + seed=grid_seed, + density=density, + ) + + for row_idx, pitch in enumerate(pitches): + row_velocity: typing.Union[int, typing.Tuple[int, int]] + + if isinstance(velocity, list): + row_velocity = int(velocity[row_idx % len(velocity)]) + elif isinstance(velocity, tuple): + # (low, high) range - resolved per placed note downstream. + row_velocity = velocity + else: + row_velocity = int(velocity) + + self._place_rhythm_sequence( + grid[row_idx], + pitch, + row_velocity, + duration, + probability, + rng, + no_overlap=no_overlap, + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def markov( + self, + transitions: typing.Dict[str, typing.List[typing.Tuple[str, int]]], + pitch_map: typing.Dict[str, int], + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.1, + spacing: float = 0.25, + start: typing.Optional[str] = None, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a sequence by walking a first-order Markov chain. + + Builds a :class:`~subsequence.weighted_graph.WeightedGraph` from + ``transitions`` and walks it, placing one note per ``spacing`` beats. + The probability of each next state depends only on the current one - + use this to generate basslines, melodies, or rhythm motifs that have + stylistic coherence without being perfectly repetitive. + + The transition dict uses the same ``(target, weight)`` pair format + as :meth:`Composition.form`, so the idiom is already familiar. + + Parameters: + transitions: Mapping of state name to a list of + ``(next_state, weight)`` tuples. Higher weight means higher + probability of that transition. + pitch_map: Mapping of state name to absolute MIDI note number. + States absent from this dict are walked but produce no note. + velocity: MIDI velocity for all placed notes (default 100), + or a ``(low, high)`` tuple for a fresh random draw per step. + duration: Note duration in beats (default 0.1). + spacing: Time between note onsets in beats (default 0.25 = 16th note). + start: Name of the starting state. Defaults to the first key + in ``transitions`` when not provided. + seed: Fix the walk for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Raises: + ValueError: If ``transitions`` or ``pitch_map`` is empty. + + Example: + ```python + # Walking bassline: root anchors, 3rd and 5th passing tones + p.markov( + transitions={ + "root": [("3rd", 3), ("5th", 2), ("root", 1)], + "3rd": [("5th", 3), ("root", 2)], + "5th": [("root", 3), ("3rd", 1)], + }, + pitch_map={"root": 52, "3rd": 56, "5th": 59}, + velocity=80, + spacing=0.5, + ) + ``` + """ + + rng = self._rng_from(seed, rng) + + if not transitions: + raise ValueError("transitions dict cannot be empty") + + if not pitch_map: + raise ValueError("pitch_map dict cannot be empty") + + graph: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) + + for source, targets in transitions.items(): + for target, weight in targets: + graph.add_transition(source, target, weight) + + if start is None: + start = next(iter(transitions)) + + if spacing <= 0: + raise ValueError( + f"markov() spacing is the time between notes in beats — it must be positive, got {spacing}" + ) + + n_steps = int(self._pattern.length / spacing) + + state = start + beat = 0.0 + + for _ in range(n_steps): + if state in pitch_map: + vel = self._resolve_velocity(velocity, rng) + self.note( + pitch=pitch_map[state], beat=beat, velocity=vel, duration=duration + ) + + state = graph.choose_next(state, rng) + beat += spacing + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def melody( + self, + state: subsequence.melodic_state.MelodicState, + spacing: float = 0.25, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, + duration: float = 0.2, + chord_tones: typing.Optional[typing.List[int]] = None, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a melodic line by querying a persistent :class:`~subsequence.melodic_state.MelodicState`. + + Places one note (or rest) per ``spacing`` beats for the full pattern + length. Pitch selection is guided by the NIR cognitive model inside + ``state``: after a large leap the model expects a direction reversal; + after a small step it expects continuation. Chord tones, range + gravity, and a pitch-diversity penalty further shape the output. + + Because ``state`` lives outside the pattern builder and persists + across bar rebuilds, melodic continuity is maintained automatically - + no manual history management is required. + + Parameters: + state: Persistent :class:`~subsequence.melodic_state.MelodicState` + instance created once at module level. + spacing: Time between note onsets in beats (default 0.25 = 16th note). + velocity: MIDI velocity. An ``int`` applies a fixed level; a + ``(low, high)`` tuple draws uniformly from that range each spacing. + duration: Note duration in beats (default 0.2 - slightly shorter + than a 16th note, giving a crisp attack). + chord_tones: Optional list of MIDI note numbers that are chord + tones this bar (e.g. from ``chord.tones(root)``). Chord-tone + pitch classes receive a ``chord_weight`` bonus inside ``state``. + seed: Fix the walk for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + melody_state = subsequence.MelodicState( + key="A", mode="aeolian", + low=60, high=84, + nir_strength=0.6, + chord_weight=0.4, + ) + + @composition.pattern(channel=4, beats=4) + def lead (p, chord): + tones = chord.tones(72) if chord else None + p.melody(melody_state, spacing=0.5, velocity=(70, 100), chord_tones=tones) + ``` + """ + + rng = self._rng_from(seed, rng) + + # A state built without key/mode adopts the composition's here + # (idempotent — explicit constructor arguments always win). + state.configure_defaults(self.key, self.scale) + + if spacing <= 0: + raise ValueError( + f"melody() spacing is the time between notes in beats — it must be positive, got {spacing}" + ) + + n_steps = int(self._pattern.length / spacing) + beat = 0.0 + + for _ in range(n_steps): + pitch = state.choose_next(chord_tones, rng, beat=beat) + + if pitch is not None: + vel = self._resolve_velocity(velocity, rng) + self.note(pitch=pitch, beat=beat, velocity=vel, duration=duration) + + beat += spacing + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def lsystem( + self, + pitch_map: typing.Dict[str, typing.Union[int, str]], + axiom: str, + rules: typing.Dict[ + str, typing.Union[str, typing.List[typing.Tuple[str, float]]] + ], + generations: int = 3, + spacing: typing.Optional[float] = None, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.2, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a note sequence using L-system string rewriting. + + Expands ``axiom`` by applying ``rules`` for ``generations`` + iterations, then walks the resulting string placing a note for + each character found in ``pitch_map``. Unmapped characters are + silent rests - they advance time but produce no note. + + The defining musical property is self-similarity: patterns repeat + at different time scales. The Fibonacci rule (``A → AB``, + ``B → A``) places hits at golden-ratio spacings. Koch and dragon + curve rules produce fractal melodic contours. + + With ``spacing=None`` (default) the entire expanded string is fitted + into the bar: each generation makes notes twice as dense while + preserving the overall shape. With a fixed ``spacing`` the string is + truncated to fit and the density stays constant. + + Parameters: + pitch_map: Maps single characters to MIDI notes or drum names. + Characters absent from the map produce rests. + axiom: Starting string (e.g. ``"A"``). + rules: Production rules. Deterministic: ``{"A": "AB"}``. + Stochastic: ``{"A": [("AB", 3), ("BA", 1)]}``. + generations: Rewriting iterations. String length grows + exponentially - keep this to 3–8 for practical use. + spacing: Time between symbols in beats. ``None`` (default) + auto-fits the full expanded string into the bar. A float + uses fixed spacing and truncates excess symbols. + velocity: MIDI velocity. An ``(low, high)`` tuple randomises + per note. + duration: Note duration in beats. + seed: Fix the stochastic-rule choices and velocity draws for this + call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # Fibonacci kick rhythm - self-similar hit spacing + p.lsystem( + pitch_map={"A": "kick_1"}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=6, + velocity=80, + ) + + # Fractal melody over scale notes + p.lsystem( + pitch_map={"F": 60, "G": 62, "+": 64, "-": 67}, + axiom="F", + rules={"F": "F+G", "G": "-F"}, + generations=4, + spacing=0.25, + velocity=(70, 100), + ) + ``` + """ + + rng = self._rng_from(seed, rng) + + expanded = subsequence.sequence_utils.lsystem_expand( + axiom=axiom, + rules=rules, + generations=generations, + rng=rng, + ) + + if not expanded: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + if spacing is None: + auto_step = self._pattern.length / len(expanded) + symbols = expanded + else: + if spacing <= 0: + raise ValueError( + f"lsystem() spacing is the time between symbols in beats — it must be positive, got {spacing}" + ) + + auto_step = spacing + n_steps = int(self._pattern.length / spacing) + symbols = expanded[:n_steps] + + beat = 0.0 + + for symbol in symbols: + if symbol in pitch_map: + vel = self._resolve_velocity(velocity, rng) + self.note( + pitch=pitch_map[symbol], + beat=beat, + velocity=vel, + duration=duration, + ) + beat += auto_step + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def thue_morse( + self, + pitch: typing.Union[int, str], + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.1, + pitch_b: typing.Optional[typing.Union[int, str]] = None, + velocity_b: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, + no_overlap: bool = False, + probability: float = 1.0, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Place notes using the Thue-Morse aperiodic binary sequence. + + The Thue-Morse sequence (0 1 1 0 1 0 0 1 1 0 0 1 0 1 1 0 …) is + perfectly balanced, overlap-free, and self-similar but never periodic. + It produces rhythmic patterns that feel structured yet never settle into + a simple repeating loop - a quality distinct from Euclidean rhythms + (evenly spaced) and cellular automata (rule-driven evolution). + + In **single-pitch mode** (default), notes are placed at positions where + the sequence is 1. In **two-pitch mode** (``pitch_b`` given), ``pitch`` + flips to the 0-positions and ``pitch_b`` takes the 1-positions - useful + for alternating two drums or two chord tones. + + Parameters: + pitch: Pitch (MIDI note number or drum name). Placed at the + sequence's 1-positions in single-pitch mode; at the + 0-positions when ``pitch_b`` takes over the 1s. + velocity: MIDI velocity for ``pitch``, or a ``(low, high)`` + tuple for a fresh random draw per hit. + duration: Note duration in beats. + pitch_b: Optional second pitch placed at sequence-1 positions. + When set, all steps produce a note (no rests). + velocity_b: Velocity for ``pitch_b`` (int or ``(low, high)`` + tuple). Defaults to ``velocity``. + no_overlap: Skip steps where ``pitch`` is already sounding. + probability: Chance (0.0–1.0) that each active step plays — 1.0 places them all, lower thins. + seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # Single-pitch Thue-Morse kick + p.thue_morse("kick_1", velocity=100) + + # Two-pitch mode: alternate kick and snare + p.thue_morse("kick_1", pitch_b="snare_1", velocity=100) + ``` + """ + rng = self._rng_from(seed, rng) + + sequence = subsequence.sequence_utils.thue_morse(self._default_grid) + + if not sequence: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + if pitch_b is None: + self._place_rhythm_sequence( + sequence, pitch, velocity, duration, probability, rng, no_overlap + ) + else: + if velocity_b is None: + velocity_b = velocity + + # Every step sounds one of the two voices (no rests), and no_overlap + # is honoured against whichever voice the step would place. + second_pitch = pitch_b + second_velocity = velocity_b + + def _event( + i: int, val: typing.Any + ) -> typing.Optional[ + typing.Tuple[ + typing.Union[int, str], + typing.Union[int, typing.Tuple[int, int]], + float, + ] + ]: + if val == 0: + return (pitch, velocity, duration) + return (second_pitch, second_velocity, duration) + + self._place_gated_sequence( + sequence, _event, probability, rng, no_overlap=no_overlap + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def de_bruijn( + self, + pitches: typing.List[typing.Union[int, str]], + window: int = 2, + spacing: typing.Optional[float] = None, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.2, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a melody that exhaustively traverses all pitch subsequences. + + A de Bruijn sequence B(k, n) over an alphabet of size ``k`` with window + ``n`` contains every possible subsequence of length ``n`` exactly once + (cyclically). Mapping each symbol to a pitch produces a melody that + systematically explores all possible ``n``-gram transitions - every + permutation of ``window`` consecutive pitches appears exactly once. + + With ``spacing=None`` (default) the full sequence is auto-fitted into the + bar, matching the behaviour of :meth:`lsystem`. With a fixed ``spacing`` + the sequence is truncated to fill the available beats. + + Parameters: + pitches: List of MIDI note numbers or note strings. The alphabet + size ``k`` is ``len(pitches)``. + window: Subsequence length ``n``. The output has ``len(pitches) ** window`` + notes. Keep small (2–4) for practical bar lengths. + spacing: Time between notes in beats. ``None`` auto-fits the sequence + into the bar; a float uses fixed spacing and truncates. + velocity: MIDI velocity. An ``(low, high)`` tuple randomises per note. + duration: Note duration in beats. + seed: Fix the velocity draws for this call (an int) — the note order + itself is deterministic; omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # All 2-note combinations of a pentatonic scale + p.de_bruijn([60, 62, 64, 67, 69], window=2, velocity=(60, 100)) + ``` + """ + + rng = self._rng_from(seed, rng) + + if not pitches: + raise ValueError("pitches list cannot be empty") + + k = len(pitches) + sequence = subsequence.sequence_utils.de_bruijn(k, window) + + if not sequence: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + if spacing is None: + auto_step = self._pattern.length / len(sequence) + symbols = sequence + else: + if spacing <= 0: + raise ValueError( + f"de_bruijn() spacing is the time between notes in beats — it must be positive, got {spacing}" + ) + + auto_step = spacing + n_steps = int(self._pattern.length / spacing) + symbols = sequence[:n_steps] + + beat = 0.0 + + for idx in symbols: + vel = self._resolve_velocity(velocity, rng) + self.note(pitch=pitches[idx], beat=beat, velocity=vel, duration=duration) + beat += auto_step + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def fibonacci( + self, + pitch: typing.Union[int, str], + count: int, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.2, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Place notes at golden-ratio-spaced beat positions (Fibonacci spiral timing). + + Uses the golden angle method - ``position_i = frac(i × φ) × bar_length``, + where ``frac`` keeps only the fractional part - to distribute ``count`` + events across the bar as a low-discrepancy (sunflower-seed) spread. + The result is sorted into ascending time order. Unlike a Euclidean rhythm (maximally even + spacing on a fixed grid), Fibonacci timing is irrational and places + events off-grid in a way that sounds organic and avoids metronomic + repetition. + + Parameters: + pitch: MIDI note number or drum name. + count: Number of notes to place. + velocity: MIDI velocity. An ``(low, high)`` tuple randomises per note. + duration: Note duration in beats. + seed: Fix the velocity draws for this call (an int) — the timing + itself is deterministic; omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # 11 hi-hat hits with golden-ratio spacing + p.fibonacci("hi_hat_closed", count=11, velocity=(60, 90)) + ``` + """ + + rng = self._rng_from(seed, rng) + + beats = subsequence.sequence_utils.fibonacci_rhythm(count, self._pattern.length) + + for beat in beats: + vel = self._resolve_velocity(velocity, rng) + self.note(pitch=pitch, beat=beat, velocity=vel, duration=duration) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def lorenz( + self, + pitches: typing.List[typing.Union[int, str]], + spacing: float = 0.25, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.2, + dt: float = 0.01, + sigma: float = 10.0, + rho: float = 28.0, + beta: float = 8.0 / 3.0, + x0: float = 0.1, + y0: float = 0.0, + z0: float = 0.0, + mapping: typing.Optional[ + typing.Callable[ + [float, float, float], + typing.Optional[typing.Tuple[typing.Union[int, str], int, float]], + ] + ] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a note sequence driven by the Lorenz strange attractor. + + Integrates the Lorenz system to produce a trajectory of (x, y, z) points, + each normalised to [0, 1]. The three axes provide correlated but + independent modulation sources: by default x drives pitch selection, + y drives velocity, and z drives duration. + + The Lorenz attractor is deterministic but extremely sensitive to initial + conditions: changing ``x0`` by even 0.001 produces a divergent trajectory + over time. This makes it ideal for cycle-by-cycle variation - pass + ``x0=p.cycle * 0.001`` to generate a unique but slowly evolving phrase + each bar. + + A custom ``mapping`` callable can override the default x/y/z → pitch/vel/dur + assignment, or return ``None`` for a rest. + + Parameters: + pitches: Pitch pool. The x-axis selects an index: ``int(x * len(pitches)) % len(pitches)``. + spacing: Time between notes in beats. Default 0.25 (16th note). + velocity: Fixed velocity or ``(low, high)`` tuple. Overridden by ``mapping``. + duration: Maximum note duration. z is scaled to ``[0.05, duration]``. + Overridden by ``mapping``. + dt: Integration time step. Default 0.01. + sigma, rho, beta: Lorenz parameters. Defaults produce the classic + butterfly attractor (chaotic regime). + x0, y0, z0: Initial conditions. Use ``x0=p.cycle * small_delta`` + for slowly evolving variation. + mapping: Optional callable ``(x, y, z) -> (pitch, velocity, duration)`` + or ``None`` for rest. + + Example: + ```python + scale = [60, 62, 64, 65, 67, 69, 71, 72] + p.lorenz(scale, spacing=0.25, velocity=(50, 110), x0=p.cycle * 0.002) + ``` + """ + + if not pitches: + raise ValueError("pitches list cannot be empty") + + if spacing <= 0: + raise ValueError( + f"lorenz() spacing is the time between notes in beats — it must be positive, got {spacing}" + ) + + n_steps = int(self._pattern.length / spacing) + points = subsequence.sequence_utils.lorenz_attractor( + n_steps, dt=dt, sigma=sigma, rho=rho, beta=beta, x0=x0, y0=y0, z0=z0 + ) + + beat = 0.0 + + for x, y, z in points: + if mapping is not None: + result = mapping(x, y, z) + if result is not None: + p_pitch, p_vel, p_dur = result + self.note(pitch=p_pitch, beat=beat, velocity=p_vel, duration=p_dur) + else: + pitch_idx = int(x * len(pitches)) % len(pitches) + p_pitch = pitches[pitch_idx] + if isinstance(velocity, tuple): + p_vel = int(velocity[0] + y * (velocity[1] - velocity[0])) + else: + # A fixed int means FIXED - the y axis only drives velocity + # when a (low, high) range invites it (or via mapping=). + p_vel = int(velocity) + p_dur = 0.05 + z * max(0.0, duration - 0.05) + self.note(pitch=p_pitch, beat=beat, velocity=p_vel, duration=p_dur) + + beat += spacing + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def reaction_diffusion( + self, + pitch: typing.Union[int, str], + threshold: float = 0.5, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.1, + feed_rate: float = 0.055, + kill_rate: float = 0.062, + steps: int = 1000, + no_overlap: bool = False, + probability: float = 1.0, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a rhythm from a 1D Gray-Scott reaction-diffusion simulation. + + Simulates the Gray-Scott model on a ring of ``_default_grid`` cells, + then thresholds the final V-concentration to produce a binary hit + pattern. Cells where concentration exceeds ``threshold`` become note + events. + + Unlike cellular automata - where rules are discrete and the state is + binary - reaction-diffusion evolves a continuous concentration field + governed by diffusion rates and chemical reactions. The resulting + spatial patterns (spots, stripes, travelling waves) have an organic, + biological character that maps naturally to rhythm. + + The ``feed_rate`` and ``kill_rate`` parameters control pattern type: + typical values that produce spots (useful rhythms) range from 0.020–0.062 + for feed and 0.045–0.069 for kill. The defaults (F=0.055, k=0.062) + produce a stable spotted pattern. + + Parameters: + pitch: MIDI note number or drum name. + threshold: V-concentration threshold for note placement (0.0–1.0). + Lower values produce denser patterns. + velocity: MIDI velocity. An ``(low, high)`` tuple is NOT random: + each step's local V-concentration is mapped into the range + deterministically, so notes are louder where the pattern is + denser. + duration: Note duration in beats. + feed_rate: Rate of U replenishment. Default 0.055. + kill_rate: Rate of V removal. Default 0.062. + steps: Number of simulation iterations. More = more developed + pattern. Default 1000. + no_overlap: Skip steps where ``pitch`` is already sounding. + probability: Chance (0.0–1.0) that each active step plays — 1.0 places them all, lower thins. + seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # Organic kick pattern from reaction-diffusion + p.reaction_diffusion("kick_1", threshold=0.4, feed_rate=0.037, kill_rate=0.060) + ``` + """ + rng = self._rng_from(seed, rng) + + concentrations = subsequence.sequence_utils.reaction_diffusion_1d( + width=self._default_grid, + steps=steps, + feed_rate=feed_rate, + kill_rate=kill_rate, + ) + + sequence = [1 if c > threshold else 0 for c in concentrations] + + if isinstance(velocity, tuple): + # Map concentration to velocity range for active steps: louder + # where the pattern is denser (deterministic, not random). + midi_vel_lo, midi_vel_hi = velocity + + def _event( + i: int, hit: typing.Any + ) -> typing.Optional[ + typing.Tuple[ + typing.Union[int, str], + typing.Union[int, typing.Tuple[int, int]], + float, + ] + ]: + if hit == 0: + return None + + vel = int(midi_vel_lo + concentrations[i] * (midi_vel_hi - midi_vel_lo)) + return (pitch, vel, duration) + + self._place_gated_sequence( + sequence, _event, probability, rng, no_overlap=no_overlap + ) + else: + self._place_rhythm_sequence( + sequence, pitch, int(velocity), duration, probability, rng, no_overlap + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def self_avoiding_walk( + self, + pitches: typing.List[typing.Union[int, str]], + spacing: float = 0.25, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.2, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a melody using a self-avoiding random walk. + + A self-avoiding walk moves ±1 step through a pitch index space, tracking + visited positions and refusing to revisit them. When the walk is trapped + (all neighbours visited), the visited set resets and the walk continues + from the current position - creating natural phrase boundaries. + + Compared to :func:`random_walk`, the self-avoiding variant guarantees + pitch diversity within each phrase: no pitch repeats until the walk + resets. The contiguous step motion (never skipping pitches) gives + melodies a smooth, step-wise quality with occasional direction reversals. + + Parameters: + pitches: Ordered list of MIDI note numbers or note strings. The walk + moves through indices ``[0, len(pitches) - 1]``, mapping each to + the corresponding pitch. + spacing: Time between notes in beats. Default 0.25 (16th note). + velocity: MIDI velocity. An ``(low, high)`` tuple randomises per note. + duration: Note duration in beats. + seed: Fix the walk for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + scale = subsequence.scale_notes("C", "ionian", low=60, high=72) + p.self_avoiding_walk(scale, spacing=0.25, velocity=(60, 100)) + ``` + """ + + rng = self._rng_from(seed, rng) + + if not pitches: + raise ValueError("pitches list cannot be empty") + + if spacing <= 0: + raise ValueError( + f"self_avoiding_walk() spacing is the time between notes in beats — it must be positive, got {spacing}" + ) + + n_steps = int(self._pattern.length / spacing) + indices = subsequence.sequence_utils.self_avoiding_walk( + n=n_steps, + low=0, + high=len(pitches) - 1, + rng=rng, + ) + + beat = 0.0 + + for idx in indices: + vel = self._resolve_velocity(velocity, rng) + self.note(pitch=pitches[idx], beat=beat, velocity=vel, duration=duration) + beat += spacing + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def thin( + self, + pitch: typing.Optional[typing.Union[int, str]] = None, + strategy: typing.Union[str, typing.List[float]] = "strength", + amount: float = 0.5, + grid: typing.Optional[int] = None, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Remove notes from the pattern based on their rhythmic position. + + This is the musical inverse of :meth:`ghost_fill()`. Where ``ghost_fill`` + uses bias weights to decide where to *add* ghost notes, ``thin`` uses the + same position vocabulary to decide where to *remove* notes. A high + strategy weight on a position means that position is dropped first. + + The strategy names match those in :meth:`build_ghost_bias()` and carry the + same rhythmic meaning: + + - ``"sixteenths"`` - removes 16th-note subdivisions (e/a), keeps beats and &. + - ``"offbeat"`` - removes the & position, straightens the groove. + - ``"e_and_a"`` - removes all non-downbeat positions, keeps only beats. + - ``"downbeat"`` - removes beat positions (floating/displaced effect). + - ``"upbeat"`` - removes only the & position. + - ``"uniform"`` - removes from all positions equally (per-instrument dropout). + - ``"strength"`` - progressive thinning: weakest positions (e/a) drop first, + strongest (downbeat) last. Useful for Perlin-driven density control. + + When ``pitch`` is given, only notes of that instrument are affected - + useful for drum layers. When ``pitch`` is ``None`` (the default), all + notes regardless of pitch are candidates. This makes ``thin`` a + rhythm-aware generalisation of :meth:`dropout()`, and is ideal for + tonal patterns such as arpeggios where each step carries a different pitch. + + Position classification is **zone-based**: each grid step owns the pulse range + ``[N * step_pulses, (N + 1) * step_pulses)``, so notes shifted by swing or + groove are still classified correctly regardless of call order. + + Parameters: + pitch: Drum name or MIDI note number to target, or ``None`` to thin + all notes regardless of pitch. Defaults to ``None``. + strategy: Named strategy string or a list of per-step drop-priority + floats (0.0 = never drop, 1.0 = highest drop priority). Must have + length equal to ``grid`` when a list is provided. + amount: Overall thinning depth (0.0 = remove nothing, 1.0 = remove all + qualifying). Effective drop probability = ``priority * amount``. + Drive this with a Perlin field or section progress for smooth, + organic thinning over time. + grid: Step grid size. Defaults to the pattern's ``default_grid``. + seed: Fix the thinning for this call (an int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example:: + + # Thin 16th ghost notes from the kick, keep anchors and off-beats + p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) + p.ghost_fill("kick_1", density=0.3, velocity=(25, 40), bias="sixteenths") + p.thin("kick_1", "sixteenths", amount=0.8) + + # Perlin-driven progressive thinning of hi-hats + sparseness = perlin_1d(p.cycle * 0.07, seed=42) + p.thin("hi_hat_closed", "strength", amount=sparseness) + + # Thin an arpeggio (all pitches) - no pitch loop needed + p.thin(strategy="strength", amount=sparseness) + """ + + rng = self._rng_from(seed, rng) + + if grid is None: + grid = self._default_grid + + if pitch is None: + midi_pitch = None + else: + midi_pitch = self._resolve_pitch_lenient(pitch) + if midi_pitch is None: + # Named voice this device lacks (already warned once): there are + # no such notes to thin, so this is a no-op rather than an error. + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + # Build the per-step drop-priority weights. + # + # Strategy names are shared with ghost_fill's bias vocabulary. The per-step + # weights from build_ghost_bias() are reused directly, with semantics inverted: + # ghost_fill: high weight → place a note here + # thin: high weight → drop a note from here + # + # "strength" is defined only for thin() - it expresses a thinning hierarchy + # (weakest positions drop first) which has no meaningful ghost_fill equivalent. + if strategy == "strength": + # Per-beat drop priorities: e/a (1.0) > & (0.6) > downbeat (0.05). + # As `amount` rises, progressively weaker positions are removed first. + steps_per_beat = max(1, grid // 4) + priorities: typing.List[float] = [] + for i in range(grid): + pos = i % steps_per_beat + if pos == 0: + priorities.append(0.05) + elif steps_per_beat > 1 and pos == steps_per_beat // 2: + priorities.append(0.6) + else: + priorities.append(1.0) + elif isinstance(strategy, list): + if len(strategy) != grid: + raise ValueError( + f"thin() custom strategy list has {len(strategy)} values " + f"but grid has {grid} steps." + ) + priorities = list(strategy) + else: + # Reuse build_ghost_bias() weights for all shared strategy names. + # The positions that ghost_fill prefers to add to are the same + # positions that thin() will prefer to remove from. + priorities = self.build_ghost_bias(grid, strategy) + + # Zone-based pulse classification. + # Zone N owns pulses in [ N * step_pulses, (N+1) * step_pulses ). + # Notes shifted by swing or groove remain in their original zone. + total_pulses = self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + step_pulses = total_pulses / grid + + pulses_to_remove: typing.List[int] = [] + + for pulse, step in list(self._pattern.steps.items()): + zone = int(pulse / step_pulses) + if zone >= grid: + zone = grid - 1 + + priority = priorities[zone] + if priority <= 0.0: + continue + + # Separate target notes from protected notes at this pulse. + if midi_pitch is None: + remaining = [] + targets = list(step.notes) + else: + remaining = [n for n in step.notes if n.pitch != midi_pitch] + targets = [n for n in step.notes if n.pitch == midi_pitch] + + for note in targets: + if rng.random() >= priority * amount: + remaining.append(note) + # else: note is dropped + + if not remaining: + pulses_to_remove.append(pulse) + else: + step.notes = remaining + + for pulse in pulses_to_remove: + del self._pattern.steps[pulse] + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def ratchet( + self, + subdivisions: int = 2, + pitch: typing.Optional[typing.Union[int, str]] = None, + probability: float = 1.0, + velocity_start: float = 1.0, + velocity_end: float = 1.0, + shape: typing.Union[str, typing.Callable[[float], float]] = "linear", + gate: float = 0.5, + steps: typing.Optional[typing.List[int]] = None, + grid: typing.Optional[int] = None, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Subdivide existing notes into rapid repeated hits (rolls/ratchets). + + A post-placement transform: takes notes already in the pattern and + replaces each one with ``subdivisions`` evenly-spaced sub-hits within + the original note's duration window. The velocity of each sub-hit is + interpolated from ``velocity_start`` to ``velocity_end`` (as multipliers + on the original velocity) using the ``shape`` easing curve, so crescendo + rolls, decrescendo buzzes, and flat repeats are all one parameter apart. + + Call ``ratchet()`` after note-placement methods (``euclidean``, + ``hit_steps``, ``arpeggio``, etc.) and before ``swing`` or ``groove`` + so that the subdivisions sit inside the original note slot and swing + displacement still affects the parent position. + + Parameters: + subdivisions: Number of sub-hits replacing each note (default 2). + If the note's duration is shorter than ``subdivisions`` pulses, + subdivisions are clamped to ``note.duration`` so they never + stack on the same pulse. + pitch: Only ratchet notes matching this pitch (MIDI number or drum + name). ``None`` (default) ratchets all notes regardless of + pitch — useful for melodic patterns such as arpeggios. + probability: Chance (0.0–1.0) that each note gets ratcheted. Notes + that fail the check are left completely unchanged. Default 1.0 + (every note is ratcheted). + velocity_start: Velocity multiplier for the first sub-hit (0.0–2.0). + Default 1.0 (same as the original). + velocity_end: Velocity multiplier for the last sub-hit (0.0–2.0). + Default 1.0. Set ``velocity_start=0.3, velocity_end=1.0`` for + a crescendo roll; ``1.0, 0.2`` for a decrescendo buzz. + shape: Easing curve applied to the velocity interpolation across + sub-hits. Accepts any name from ``subsequence.easing`` (e.g. + ``"ease_in"``, ``"ease_out"``, ``"s_curve"``) or a custom + callable ``f(t) → t`` for t ∈ [0, 1]. Default ``"linear"``. + gate: Sub-note duration as a fraction of each subdivision slot + (0.0–1.0). ``1.0`` = legato (sub-hits touch), ``0.5`` = + staccato (half the slot). Default 0.5. + steps: Grid positions to ratchet (e.g. ``[0, 4, 12]``). Notes are + classified to grid zones the same way ``thin()`` works — swing- + shifted notes remain in their original zone. ``None`` (default) + applies ratchet to all eligible notes. + grid: Grid resolution used for ``steps`` zone classification. + Defaults to the pattern's ``default_grid``. + seed: Fix the probability gating for this call (an int); omit to + use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Examples: + ```python + # Subdivide every hi-hat into a triplet roll + p.euclidean("hi_hat_closed", 8).ratchet(3, pitch="hi_hat_closed") + + # Crescendo roll into a snare + p.hit_steps("snare", [12]).ratchet(4, velocity_start=0.3, + velocity_end=1.0, + shape="ease_in") + + # Probabilistic 2× ratchet on hi-hats only + p.euclidean("hi_hat_closed", 12).ratchet(2, pitch="hi_hat_closed", + probability=0.4, gate=0.3) + + # Ratchet only steps 0 and 8 (downbeats) + p.euclidean("kick_1", 6).ratchet(2, pitch="kick_1", steps=[0, 8]) + ``` + """ + + rng = self._rng_from(seed, rng) + + if grid is None: + grid = self._default_grid + + if pitch is None: + midi_pitch = None + else: + midi_pitch = self._resolve_pitch_lenient(pitch) + if midi_pitch is None: + # Named voice this device lacks (already warned once): nothing + # matches, so leave the pattern unchanged rather than raising. + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + ease_fn = subsequence.easing.get_easing(shape) + + # Build zone set for steps mask (zone-based, matching thin()'s approach). + target_zones: typing.Optional[typing.Set[int]] = None + if steps is not None: + target_zones = set(steps) + + total_pulses = self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + step_pulses = total_pulses / grid + + new_steps: typing.Dict[int, subsequence.pattern.Step] = {} + + for pulse, step in self._pattern.steps.items(): + # Zone classification for steps mask. + if target_zones is not None: + zone = int(pulse / step_pulses) + if zone >= grid: + zone = grid - 1 + in_zone = zone in target_zones + else: + in_zone = True + + # Separate targeted notes from passthrough notes. + if midi_pitch is None: + targets = list(step.notes) if in_zone else [] + passthrough = [] if in_zone else list(step.notes) + else: + if in_zone: + targets = [n for n in step.notes if n.pitch == midi_pitch] + passthrough = [n for n in step.notes if n.pitch != midi_pitch] + else: + targets = [] + passthrough = list(step.notes) + + # Passthrough notes keep their original pulse position. + if passthrough: + if pulse not in new_steps: + new_steps[pulse] = subsequence.pattern.Step() + new_steps[pulse].notes.extend(passthrough) + + for note in targets: + # Probability gate — failed notes are kept unchanged. + if probability < 1.0 and rng.random() < (1.0 - probability): + if pulse not in new_steps: + new_steps[pulse] = subsequence.pattern.Step() + new_steps[pulse].notes.append(note) + continue + + # Clamp subdivisions so sub-hits never stack on the same pulse. + effective_subdivs = min(subdivisions, note.duration) + if effective_subdivs < 1: + effective_subdivs = 1 + + slot_pulses = note.duration / effective_subdivs + + for i in range(effective_subdivs): + sub_pulse = pulse + int(round(i * slot_pulses)) + + # Velocity interpolation via easing. + if effective_subdivs == 1: + t = 0.0 + else: + t = i / (effective_subdivs - 1) + eased_t = ease_fn(t) + vel_mul = velocity_start + (velocity_end - velocity_start) * eased_t + sub_velocity = max(1, min(127, int(round(note.velocity * vel_mul)))) + + sub_duration = max(1, int(round(slot_pulses * gate))) + + sub_note = subsequence.pattern.Note( + pitch=note.pitch, + velocity=sub_velocity, + duration=sub_duration, + channel=note.channel, + ) + + if sub_pulse not in new_steps: + new_steps[sub_pulse] = subsequence.pattern.Step() + new_steps[sub_pulse].notes.append(sub_note) + + self._pattern.steps = new_steps + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def evolve( + self, + pitches: typing.List[typing.Union[int, str]], + length: typing.Optional[int] = None, + drift: float = 0.0, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.2, + spacing: float = 0.25, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Loop a pitch sequence that gradually mutates each cycle. + + On cycle 0, the sequence is locked to the initial ``pitches`` (truncated to + ``length`` if provided). Each subsequent cycle, every step has a ``drift`` + probability of being replaced by a randomly-chosen value from the pool. + When ``drift=0.0`` the loop never changes; when ``drift=1.0`` every step + is redrawn every cycle. + + State is stored in ``p.data`` under a key derived from the pitch content, so the + buffer persists across pattern rebuilds. The buffer is reset whenever + ``cycle == 0`` so restarts produce deterministic output. + + Combine with ``p.snap_to_scale()`` to keep drifted pitches in key: + + ```python + p.evolve([60, 64, 67, 72], length=8, drift=0.12) + p.snap_to_scale("C", "minor") + ``` + + Parameters: + pitches: Initial pitch pool. The initial buffer is built from the first + ``length`` values (cycling if shorter than ``length``). Mutation + also draws replacements from this pool. + length: Number of steps in the loop. Defaults to ``len(pitches)``. + drift: Per-step mutation probability each cycle (0.0–1.0). + ``0.0`` = locked loop, ``1.0`` = fully random each cycle. + velocity: MIDI velocity. An ``(low, high)`` tuple randomises per step. + duration: Note duration in beats. + spacing: Beat interval between steps. + seed: Fix the drift mutations and velocity draws for this call (an + int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # 8-step loop that slowly diverges from its seed + p.evolve([60, 62, 64, 65, 67, 69], length=8, drift=0.1, + velocity=(70, 100), spacing=0.5) + p.snap_to_scale("C", "dorian") + ``` + """ + + rng = self._rng_from(seed, rng) + + if not pitches: + raise ValueError("pitches list cannot be empty") + + resolved_opt = [ + self._resolve_pitch_lenient(p) if isinstance(p, str) else p for p in pitches + ] + resolved = [r for r in resolved_opt if r is not None] + if not resolved: + # Every seed name was a voice this device lacks (each warned once). + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + n = length if length is not None else len(resolved) + + # Stable key derived from the seed *content* (not object identity). The + # documented idiom passes a fresh list literal every cycle; keying on + # ``id(pitches)`` gave that literal a new address each cycle, so the + # buffer re-seeded every cycle (drift never accumulated) and a dict key + # leaked per cycle. Content keying makes the buffer persist so the walk + # actually evolves, and scoping by the owning pattern's builder name keeps + # two patterns' identical seeds from sharing one walk. ``resolved`` (the + # post-lenient ints) and ``n`` are folded in so a changed step count or a + # seed whose unknown names were dropped still keys stably. + builder_fn = getattr(self._pattern, "_builder_fn", None) + scope = getattr(builder_fn, "__name__", "") if builder_fn is not None else "" + data_key = f"_evolve_{scope}_{n}_{tuple(resolved)}" + + # Initialise or reset the buffer on cycle 0. + if data_key not in self.data or self.cycle == 0: + self.data[data_key] = [resolved[i % len(resolved)] for i in range(n)] + + buffer = self.data[data_key] + + # Mutate the buffer in place for this cycle (skipped on cycle 0 — seed plays first). + if self.cycle > 0 and drift > 0.0: + for i in range(n): + if rng.random() < drift: + buffer[i] = rng.choice(resolved) + + # Place notes. + for i, pitch in enumerate(buffer): + vel = self._resolve_velocity(velocity, rng) + self.note(pitch=pitch, beat=i * spacing, velocity=vel, duration=duration) + + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def branch( + self, + pitches: typing.List[typing.Union[int, str]], + depth: int = 2, + path: int = 0, + mutation: float = 0.0, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_GENERATIVE_VELOCITY, + duration: float = 0.2, + spacing: float = 0.25, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Generate a melodic variation by navigating a fractal tree of transforms. + + The ``pitches`` sequence is the "trunk". At each branch level, two musical + transforms are assigned deterministically (derived from the original + sequence), and the ``path`` index selects left or right at each level. + After ``depth`` levels the result is a variation that is always + structurally related to the input ``pitches``. + + Use ``path=p.cycle`` to step through all ``2 ** depth`` variations in + order; the index wraps automatically. + + **Transforms** (assigned deterministically per level): + + - *Retrograde* — reverse the sequence. + - *Invert* — mirror each pitch around the first note. + - *Transpose* — shift all pitches by the interval between the first + two notes. + - *Rotate* — shift the starting position by one step. + - *Scale intervals* — multiply intervals from the first note by 0.5 + (compress) or 2.0 (expand), rounded to the nearest semitone. + + An optional ``mutation`` layer randomly substitutes individual notes + with other input pitches on top of the deterministic branching. + + Parameters: + pitches: Original pitch sequence. All variations are derived from this. + depth: Branching levels. ``2 ** depth`` unique variations are + available before the path wraps. + path: Which variation to play (0-based). ``path=p.cycle`` advances + automatically. Values wrap modulo ``2 ** depth``. + mutation: Probability that any step is replaced by a random input + pitch after branching (0.0 = none, 1.0 = fully random). + velocity: MIDI velocity. An ``(low, high)`` tuple randomises per step. + duration: Note duration in beats. + spacing: Beat interval between steps. + seed: Fix the mutation substitutions and velocity draws for this + call (an int) — the variation tree itself is deterministic; + omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # Cycle through 8 variations (depth=3) of a 4-note motif + p.branch([60, 64, 67, 72], depth=3, path=p.cycle, + velocity=85, spacing=0.5) + p.snap_to_scale("C", "minor") + ``` + """ + + rng = self._rng_from(seed, rng) + + if not pitches: + raise ValueError("pitches list cannot be empty") + + resolved_opt = [ + self._resolve_pitch_lenient(p) if isinstance(p, str) else p for p in pitches + ] + resolved = [r for r in resolved_opt if r is not None] + if not resolved: + # Every seed name was a voice this device lacks (each warned once). + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + # The variation tree itself lives in sequence_utils.branch_sequence — + # a reusable pure kernel (feed its output to Motif.notes() for a + # storable variation). This verb resolves drum names, derives the + # variation, and places it on the grid. + sequence = subsequence.sequence_utils.branch_sequence( + resolved, + depth=depth, + path=path, + mutation=mutation, + rng=rng, + ) + + # Place notes. + for i, pitch in enumerate(sequence): + vel = self._resolve_velocity(velocity, rng) + self.note(pitch=pitch, beat=i * spacing, velocity=vel, duration=duration) + + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) diff --git a/subsequence/pattern_builder.py b/subsequence/pattern_builder.py index ce55546..fcd6e34 100644 --- a/subsequence/pattern_builder.py +++ b/subsequence/pattern_builder.py @@ -32,2558 +32,2872 @@ logger = logging.getLogger(__name__) -def _expand_sequence_param (name: str, value: typing.Any, n: int) -> list: +def _expand_sequence_param(name: str, value: typing.Any, n: int) -> list: + """Expand a scalar to a list of length n, or adjust a list to length n. - """Expand a scalar to a list of length n, or adjust a list to length n. + Parameters: + name: The name of the parameter being expanded (used for logging). + value: A scalar (e.g., int, float, str) or an iterable to expand. + n: The target length for the returned list. - Parameters: - name: The name of the parameter being expanded (used for logging). - value: A scalar (e.g., int, float, str) or an iterable to expand. - n: The target length for the returned list. + Returns: + A list of length ``n``. If ``value`` is a scalar, returns ``[value] * n``. + If ``value`` is a list longer than ``n``, truncates it and logs a warning. + If ``value`` is a list shorter than ``n``, repeats the last value and logs a warning. + """ - Returns: - A list of length ``n``. If ``value`` is a scalar, returns ``[value] * n``. - If ``value`` is a list longer than ``n``, truncates it and logs a warning. - If ``value`` is a list shorter than ``n``, repeats the last value and logs a warning. - """ + if isinstance(value, (int, float, str)): + return [value] * n - if isinstance(value, (int, float, str)): - return [value] * n + result = list(value) - result = list(value) + if len(result) == 0: + raise ValueError(f"sequence(): {name} list cannot be empty") - if len(result) == 0: - raise ValueError(f"sequence(): {name} list cannot be empty") + if len(result) > n: + logger.warning( + "sequence(): %s has %d values but only %d steps - truncating", + name, + len(result), + n, + ) + return result[:n] - if len(result) > n: - logger.warning("sequence(): %s has %d values but only %d steps - truncating", name, len(result), n) - return result[:n] + if len(result) < n: + logger.warning( + "sequence(): %s has %d values but %d steps - repeating last value", + name, + len(result), + n, + ) + return result + [result[-1]] * (n - len(result)) - if len(result) < n: - logger.warning("sequence(): %s has %d values but %d steps - repeating last value", name, len(result), n) - return result + [result[-1]] * (n - len(result)) - - return result + return result class BarCycle: + """Position of the current bar within a repeating cycle of bars. - """Position of the current bar within a repeating cycle of bars. - - Returned by :meth:`PatternBuilder.bar_cycle`. Provides readable, musician-friendly - properties for bar-position logic without raw modulo arithmetic. + Returned by :meth:`PatternBuilder.bar_cycle`. Provides readable, musician-friendly + properties for bar-position logic without raw modulo arithmetic. - Attributes: - bar: Zero-indexed bar within the cycle (0 … length−1). - length: The cycle length in bars passed to :meth:`PatternBuilder.bar_cycle`. - """ + Attributes: + bar: Zero-indexed bar within the cycle (0 … length−1). + length: The cycle length in bars passed to :meth:`PatternBuilder.bar_cycle`. + """ - __slots__ = ("bar", "length") + __slots__ = ("bar", "length") - def __init__ (self, bar: int, length: int) -> None: - self.bar = bar - self.length = length + def __init__(self, bar: int, length: int) -> None: + self.bar = bar + self.length = length - @property - def first (self) -> bool: - """True on the first bar of the cycle (``bar == 0``).""" - return self.bar == 0 + @property + def first(self) -> bool: + """True on the first bar of the cycle (``bar == 0``).""" + return self.bar == 0 - @property - def last (self) -> bool: - """True on the last bar of the cycle (``bar == length − 1``).""" - return self.bar == self.length - 1 + @property + def last(self) -> bool: + """True on the last bar of the cycle (``bar == length − 1``).""" + return self.bar == self.length - 1 - @property - def progress (self) -> float: - """Fractional progress through the cycle: 0.0 on bar 0, rising each bar. + @property + def progress(self) -> float: + """Fractional progress through the cycle: 0.0 on bar 0, rising each bar. - For a 4-bar cycle: 0.0, 0.25, 0.5, 0.75. - Useful for gradual intensity curves or as a noise/LFO seed. - """ - return self.bar / self.length + For a 4-bar cycle: 0.0, 0.25, 0.5, 0.75. + Useful for gradual intensity curves or as a noise/LFO seed. + """ + return self.bar / self.length class PatternBuilder( - subsequence.pattern_algorithmic.PatternAlgorithmicMixin, - subsequence.pattern_midi.PatternMidiMixin, + subsequence.pattern_algorithmic.PatternAlgorithmicMixin, + subsequence.pattern_midi.PatternMidiMixin, ): + """ + The musician's 'palette' for creating musical content. + + A ``PatternBuilder`` instance (commonly named ``p``) is passed to every + pattern function. It provides methods for placing notes, generating rhythms, + and transforming the resulting sequence (e.g., swinging, reversing, or transposing). + + Rhythm in Subsequence is typically expressed in **beats** (where 1.0 is a + quarter note) or **steps** (subdivisions of a pattern). + """ + + def __init__( + self, + pattern: subsequence.pattern.Pattern, + cycle: int, + conductor: typing.Optional[subsequence.conductor.Conductor] = None, + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + cc_name_map: typing.Optional[typing.Dict[str, int]] = None, + nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, + section: typing.Any = None, + bar: int = 0, + rng: typing.Optional[random.Random] = None, + tweaks: typing.Optional[typing.Dict[str, typing.Any]] = None, + default_grid: int = 16, + data: typing.Optional[typing.Dict[str, typing.Any]] = None, + key: typing.Optional[str] = None, + scale: typing.Optional[str] = None, + time_signature: typing.Tuple[int, int] = (4, 4), + held_notes: typing.Optional[subsequence.held_notes.HeldNotes] = None, + harmony: typing.Optional[typing.Any] = None, + section_motifs: typing.Optional[ + typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] + ] = None, + energy: float = 0.5, + ) -> None: + """Initialize the builder with pattern context, cycle count, and optional section info. + + Parameters: + pattern: The ``Pattern`` instance this builder populates. + cycle: Zero-based rebuild counter. + conductor: Optional ``Conductor`` for time-varying signals. + drum_note_map: Optional mapping of drum names to MIDI notes. + cc_name_map: Optional mapping of CC names to MIDI CC numbers. + nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit + parameter numbers (0–16383). Used by ``p.nrpn()`` and + ``p.nrpn_ramp()`` for symbolic access — typically a + device-specific dictionary (e.g. Sequential Take 5's + ``Osc1FreqFine`` → 9). + section: Current ``SectionInfo`` (or ``None``). + bar: Global bar count. + rng: Optional seeded ``Random`` for reproducibility. + tweaks: Per-pattern overrides set via ``composition.tweak()``. + default_grid: Number of grid slots used by ``hit_steps()``, + ``sequence()``, and ``rotate()`` when no explicit ``grid`` + is passed. Normally set automatically from the decorator's + ``beats``/``bars``/``steps`` and ``step_duration`` parameters. + data: Shared state dict from the parent ``Composition`` + (same object as ``composition.data``). Read and write + via ``p.data`` for cross-pattern communication and + external data access. Patterns rebuild in definition + order; when two patterns share the same ``length``, + a writer defined earlier in source is guaranteed to + run before a reader defined later in the same cycle. + key: The composition's key (e.g. ``"C"``), used by ``p.progression()`` + to generate chords from a graph style and by ``p.motif()`` to + resolve scale degrees. ``None`` when the composition has no + key set. + scale: The composition's scale/mode name (e.g. ``"minor"``), + read via ``p.scale`` and used to resolve scale degrees in + ``p.motif()``. ``None`` means ionian/major. + time_signature: The composition's time signature, read via + ``p.time_signature``; powers the metric-weight table. + section_motifs: Optional reference to the composition's + section-motif registry, read by ``p.section_motif()``. + harmony: Optional read-only harmony window view for this cycle + (``p.harmony``) — ``p.harmony.chord``, ``chord_at(beat)``, + ``next_chord``, ``until_change``. ``None`` until the + harmonic clock has published a window. + held_notes: Optional live held-note tracker from ``composition.note_input()``. + Read via ``p.held_notes()``. ``None`` when no note input was declared + (and when rendering headlessly), so the accessor returns an empty list. + energy: The current section's energy level (0.0–1.0), read via + ``p.energy`` — the arranging dial. 0.5 when no energy source + is configured. + """ + + self._pattern = pattern + self.cycle = cycle + self.conductor = conductor + self._drum_note_map = drum_note_map + self._cc_name_map = cc_name_map + self._nrpn_name_map = nrpn_name_map + self.section = section + self.bar = bar + self.rng: random.Random = rng or random.Random() + self._tweaks: typing.Dict[str, typing.Any] = tweaks or {} + self._default_grid: int = default_grid + self.data: typing.Dict[str, typing.Any] = data if data is not None else {} + self.key: typing.Optional[str] = ( + key # composition key, for p.progression() chord generation + ) + self.scale: typing.Optional[str] = ( + scale # composition scale/mode, for degree resolution + ) + self.time_signature: typing.Tuple[int, int] = time_signature + self.harmony: typing.Optional[typing.Any] = ( + harmony # HarmonyView for this cycle, or None + ) + self.energy: float = energy # current section's energy (the arranging dial) + self._section_motifs: typing.Optional[ + typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] + ] = section_motifs + self._held_notes: typing.Optional[subsequence.held_notes.HeldNotes] = held_notes + self._tuning_applied: bool = ( + False # set by apply_tuning() to prevent double-apply + ) + + @property + def grid(self) -> int: + """Number of grid slots in this pattern (e.g. 16 for a 4-beat sixteenth-note pattern).""" + return self._default_grid + + def _has_pitch_at_beat(self, pitch: typing.Union[int, str], beat: float) -> bool: + """Helper to check if a pitch is already sounding at a specific beat. + + Tolerant of unmappable drum names: a name absent from this pattern's + ``drum_note_map`` can't already be sounding here, so it returns False + (the placement itself handles the drop/warn) rather than raising.""" + if isinstance(pitch, str): + if self._drum_note_map is None or pitch not in self._drum_note_map: + return False + midi_pitch = self._drum_note_map[pitch] + else: + midi_pitch = pitch + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + if pulse in self._pattern.steps: + return any(n.pitch == midi_pitch for n in self._pattern.steps[pulse].notes) + return False + + @property + def c(self) -> typing.Optional[subsequence.conductor.Conductor]: + """Alias for self.conductor.""" + + return self.conductor + + def signal(self, name: str) -> float: + """Read a conductor signal at the current bar. + + Shorthand for ``p.c.get(name, p.bar * beats_per_bar)``, where + ``beats_per_bar`` comes from the composition's time signature — + so the signal is read at the beat this bar actually starts on, + in any metre. Returns 0.0 if no conductor is attached or the + signal is not defined. + """ + + if self.conductor is None: + return 0.0 + + return self.conductor.get(name, self.bar * float(self.time_signature[0])) + + def held_notes(self) -> typing.List[int]: + """Return the MIDI notes currently held on the ``note_input`` keyboard. + + The notes are sorted ascending. Pass the result straight to + ``p.arpeggio()`` to arpeggiate whatever the player is holding — + ``p.arpeggio(p.held_notes())`` rests when no keys are down. Returns + an empty list when no ``note_input()`` source was declared and when + rendering headlessly (so seeded output stays deterministic). + + The set is sampled once per rebuild; ``note_input(release_ms=…)`` + smooths the gap during hand-position changes so the arp does not drop + out, and ``note_input(latch=True)`` holds the chord until you play a + new one. + """ + + if self._held_notes is None: + return [] + + return self._held_notes.snapshot(time.perf_counter()) + + def param(self, name: str, default: typing.Any = None) -> typing.Any: + """Read a tweakable parameter for this pattern. + + Returns the value set via ``composition.tweak()`` if one + exists, otherwise returns ``default``. + + Parameters: + name: The parameter name. + default: The value to return if no tweak is active. + + Example:: + + @composition.pattern(channel=1, beats=4) + def bass (p): + pitches = p.param("pitches", [60, 64, 67, 72]) + p.sequence(steps=[0, 4, 8, 12], pitches=pitches) + """ + + return self._tweaks.get(name, default) + + def set_length(self, length: float) -> "PatternBuilder": + """ + Dynamically change the length of the pattern. + + The new length takes effect immediately for any subsequent notes + placed in the current builder call, and will be used by the + sequencer for next cycle's scheduling. + + Parameters: + length: New pattern length in beats (e.g., 4.0 for a bar). + + Returns ``self`` for fluent chaining. + """ + + if length <= 0: + raise ValueError("Pattern length must be positive") + + self._pattern.length = length + return self + + def _resolve_pitch(self, pitch: typing.Union[int, str]) -> int: + """ + Resolve a pitch value to a MIDI note number (strict). + + Raises on an unknown drum name — the strict counterpart of + :meth:`_resolve_pitch_lenient`. Note-placement and transform methods use + the lenient variant, so a device may legitimately lack a voice others + have; this strict primitive is retained for parity with the sibling + ``_resolve_cc`` / ``_resolve_nrpn`` / ``_resolve_rpn`` name resolvers, + where an unknown name is always a configuration error. + """ + + if isinstance(pitch, int): + return pitch + + if self._drum_note_map is None: + raise ValueError( + f"String pitch '{pitch}' requires a drum_note_map, but none was provided" + ) + + if pitch not in self._drum_note_map: + raise ValueError( + f"Unknown drum name '{pitch}' - not found in drum_note_map" + ) + + return self._drum_note_map[pitch] + + def _resolve_hit_pitch( + self, pitch: typing.Union[int, str] + ) -> typing.Optional[typing.Tuple[int, typing.Optional[str], bool]]: + """Resolve a step-note pitch for placement, leniently for named drums. + + Returns ``(midi_pitch, origin, primary_unmapped)``, or ``None`` to drop + the hit entirely. + + Unlike :meth:`_resolve_pitch`, an unknown drum *name* does not raise: + faithful-core device maps legitimately lack voices other devices have, + so a name a device can't voice is dropped rather than crashing the + pattern. The cases: + + - Integer pitch → ``(pitch, None, False)``. + - String in this pattern's ``drum_note_map`` → ``(note, name, False)``. + - String absent here but present in a mirror's map → + ``(placeholder, name, True)``: the primary can't voice it, but a + symbolic mirror can (the placeholder pitch is used only by transforms + and display, never for playback — see ``Note.primary_unmapped``). + - String absent everywhere → warn once and return ``None`` (drop). + - String with **no** ``drum_note_map`` at all → still a configuration + error; raises (you forgot the map, this is not a capability gap). + """ + + if isinstance(pitch, int): + return (pitch, None, False) + + if self._drum_note_map is None: + raise ValueError( + f"String pitch '{pitch}' requires a drum_note_map, but none was provided" + ) + + if pitch in self._drum_note_map: + return (self._drum_note_map[pitch], pitch, False) + + mirror_pitch = self._first_mirror_pitch(pitch) + if mirror_pitch is not None: + return (mirror_pitch, pitch, True) + + self._warn_unknown_drum(pitch) + return None + + def _first_mirror_pitch(self, name: str) -> typing.Optional[int]: + """Return the first mirror ``drum_note_map`` value for *name*, or None. + + Lets a named hit absent from the primary map still be placed (as a + ``primary_unmapped`` Note) when a symbolic (3-tuple) mirror can voice it. + """ + + for entry in getattr(self._pattern, "mirrors", []): + if len(entry) == 3 and entry[2] is not None and name in entry[2]: + return typing.cast(int, entry[2][name]) + return None + + def _warn_unknown_drum(self, name: str, include_mirrors: bool = True) -> None: + """Warn once (per pattern, per name) that a drum name maps to nothing. + + Deduplicated via the Pattern's ``_warned_drum_names`` set so the per-bar + rebuild does not spam; a hot-reload builds a fresh Pattern and + re-surfaces the warning. + + ``include_mirrors`` tailors the wording: step-note placement checks the + mirror maps too (the name maps to *no* device), whereas the methods that + resolve against the primary map only — drones, ``arpeggio``, ``evolve``, + ``branch``, and the ``thin``/``ratchet`` pitch filter — report just this + device. + """ + + warned = getattr(self._pattern, "_warned_drum_names", None) + if warned is not None: + if name in warned: + return + warned.add(name) + + fn = getattr(self._pattern, "_builder_fn", None) + label = getattr(fn, "__name__", None) + where = ( + f"pattern '{label}'" + if label + else f"device {self._pattern.device} channel {self._pattern.channel}" + ) + if include_mirrors: + scope = f"the drum_note_map for {where} or any of its mirror destinations" + reason = "no device maps this voice" + else: + scope = f"the drum_note_map for {where}" + reason = "this device has no such voice" + logger.warning( + f"Drum name '{name}' is not in {scope} — the note is dropped ({reason}). Check the spelling, or add it to a map." + ) + + def _resolve_pitch_lenient( + self, pitch: typing.Union[int, str] + ) -> typing.Optional[int]: + """Resolve a pitch against this pattern's own ``drum_note_map``, leniently. + + Like :meth:`_resolve_pitch`, but an unknown drum *name* (a map is present + yet lacks the voice) is **dropped** — warned once, returns ``None`` — + instead of raising, so a device may legitimately lack a voice that other + devices have. Used by the methods that do NOT carry the drum name to + mirror destinations (``note_on``/``note_off``/``drone``, ``arpeggio``, + ``evolve``, ``branch``, and the ``thin``/``ratchet`` pitch filter), so + resolution is against the primary map only. A string with **no** + ``drum_note_map`` at all is still a configuration error and raises. + """ + + if isinstance(pitch, int): + return pitch + + if self._drum_note_map is None: + raise ValueError( + f"String pitch '{pitch}' requires a drum_note_map, but none was provided" + ) + + if pitch in self._drum_note_map: + return self._drum_note_map[pitch] + + self._warn_unknown_drum(pitch, include_mirrors=False) + return None + + def _resolve_cc(self, control: typing.Union[int, str]) -> int: + """Resolve a CC name or number to a MIDI CC number.""" + + if isinstance(control, int): + return control + + if self._cc_name_map is None: + raise ValueError( + f"String CC name '{control}' requires a cc_name_map, but none was provided" + ) + + if control not in self._cc_name_map: + raise ValueError(f"Unknown CC name '{control}' - not found in cc_name_map") + + return self._cc_name_map[control] + + def _resolve_nrpn(self, parameter: typing.Union[int, str]) -> int: + """Resolve an NRPN parameter name or number to a 14-bit parameter number. + + Strings require an ``nrpn_name_map`` on the pattern decorator — + NRPN parameter numbers are vendor-specific, so subsequence does not + ship a default mapping. Integer parameters must be in the 14-bit + range 0–16383. + """ + + if isinstance(parameter, int): + if not 0 <= parameter <= 16383: + raise ValueError( + f"NRPN parameter number must be 0–16383, got {parameter}" + ) + return parameter + + if self._nrpn_name_map is None: + raise ValueError( + f"String NRPN name '{parameter}' requires an nrpn_name_map, but none was provided" + ) + + if parameter not in self._nrpn_name_map: + raise ValueError( + f"Unknown NRPN name '{parameter}' - not found in nrpn_name_map" + ) + + return self._nrpn_name_map[parameter] + + def _resolve_rpn(self, parameter: typing.Union[int, str]) -> int: + """Resolve an RPN parameter name or number to a 14-bit parameter number. + + Strings fall back to ``pymididefs.rpn.RPN_MAP`` — the standardised + set of MIDI Registered Parameter Numbers (``pitch_bend_sensitivity``, + ``channel_fine_tuning``, ...). No per-pattern map needed. Integer + parameters must be in the 14-bit range 0–16383. + """ + + if isinstance(parameter, int): + if not 0 <= parameter <= 16383: + raise ValueError( + f"RPN parameter number must be 0–16383, got {parameter}" + ) + return parameter + + if parameter not in pymididefs.rpn.RPN_MAP: + raise ValueError( + f"Unknown RPN name '{parameter}' - not a standard Registered Parameter Number" + ) + + return pymididefs.rpn.RPN_MAP[parameter] + + def note( + self, + pitch: typing.Union[int, str], + beat: float, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.25, + ) -> "PatternBuilder": + """ + Place a single MIDI note at a specific beat position. + + A drum name is carried through to the mirror fan-out so each device can + re-resolve it through its own ``drum_note_map``. A name no destination + maps (not in the pattern's own map nor any mirror's) is dropped and + warned once — it does not raise — so device maps can legitimately lack + voices others have. (A string pitch with **no** ``drum_note_map`` at all + is still a configuration error and raises.) + + Parameters: + pitch: MIDI note number (0-127) or a drum name string from + the pattern's ``drum_note_map``. + beat: The beat position (0.0 is the start). Negative values + wrap from the end (e.g., -1.0 is one beat before the end). + velocity: MIDI velocity (0-127, default 100), or a + ``(low, high)`` tuple for a single random draw. + duration: Note duration in beats (default 0.25). + + Example: + ```python + p.note(60, beat=0, velocity=110) # Middle C on beat 1 + p.note("kick", beat=1.0) # Kick on beat 2 + p.note(67, beat=-0.5, duration=0.5) # G on the 'and' of the last beat + ``` + """ + + # Resolve leniently: a named drum the target can't voice is dropped (and + # warned once) rather than raising, and the drum name is carried so each + # destination can re-resolve it through its own drum_note_map. + resolution = self._resolve_hit_pitch(pitch) + if resolution is None: + return self # unknown drum name, mapped by no destination — dropped + midi_pitch, origin, primary_unmapped = resolution + + resolved_velocity = self._resolve_velocity(velocity) + + # Negative beat values wrap to the end of the pattern. + if beat < 0: + beat = beat % self._pattern.length # wrap from the end (any magnitude) + + self._pattern.add_note_beats( + beat_position=beat, + pitch=midi_pitch, + velocity=resolved_velocity, + duration_beats=duration, + origin=origin, + primary_unmapped=primary_unmapped, + ) + return self + + def note_on( + self, + pitch: typing.Union[int, str], + beat: float, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + ) -> "PatternBuilder": + """ + Place an explicit Note On event without a duration. + Useful for drones or infinite sustains. Must be paired with + a ``note_off()`` later to silence the note. + + Parameters: + pitch: MIDI note number (0-127) or a drum name string. + beat: The beat position (0.0 is the start). + velocity: MIDI velocity (0-127, default 100), or a + ``(low, high)`` tuple for a single random draw. + + A drum name this device's ``drum_note_map`` lacks is dropped (warned + once) rather than raising — consistent with the step-note methods. A + string pitch with no ``drum_note_map`` at all is still a configuration + error and raises. + """ + + midi_pitch = self._resolve_pitch_lenient(pitch) + if midi_pitch is None: + return self # drum name this device can't voice — dropped (warned once) + resolved_velocity = self._resolve_velocity(velocity) + if beat < 0: + beat = beat % self._pattern.length # wrap from the end (any magnitude) + + self._pattern.add_raw_note_beats( + message_type="note_on", + beat_position=beat, + pitch=midi_pitch, + velocity=resolved_velocity, + origin=pitch if isinstance(pitch, str) else None, + ) + return self + + def note_off(self, pitch: typing.Union[int, str], beat: float) -> "PatternBuilder": + """ + Place an explicit Note Off event to silence a drone. + + Parameters: + pitch: MIDI note number (0-127) or a drum name string. + beat: The beat position (0.0 is the start). + + A drum name this device's ``drum_note_map`` lacks is dropped (warned + once) rather than raising; with no ``drum_note_map`` at all it raises. + """ + + midi_pitch = self._resolve_pitch_lenient(pitch) + if midi_pitch is None: + return self # nothing to silence — this device can't voice the name + if beat < 0: + beat = beat % self._pattern.length # wrap from the end (any magnitude) + + self._pattern.add_raw_note_beats( + message_type="note_off", + beat_position=beat, + pitch=midi_pitch, + velocity=0, + origin=pitch if isinstance(pitch, str) else None, + ) + return self + + def drone( + self, + pitch: typing.Union[int, str], + beat: float = 0.0, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + ) -> "PatternBuilder": + """ + A musical alias for ``note_on``. Places a raw Note On event without a duration, + typically used for sustained notes that span multiple cycles. + Must be silenced later using ``drone_off()``. + + Parameters: + pitch: MIDI note number (0-127) or a drum name string. + beat: The beat position (0.0 is the start). + velocity: MIDI velocity (0-127, default 100), or a + ``(low, high)`` tuple for a single random draw. + """ + + self.note_on(pitch, beat=beat, velocity=velocity) + return self + + def drone_off(self, pitch: typing.Union[int, str]) -> "PatternBuilder": + """ + A musical alias for ``note_off``. Places a raw Note Off event at beat 0.0. + Used to stop a sequence started by ``drone()``. + + Parameters: + pitch: MIDI note number (0-127) or a drum name string. + """ + + self.note_off(pitch, beat=0.0) + return self + + def silence(self, beat: float = 0.0) -> "PatternBuilder": + """ + Sends an 'All Notes Off' (CC 123) and 'All Sound Off' (CC 120) message + on the pattern's channel to immediately silence any ringing notes or drones. + + Parameters: + beat: The beat position (0.0 is the start). + """ + + self.cc(control=123, value=0, beat=beat) + self.cc(control=120, value=0, beat=beat) + return self + + def hit( + self, + pitch: typing.Union[int, str], + beats: typing.List[float], + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.1, + ) -> "PatternBuilder": + """ + Place multiple short 'hits' at a list of beat positions. + + Parameters: + pitch: MIDI note number or drum name. + beats: List of beat positions. + velocity: MIDI velocity (0-127), or a ``(low, high)`` tuple + for a fresh random draw per hit. + duration: Note duration in beats. + + Example: + ```python + p.hit("snare", [1, 3]) # Standard backbeat + p.hit("snare", [1, 3], velocity=(80, 110)) # Human velocity range + ``` + """ + + for beat in beats: + self.note(pitch=pitch, beat=beat, velocity=velocity, duration=duration) + return self + + def hit_steps( + self, + pitch: typing.Union[int, str], + steps: typing.List[int], + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.1, + grid: typing.Optional[int] = None, + probability: float = 1.0, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "PatternBuilder": + """ + Place short hits at specific step (grid) positions. + + Parameters: + pitch: MIDI note number or drum name. + steps: A list of grid indices (0 to ``grid - 1``). + velocity: MIDI velocity (0-127), or a ``(low, high)`` tuple + for a fresh random draw per step. + duration: Note duration in beats. + grid: How many grid slots the pattern is divided into. + Defaults to the pattern's ``default_grid`` (set from the + decorator's ``steps``/``step_duration``, or sixteenth-note + resolution when ``unit`` is omitted). + probability: Chance (0.0 to 1.0) that each hit will play. + seed: Fix the probability gating for this call (an int); omit to + use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # Typical sixteenth-note hi-hats with some probability variation + p.hit_steps("hh", range(16), velocity=70, probability=0.8) + + # Humanised hi-hats — each step gets a fresh random velocity. + p.hit_steps("hh", range(16), velocity=(40, 90)) + ``` + """ + + rng = self._rng_from(seed, rng) + + if grid is None: + grid = self._default_grid + + if grid <= 0: + return self + + step_duration = self._pattern.length / grid + + for i in steps: + if probability < 1.0 and rng.random() >= probability: + continue + + beat = i * step_duration + self.note(pitch=pitch, beat=beat, velocity=velocity, duration=duration) + return self + + def motif( + self, + m: "subsequence.motifs.Motif", + beat: float = 0.0, + span: typing.Optional[float] = None, + root: int = 60, + velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, + fit: typing.Optional[float] = None, + fit_weights: typing.Optional[typing.List[float]] = None, + resolution: typing.Optional[int] = None, + ) -> "PatternBuilder": + """ + Place an immutable :class:`~subsequence.motifs.Motif` onto the pattern. + + Note events route through the universal ``note()`` funnel (drum names, + mirrors, velocity tuples all work); control gestures emit through the + same machinery as ``cc()`` / ``cc_ramp()`` / ``pitch_bend()`` / + ``nrpn()`` / ``osc()``. Pitch specs resolve here, late: ints are MIDI, + strings are drum names, scale degrees resolve against the composition + key + scale anchored near ``root=``. Per-event probabilities roll + fresh each cycle against the pattern's seeded stream. + + Parameters: + m: The motif value (anything exposing ``.events`` / ``.length`` + places; ``.controls`` is read when present). + beat: Where the motif starts within the pattern. + span: Clamp — events whose onset falls at or beyond *span* beats + into the motif are dropped (the ``arpeggio()`` convention). + root: Register anchor for scale-degree resolution: the tonic + lands at its nearest instance to this MIDI note (ties resolve + upward) and the melody keeps its written contour from there. + velocity: Optional override applied to every note (otherwise each + event's own velocity is used). + fit: The chord-tones-on-strong-beats dial, 0.0–1.0: resolved + Degree/int pitches landing on strong beats (metric weight + >= 0.5) snap to the nearest chord tone with this + probability. Defaults to the motif's own ``fit`` (0.7 on + generated motifs, none on hand-written ones — typed degrees + are sacred); inactive without a chord context. ChordTone + and Approach events never snap — their harmony reading is + inherent (an Approach's chromaticism is the point). + fit_weights: Custom per-step metric weight list (the + ``build_ghost_bias`` precedent) for additive or + non-isochronous meters; defaults to the time signature's + table. + resolution: Pulses between control-ramp messages (defaults to + each control verb's own default). Kept out of the value by + design: beats and shapes are music, traffic density is wire. + """ + + events = getattr(m, "events", None) + + if events is None or not hasattr(m, "length"): + raise TypeError( + f"motif() places Motif-like values (.events/.length) — got {type(m).__name__}" + ) + + effective_fit = fit if fit is not None else getattr(m, "fit", None) + fit_table: typing.Optional[typing.List[float]] = None + snap_probability = 0.0 + + if effective_fit: + snap_probability = float(effective_fit) + fit_table = ( + list(fit_weights) + if fit_weights is not None + else subsequence.sequence_utils.build_metric_weights( + self.time_signature, grid=self._default_grid + ) + ) + + for event in events: + if span is not None and event.beat >= span: + continue + if event.probability < 1.0 and self.rng.random() >= event.probability: + continue + + resolved = self._resolve_motif_pitch(event.pitch, root, beat + event.beat) + + # The fit dial reads only Degree/int content: drums have no + # pitch to snap, ChordTones already are chord tones, and an + # Approach's chromaticism is the point. + if ( + fit_table is not None + and isinstance(resolved, int) + and isinstance(event.pitch, (int, subsequence.motifs.Degree)) + ): + resolved = self._fit_snap( + resolved, beat + event.beat, snap_probability, fit_table + ) + + self.note( + pitch=resolved, + beat=beat + event.beat, + velocity=velocity if velocity is not None else event.velocity, + duration=event.duration, + ) + + for control in getattr(m, "controls", ()): + if span is not None and control.beat >= span: + continue + if control.probability < 1.0 and self.rng.random() >= control.probability: + continue + + self._emit_control(control, beat, resolution) + + return self + + def _resolve_motif_pitch( + self, pitch: typing.Any, root: int, event_beat: float = 0.0 + ) -> typing.Union[int, str]: + """Resolve one stored pitch spec to a MIDI int or drum name, late. + + ``event_beat`` is the event's position within this cycle — chord- + relative specs resolve against the chord sounding *under the event* + (``p.harmony.chord_at``), not the cycle-start snapshot. + """ + + if pitch is None: + raise ValueError( + "This motif is a rhythm skeleton (pitches stripped) — " + "re-pitch it with .pitched() before placing" + ) + + if isinstance(pitch, (int, str)): + return pitch + + if isinstance(pitch, subsequence.motifs.Degree): + return self._resolve_degree_pitch(pitch, root) + + if isinstance(pitch, subsequence.motifs.ChordTone): + return self._resolve_chord_tone_pitch(pitch, root, event_beat) + + if isinstance(pitch, subsequence.motifs.Approach): + return self._resolve_approach_pitch(pitch, root, event_beat) + + raise TypeError(f"Unknown pitch spec: {type(pitch).__name__}") + + def _resolve_approach_pitch( + self, approach: "subsequence.motifs.Approach", root: int, event_beat: float + ) -> int: + """Resolve an Approach: one semitone below its target's pitch. + + A ``ChordTone`` target reads the chord at the NEXT boundary after the + event (the harmony window's anticipation data) — the approach is the + tension, the target is where the harmony lands. When the window + holds no committed next chord (the live mode horizon's edge), the + sounding chord stands in. ``Degree``/``int`` targets resolve as + usual (no harmony needed). + """ + + target = approach.target + + if isinstance(target, subsequence.motifs.ChordTone): + if self.harmony is None: + raise ValueError( + "an Approach at a chord tone needs the harmonic clock — " + "call composition.harmony(...) (a style or a bound progression)" + ) + + chord = self.harmony.next_chord_at(event_beat) + + if chord is None: + chord = self.harmony.chord_at(event_beat) + if chord is None: + raise ValueError( + f"No chord is known around beat {event_beat:g} of this cycle — " + "the harmony window does not cover it" + ) + + tones = chord.tones(root, count=target.index) + resolved = int(tones[target.index - 1]) + 12 * target.octave + + elif isinstance(target, subsequence.motifs.Degree): + resolved = self._resolve_degree_pitch(target, root) + + elif isinstance(target, int): + resolved = target + + else: + raise TypeError(f"cannot approach {type(target).__name__} content") + + pitch = resolved - 1 + + if not 0 <= pitch <= 127: + raise ValueError( + f"Approach resolves to MIDI {pitch}, outside 0–127 — adjust root= or the target's octave" + ) + + return pitch - """ - The musician's 'palette' for creating musical content. - - A ``PatternBuilder`` instance (commonly named ``p``) is passed to every - pattern function. It provides methods for placing notes, generating rhythms, - and transforming the resulting sequence (e.g., swinging, reversing, or transposing). - - Rhythm in Subsequence is typically expressed in **beats** (where 1.0 is a - quarter note) or **steps** (subdivisions of a pattern). - """ - - def __init__ (self, pattern: subsequence.pattern.Pattern, cycle: int, conductor: typing.Optional[subsequence.conductor.Conductor] = None, drum_note_map: typing.Optional[typing.Dict[str, int]] = None, cc_name_map: typing.Optional[typing.Dict[str, int]] = None, nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, section: typing.Any = None, bar: int = 0, rng: typing.Optional[random.Random] = None, tweaks: typing.Optional[typing.Dict[str, typing.Any]] = None, default_grid: int = 16, data: typing.Optional[typing.Dict[str, typing.Any]] = None, key: typing.Optional[str] = None, scale: typing.Optional[str] = None, time_signature: typing.Tuple[int, int] = (4, 4), held_notes: typing.Optional[subsequence.held_notes.HeldNotes] = None, harmony: typing.Optional[typing.Any] = None, section_motifs: typing.Optional[typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any]] = None, energy: float = 0.5) -> None: - - """Initialize the builder with pattern context, cycle count, and optional section info. - - Parameters: - pattern: The ``Pattern`` instance this builder populates. - cycle: Zero-based rebuild counter. - conductor: Optional ``Conductor`` for time-varying signals. - drum_note_map: Optional mapping of drum names to MIDI notes. - cc_name_map: Optional mapping of CC names to MIDI CC numbers. - nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit - parameter numbers (0–16383). Used by ``p.nrpn()`` and - ``p.nrpn_ramp()`` for symbolic access — typically a - device-specific dictionary (e.g. Sequential Take 5's - ``Osc1FreqFine`` → 9). - section: Current ``SectionInfo`` (or ``None``). - bar: Global bar count. - rng: Optional seeded ``Random`` for reproducibility. - tweaks: Per-pattern overrides set via ``composition.tweak()``. - default_grid: Number of grid slots used by ``hit_steps()``, - ``sequence()``, and ``rotate()`` when no explicit ``grid`` - is passed. Normally set automatically from the decorator's - ``beats``/``bars``/``steps`` and ``step_duration`` parameters. - data: Shared state dict from the parent ``Composition`` - (same object as ``composition.data``). Read and write - via ``p.data`` for cross-pattern communication and - external data access. Patterns rebuild in definition - order; when two patterns share the same ``length``, - a writer defined earlier in source is guaranteed to - run before a reader defined later in the same cycle. - key: The composition's key (e.g. ``"C"``), used by ``p.progression()`` - to generate chords from a graph style and by ``p.motif()`` to - resolve scale degrees. ``None`` when the composition has no - key set. - scale: The composition's scale/mode name (e.g. ``"minor"``), - read via ``p.scale`` and used to resolve scale degrees in - ``p.motif()``. ``None`` means ionian/major. - time_signature: The composition's time signature, read via - ``p.time_signature``; powers the metric-weight table. - section_motifs: Optional reference to the composition's - section-motif registry, read by ``p.section_motif()``. - harmony: Optional read-only harmony window view for this cycle - (``p.harmony``) — ``p.harmony.chord``, ``chord_at(beat)``, - ``next_chord``, ``until_change``. ``None`` until the - harmonic clock has published a window. - held_notes: Optional live held-note tracker from ``composition.note_input()``. - Read via ``p.held_notes()``. ``None`` when no note input was declared - (and when rendering headlessly), so the accessor returns an empty list. - energy: The current section's energy level (0.0–1.0), read via - ``p.energy`` — the arranging dial. 0.5 when no energy source - is configured. - """ - - self._pattern = pattern - self.cycle = cycle - self.conductor = conductor - self._drum_note_map = drum_note_map - self._cc_name_map = cc_name_map - self._nrpn_name_map = nrpn_name_map - self.section = section - self.bar = bar - self.rng: random.Random = rng or random.Random() - self._tweaks: typing.Dict[str, typing.Any] = tweaks or {} - self._default_grid: int = default_grid - self.data: typing.Dict[str, typing.Any] = data if data is not None else {} - self.key: typing.Optional[str] = key # composition key, for p.progression() chord generation - self.scale: typing.Optional[str] = scale # composition scale/mode, for degree resolution - self.time_signature: typing.Tuple[int, int] = time_signature - self.harmony: typing.Optional[typing.Any] = harmony # HarmonyView for this cycle, or None - self.energy: float = energy # current section's energy (the arranging dial) - self._section_motifs: typing.Optional[typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any]] = section_motifs - self._held_notes: typing.Optional[subsequence.held_notes.HeldNotes] = held_notes - self._tuning_applied: bool = False # set by apply_tuning() to prevent double-apply - - @property - def grid (self) -> int: - """Number of grid slots in this pattern (e.g. 16 for a 4-beat sixteenth-note pattern).""" - return self._default_grid - - def _has_pitch_at_beat (self, pitch: typing.Union[int, str], beat: float) -> bool: - """Helper to check if a pitch is already sounding at a specific beat. - - Tolerant of unmappable drum names: a name absent from this pattern's - ``drum_note_map`` can't already be sounding here, so it returns False - (the placement itself handles the drop/warn) rather than raising.""" - if isinstance(pitch, str): - if self._drum_note_map is None or pitch not in self._drum_note_map: - return False - midi_pitch = self._drum_note_map[pitch] - else: - midi_pitch = pitch - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - if pulse in self._pattern.steps: - return any(n.pitch == midi_pitch for n in self._pattern.steps[pulse].notes) - return False - - - @property - def c (self) -> typing.Optional[subsequence.conductor.Conductor]: - - """Alias for self.conductor.""" - - return self.conductor - - def signal (self, name: str) -> float: - - """Read a conductor signal at the current bar. - - Shorthand for ``p.c.get(name, p.bar * beats_per_bar)``, where - ``beats_per_bar`` comes from the composition's time signature — - so the signal is read at the beat this bar actually starts on, - in any metre. Returns 0.0 if no conductor is attached or the - signal is not defined. - """ - - if self.conductor is None: - return 0.0 - - return self.conductor.get(name, self.bar * float(self.time_signature[0])) - - def held_notes (self) -> typing.List[int]: - - """Return the MIDI notes currently held on the ``note_input`` keyboard. - - The notes are sorted ascending. Pass the result straight to - ``p.arpeggio()`` to arpeggiate whatever the player is holding — - ``p.arpeggio(p.held_notes())`` rests when no keys are down. Returns - an empty list when no ``note_input()`` source was declared and when - rendering headlessly (so seeded output stays deterministic). - - The set is sampled once per rebuild; ``note_input(release_ms=…)`` - smooths the gap during hand-position changes so the arp does not drop - out, and ``note_input(latch=True)`` holds the chord until you play a - new one. - """ - - if self._held_notes is None: - return [] - - return self._held_notes.snapshot(time.perf_counter()) - - def param (self, name: str, default: typing.Any = None) -> typing.Any: - - """Read a tweakable parameter for this pattern. - - Returns the value set via ``composition.tweak()`` if one - exists, otherwise returns ``default``. - - Parameters: - name: The parameter name. - default: The value to return if no tweak is active. - - Example:: - - @composition.pattern(channel=1, beats=4) - def bass (p): - pitches = p.param("pitches", [60, 64, 67, 72]) - p.sequence(steps=[0, 4, 8, 12], pitches=pitches) - """ - - return self._tweaks.get(name, default) - - def set_length (self, length: float) -> "PatternBuilder": - - """ - Dynamically change the length of the pattern. - - The new length takes effect immediately for any subsequent notes - placed in the current builder call, and will be used by the - sequencer for next cycle's scheduling. - - Parameters: - length: New pattern length in beats (e.g., 4.0 for a bar). - - Returns ``self`` for fluent chaining. - """ - - if length <= 0: - raise ValueError("Pattern length must be positive") - - self._pattern.length = length - return self - - def _resolve_pitch (self, pitch: typing.Union[int, str]) -> int: - - """ - Resolve a pitch value to a MIDI note number (strict). - - Raises on an unknown drum name — the strict counterpart of - :meth:`_resolve_pitch_lenient`. Note-placement and transform methods use - the lenient variant, so a device may legitimately lack a voice others - have; this strict primitive is retained for parity with the sibling - ``_resolve_cc`` / ``_resolve_nrpn`` / ``_resolve_rpn`` name resolvers, - where an unknown name is always a configuration error. - """ - - if isinstance(pitch, int): - return pitch - - if self._drum_note_map is None: - raise ValueError(f"String pitch '{pitch}' requires a drum_note_map, but none was provided") - - if pitch not in self._drum_note_map: - raise ValueError(f"Unknown drum name '{pitch}' - not found in drum_note_map") - - return self._drum_note_map[pitch] - - def _resolve_hit_pitch (self, pitch: typing.Union[int, str]) -> typing.Optional[typing.Tuple[int, typing.Optional[str], bool]]: - - """Resolve a step-note pitch for placement, leniently for named drums. - - Returns ``(midi_pitch, origin, primary_unmapped)``, or ``None`` to drop - the hit entirely. - - Unlike :meth:`_resolve_pitch`, an unknown drum *name* does not raise: - faithful-core device maps legitimately lack voices other devices have, - so a name a device can't voice is dropped rather than crashing the - pattern. The cases: - - - Integer pitch → ``(pitch, None, False)``. - - String in this pattern's ``drum_note_map`` → ``(note, name, False)``. - - String absent here but present in a mirror's map → - ``(placeholder, name, True)``: the primary can't voice it, but a - symbolic mirror can (the placeholder pitch is used only by transforms - and display, never for playback — see ``Note.primary_unmapped``). - - String absent everywhere → warn once and return ``None`` (drop). - - String with **no** ``drum_note_map`` at all → still a configuration - error; raises (you forgot the map, this is not a capability gap). - """ - - if isinstance(pitch, int): - return (pitch, None, False) - - if self._drum_note_map is None: - raise ValueError(f"String pitch '{pitch}' requires a drum_note_map, but none was provided") - - if pitch in self._drum_note_map: - return (self._drum_note_map[pitch], pitch, False) - - mirror_pitch = self._first_mirror_pitch(pitch) - if mirror_pitch is not None: - return (mirror_pitch, pitch, True) - - self._warn_unknown_drum(pitch) - return None - - def _first_mirror_pitch (self, name: str) -> typing.Optional[int]: - - """Return the first mirror ``drum_note_map`` value for *name*, or None. - - Lets a named hit absent from the primary map still be placed (as a - ``primary_unmapped`` Note) when a symbolic (3-tuple) mirror can voice it. - """ - - for entry in getattr(self._pattern, 'mirrors', []): - if len(entry) == 3 and entry[2] is not None and name in entry[2]: - return typing.cast(int, entry[2][name]) - return None - - def _warn_unknown_drum (self, name: str, include_mirrors: bool = True) -> None: - - """Warn once (per pattern, per name) that a drum name maps to nothing. - - Deduplicated via the Pattern's ``_warned_drum_names`` set so the per-bar - rebuild does not spam; a hot-reload builds a fresh Pattern and - re-surfaces the warning. - - ``include_mirrors`` tailors the wording: step-note placement checks the - mirror maps too (the name maps to *no* device), whereas the methods that - resolve against the primary map only — drones, ``arpeggio``, ``evolve``, - ``branch``, and the ``thin``/``ratchet`` pitch filter — report just this - device. - """ - - warned = getattr(self._pattern, '_warned_drum_names', None) - if warned is not None: - if name in warned: - return - warned.add(name) - - fn = getattr(self._pattern, '_builder_fn', None) - label = getattr(fn, '__name__', None) - where = f"pattern '{label}'" if label else f"device {self._pattern.device} channel {self._pattern.channel}" - if include_mirrors: - scope = f"the drum_note_map for {where} or any of its mirror destinations" - reason = "no device maps this voice" - else: - scope = f"the drum_note_map for {where}" - reason = "this device has no such voice" - logger.warning(f"Drum name '{name}' is not in {scope} — the note is dropped ({reason}). Check the spelling, or add it to a map.") - - def _resolve_pitch_lenient (self, pitch: typing.Union[int, str]) -> typing.Optional[int]: - - """Resolve a pitch against this pattern's own ``drum_note_map``, leniently. - - Like :meth:`_resolve_pitch`, but an unknown drum *name* (a map is present - yet lacks the voice) is **dropped** — warned once, returns ``None`` — - instead of raising, so a device may legitimately lack a voice that other - devices have. Used by the methods that do NOT carry the drum name to - mirror destinations (``note_on``/``note_off``/``drone``, ``arpeggio``, - ``evolve``, ``branch``, and the ``thin``/``ratchet`` pitch filter), so - resolution is against the primary map only. A string with **no** - ``drum_note_map`` at all is still a configuration error and raises. - """ - - if isinstance(pitch, int): - return pitch - - if self._drum_note_map is None: - raise ValueError(f"String pitch '{pitch}' requires a drum_note_map, but none was provided") - - if pitch in self._drum_note_map: - return self._drum_note_map[pitch] - - self._warn_unknown_drum(pitch, include_mirrors=False) - return None - - def _resolve_cc (self, control: typing.Union[int, str]) -> int: - - """Resolve a CC name or number to a MIDI CC number.""" - - if isinstance(control, int): - return control - - if self._cc_name_map is None: - raise ValueError(f"String CC name '{control}' requires a cc_name_map, but none was provided") - - if control not in self._cc_name_map: - raise ValueError(f"Unknown CC name '{control}' - not found in cc_name_map") - - return self._cc_name_map[control] - - def _resolve_nrpn (self, parameter: typing.Union[int, str]) -> int: - - """Resolve an NRPN parameter name or number to a 14-bit parameter number. - - Strings require an ``nrpn_name_map`` on the pattern decorator — - NRPN parameter numbers are vendor-specific, so subsequence does not - ship a default mapping. Integer parameters must be in the 14-bit - range 0–16383. - """ - - if isinstance(parameter, int): - if not 0 <= parameter <= 16383: - raise ValueError(f"NRPN parameter number must be 0–16383, got {parameter}") - return parameter - - if self._nrpn_name_map is None: - raise ValueError(f"String NRPN name '{parameter}' requires an nrpn_name_map, but none was provided") - - if parameter not in self._nrpn_name_map: - raise ValueError(f"Unknown NRPN name '{parameter}' - not found in nrpn_name_map") - - return self._nrpn_name_map[parameter] - - def _resolve_rpn (self, parameter: typing.Union[int, str]) -> int: - - """Resolve an RPN parameter name or number to a 14-bit parameter number. - - Strings fall back to ``pymididefs.rpn.RPN_MAP`` — the standardised - set of MIDI Registered Parameter Numbers (``pitch_bend_sensitivity``, - ``channel_fine_tuning``, ...). No per-pattern map needed. Integer - parameters must be in the 14-bit range 0–16383. - """ - - if isinstance(parameter, int): - if not 0 <= parameter <= 16383: - raise ValueError(f"RPN parameter number must be 0–16383, got {parameter}") - return parameter - - if parameter not in pymididefs.rpn.RPN_MAP: - raise ValueError(f"Unknown RPN name '{parameter}' - not a standard Registered Parameter Number") - - return pymididefs.rpn.RPN_MAP[parameter] - - def note (self, pitch: typing.Union[int, str], beat: float, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, duration: float = 0.25) -> "PatternBuilder": - - """ - Place a single MIDI note at a specific beat position. - - A drum name is carried through to the mirror fan-out so each device can - re-resolve it through its own ``drum_note_map``. A name no destination - maps (not in the pattern's own map nor any mirror's) is dropped and - warned once — it does not raise — so device maps can legitimately lack - voices others have. (A string pitch with **no** ``drum_note_map`` at all - is still a configuration error and raises.) - - Parameters: - pitch: MIDI note number (0-127) or a drum name string from - the pattern's ``drum_note_map``. - beat: The beat position (0.0 is the start). Negative values - wrap from the end (e.g., -1.0 is one beat before the end). - velocity: MIDI velocity (0-127, default 100), or a - ``(low, high)`` tuple for a single random draw. - duration: Note duration in beats (default 0.25). - - Example: - ```python - p.note(60, beat=0, velocity=110) # Middle C on beat 1 - p.note("kick", beat=1.0) # Kick on beat 2 - p.note(67, beat=-0.5, duration=0.5) # G on the 'and' of the last beat - ``` - """ - - # Resolve leniently: a named drum the target can't voice is dropped (and - # warned once) rather than raising, and the drum name is carried so each - # destination can re-resolve it through its own drum_note_map. - resolution = self._resolve_hit_pitch(pitch) - if resolution is None: - return self # unknown drum name, mapped by no destination — dropped - midi_pitch, origin, primary_unmapped = resolution - - resolved_velocity = self._resolve_velocity(velocity) - - # Negative beat values wrap to the end of the pattern. - if beat < 0: - beat = beat % self._pattern.length # wrap from the end (any magnitude) - - self._pattern.add_note_beats( - beat_position = beat, - pitch = midi_pitch, - velocity = resolved_velocity, - duration_beats = duration, - origin = origin, - primary_unmapped = primary_unmapped - ) - return self - - def note_on (self, pitch: typing.Union[int, str], beat: float, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY) -> "PatternBuilder": - - """ - Place an explicit Note On event without a duration. - Useful for drones or infinite sustains. Must be paired with - a ``note_off()`` later to silence the note. - - Parameters: - pitch: MIDI note number (0-127) or a drum name string. - beat: The beat position (0.0 is the start). - velocity: MIDI velocity (0-127, default 100), or a - ``(low, high)`` tuple for a single random draw. - - A drum name this device's ``drum_note_map`` lacks is dropped (warned - once) rather than raising — consistent with the step-note methods. A - string pitch with no ``drum_note_map`` at all is still a configuration - error and raises. - """ - - midi_pitch = self._resolve_pitch_lenient(pitch) - if midi_pitch is None: - return self # drum name this device can't voice — dropped (warned once) - resolved_velocity = self._resolve_velocity(velocity) - if beat < 0: - beat = beat % self._pattern.length # wrap from the end (any magnitude) - - self._pattern.add_raw_note_beats( - message_type = 'note_on', - beat_position = beat, - pitch = midi_pitch, - velocity = resolved_velocity, - origin = pitch if isinstance(pitch, str) else None - ) - return self - - def note_off (self, pitch: typing.Union[int, str], beat: float) -> "PatternBuilder": - - """ - Place an explicit Note Off event to silence a drone. - - Parameters: - pitch: MIDI note number (0-127) or a drum name string. - beat: The beat position (0.0 is the start). - - A drum name this device's ``drum_note_map`` lacks is dropped (warned - once) rather than raising; with no ``drum_note_map`` at all it raises. - """ - - midi_pitch = self._resolve_pitch_lenient(pitch) - if midi_pitch is None: - return self # nothing to silence — this device can't voice the name - if beat < 0: - beat = beat % self._pattern.length # wrap from the end (any magnitude) - - self._pattern.add_raw_note_beats( - message_type = 'note_off', - beat_position = beat, - pitch = midi_pitch, - velocity = 0, - origin = pitch if isinstance(pitch, str) else None - ) - return self - - def drone (self, pitch: typing.Union[int, str], beat: float = 0.0, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY) -> "PatternBuilder": - - """ - A musical alias for ``note_on``. Places a raw Note On event without a duration, - typically used for sustained notes that span multiple cycles. - Must be silenced later using ``drone_off()``. - - Parameters: - pitch: MIDI note number (0-127) or a drum name string. - beat: The beat position (0.0 is the start). - velocity: MIDI velocity (0-127, default 100), or a - ``(low, high)`` tuple for a single random draw. - """ - - self.note_on(pitch, beat=beat, velocity=velocity) - return self - - def drone_off (self, pitch: typing.Union[int, str]) -> "PatternBuilder": - - """ - A musical alias for ``note_off``. Places a raw Note Off event at beat 0.0. - Used to stop a sequence started by ``drone()``. - - Parameters: - pitch: MIDI note number (0-127) or a drum name string. - """ - - self.note_off(pitch, beat=0.0) - return self - - def silence (self, beat: float = 0.0) -> "PatternBuilder": - - """ - Sends an 'All Notes Off' (CC 123) and 'All Sound Off' (CC 120) message - on the pattern's channel to immediately silence any ringing notes or drones. - - Parameters: - beat: The beat position (0.0 is the start). - """ - - self.cc(control=123, value=0, beat=beat) - self.cc(control=120, value=0, beat=beat) - return self - - def hit (self, pitch: typing.Union[int, str], beats: typing.List[float], velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, duration: float = 0.1) -> "PatternBuilder": - - """ - Place multiple short 'hits' at a list of beat positions. - - Parameters: - pitch: MIDI note number or drum name. - beats: List of beat positions. - velocity: MIDI velocity (0-127), or a ``(low, high)`` tuple - for a fresh random draw per hit. - duration: Note duration in beats. - - Example: - ```python - p.hit("snare", [1, 3]) # Standard backbeat - p.hit("snare", [1, 3], velocity=(80, 110)) # Human velocity range - ``` - """ - - for beat in beats: - self.note(pitch=pitch, beat=beat, velocity=velocity, duration=duration) - return self - - def hit_steps (self, pitch: typing.Union[int, str], steps: typing.List[int], velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, duration: float = 0.1, grid: typing.Optional[int] = None, probability: float = 1.0, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> "PatternBuilder": - - """ - Place short hits at specific step (grid) positions. - - Parameters: - pitch: MIDI note number or drum name. - steps: A list of grid indices (0 to ``grid - 1``). - velocity: MIDI velocity (0-127), or a ``(low, high)`` tuple - for a fresh random draw per step. - duration: Note duration in beats. - grid: How many grid slots the pattern is divided into. - Defaults to the pattern's ``default_grid`` (set from the - decorator's ``steps``/``step_duration``, or sixteenth-note - resolution when ``unit`` is omitted). - probability: Chance (0.0 to 1.0) that each hit will play. - seed: Fix the probability gating for this call (an int); omit to - use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # Typical sixteenth-note hi-hats with some probability variation - p.hit_steps("hh", range(16), velocity=70, probability=0.8) - - # Humanised hi-hats — each step gets a fresh random velocity. - p.hit_steps("hh", range(16), velocity=(40, 90)) - ``` - """ - - rng = self._rng_from(seed, rng) - - if grid is None: - grid = self._default_grid - - if grid <= 0: - return self - - step_duration = self._pattern.length / grid - - for i in steps: - - if probability < 1.0 and rng.random() >= probability: - continue - - beat = i * step_duration - self.note(pitch=pitch, beat=beat, velocity=velocity, duration=duration) - return self - - def motif ( - self, - m: "subsequence.motifs.Motif", - beat: float = 0.0, - span: typing.Optional[float] = None, - root: int = 60, - velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, - fit: typing.Optional[float] = None, - fit_weights: typing.Optional[typing.List[float]] = None, - resolution: typing.Optional[int] = None, - ) -> "PatternBuilder": - - """ - Place an immutable :class:`~subsequence.motifs.Motif` onto the pattern. - - Note events route through the universal ``note()`` funnel (drum names, - mirrors, velocity tuples all work); control gestures emit through the - same machinery as ``cc()`` / ``cc_ramp()`` / ``pitch_bend()`` / - ``nrpn()`` / ``osc()``. Pitch specs resolve here, late: ints are MIDI, - strings are drum names, scale degrees resolve against the composition - key + scale anchored near ``root=``. Per-event probabilities roll - fresh each cycle against the pattern's seeded stream. - - Parameters: - m: The motif value (anything exposing ``.events`` / ``.length`` - places; ``.controls`` is read when present). - beat: Where the motif starts within the pattern. - span: Clamp — events whose onset falls at or beyond *span* beats - into the motif are dropped (the ``arpeggio()`` convention). - root: Register anchor for scale-degree resolution: the tonic - lands at its nearest instance to this MIDI note (ties resolve - upward) and the melody keeps its written contour from there. - velocity: Optional override applied to every note (otherwise each - event's own velocity is used). - fit: The chord-tones-on-strong-beats dial, 0.0–1.0: resolved - Degree/int pitches landing on strong beats (metric weight - >= 0.5) snap to the nearest chord tone with this - probability. Defaults to the motif's own ``fit`` (0.7 on - generated motifs, none on hand-written ones — typed degrees - are sacred); inactive without a chord context. ChordTone - and Approach events never snap — their harmony reading is - inherent (an Approach's chromaticism is the point). - fit_weights: Custom per-step metric weight list (the - ``build_ghost_bias`` precedent) for additive or - non-isochronous meters; defaults to the time signature's - table. - resolution: Pulses between control-ramp messages (defaults to - each control verb's own default). Kept out of the value by - design: beats and shapes are music, traffic density is wire. - """ - - events = getattr(m, "events", None) - - if events is None or not hasattr(m, "length"): - raise TypeError(f"motif() places Motif-like values (.events/.length) — got {type(m).__name__}") - - effective_fit = fit if fit is not None else getattr(m, "fit", None) - fit_table: typing.Optional[typing.List[float]] = None - snap_probability = 0.0 - - if effective_fit: - snap_probability = float(effective_fit) - fit_table = list(fit_weights) if fit_weights is not None else subsequence.sequence_utils.build_metric_weights( - self.time_signature, grid = self._default_grid - ) - - for event in events: - - if span is not None and event.beat >= span: - continue - if event.probability < 1.0 and self.rng.random() >= event.probability: - continue - - resolved = self._resolve_motif_pitch(event.pitch, root, beat + event.beat) - - # The fit dial reads only Degree/int content: drums have no - # pitch to snap, ChordTones already are chord tones, and an - # Approach's chromaticism is the point. - if ( - fit_table is not None - and isinstance(resolved, int) - and isinstance(event.pitch, (int, subsequence.motifs.Degree)) - ): - resolved = self._fit_snap(resolved, beat + event.beat, snap_probability, fit_table) - - self.note( - pitch = resolved, - beat = beat + event.beat, - velocity = velocity if velocity is not None else event.velocity, - duration = event.duration, - ) - - for control in getattr(m, "controls", ()): - - if span is not None and control.beat >= span: - continue - if control.probability < 1.0 and self.rng.random() >= control.probability: - continue - - self._emit_control(control, beat, resolution) - - return self - - def _resolve_motif_pitch (self, pitch: typing.Any, root: int, event_beat: float = 0.0) -> typing.Union[int, str]: - - """Resolve one stored pitch spec to a MIDI int or drum name, late. - - ``event_beat`` is the event's position within this cycle — chord- - relative specs resolve against the chord sounding *under the event* - (``p.harmony.chord_at``), not the cycle-start snapshot. - """ - - if pitch is None: - raise ValueError( - "This motif is a rhythm skeleton (pitches stripped) — " - "re-pitch it with .pitched() before placing" - ) - - if isinstance(pitch, (int, str)): - return pitch - - if isinstance(pitch, subsequence.motifs.Degree): - return self._resolve_degree_pitch(pitch, root) - - if isinstance(pitch, subsequence.motifs.ChordTone): - return self._resolve_chord_tone_pitch(pitch, root, event_beat) - - if isinstance(pitch, subsequence.motifs.Approach): - return self._resolve_approach_pitch(pitch, root, event_beat) - - raise TypeError(f"Unknown pitch spec: {type(pitch).__name__}") - - def _resolve_approach_pitch (self, approach: "subsequence.motifs.Approach", root: int, event_beat: float) -> int: - - """Resolve an Approach: one semitone below its target's pitch. - - A ``ChordTone`` target reads the chord at the NEXT boundary after the - event (the harmony window's anticipation data) — the approach is the - tension, the target is where the harmony lands. When the window - holds no committed next chord (the live mode horizon's edge), the - sounding chord stands in. ``Degree``/``int`` targets resolve as - usual (no harmony needed). - """ - - target = approach.target - - if isinstance(target, subsequence.motifs.ChordTone): - - if self.harmony is None: - raise ValueError( - "an Approach at a chord tone needs the harmonic clock — " - "call composition.harmony(...) (a style or a bound progression)" - ) - - chord = self.harmony.next_chord_at(event_beat) - - if chord is None: - chord = self.harmony.chord_at(event_beat) - if chord is None: - raise ValueError( - f"No chord is known around beat {event_beat:g} of this cycle — " - "the harmony window does not cover it" - ) - - tones = chord.tones(root, count = target.index) - resolved = int(tones[target.index - 1]) + 12 * target.octave - - elif isinstance(target, subsequence.motifs.Degree): - resolved = self._resolve_degree_pitch(target, root) - - elif isinstance(target, int): - resolved = target - - else: - raise TypeError(f"cannot approach {type(target).__name__} content") - - pitch = resolved - 1 - - if not 0 <= pitch <= 127: - raise ValueError( - f"Approach resolves to MIDI {pitch}, outside 0–127 — adjust root= or the target's octave" - ) - - return pitch - - def _fit_snap (self, pitch: int, event_beat: float, fit: float, weights: typing.List[float]) -> int: - - """The fit dial: snap a strong-beat pitch to the nearest chord tone, with probability *fit*. - - Strong beats are the metric-weight table's >= 0.5 positions - (downbeats and beats); off-grid events take the nearest grid - position's weight. Inactive without a chord context. - """ - - if self.harmony is None: - return pitch - - bar_beats = float(self.time_signature[0]) - grid = len(weights) - step = (event_beat % bar_beats) * grid / bar_beats - weight = weights[int(round(step)) % grid] - - if weight < 0.5: - return pitch - if self.rng.random() >= fit: - return pitch - - chord = self.harmony.chord_at(event_beat) - - if chord is None: - return pitch - - chord_pcs = {tone % 12 for tone in chord.tones(pitch)} - - if pitch % 12 in chord_pcs: - return pitch - - # Nearest chord tone, ties upward. - for delta in (1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6): - if (pitch + delta) % 12 in chord_pcs and 0 <= pitch + delta <= 127: - return pitch + delta - - return pitch - - def _resolve_chord_tone_pitch (self, tone: "subsequence.motifs.ChordTone", root: int, event_beat: float) -> int: - - """Resolve a 1-based chord-tone index against the chord under the event. - - Reads the harmony window (``p.harmony.chord_at(event_beat)``): indices - walk the sounding chord's tones nearest ``root``, cycling into higher - octaves past the chord's natural size, plus whole-octave shifts. - """ - - if self.harmony is None: - raise ValueError( - "ChordTone pitches resolve against the harmonic clock — " - "call composition.harmony(...) (a style or a bound progression)" - ) - - chord = self.harmony.chord_at(event_beat) - - if chord is None: - raise ValueError( - f"No chord is known at beat {event_beat:g} of this cycle — " - "the harmony window does not cover it" - ) - - tones = chord.tones(root, count = tone.index) - midi = int(tones[tone.index - 1]) + 12 * tone.octave - - if not 0 <= midi <= 127: - raise ValueError( - f"Chord tone {tone.index} resolves to MIDI {midi}, outside 0–127 — " - "adjust root= or the tone's octaves" - ) - - return midi - - def _resolve_degree_pitch (self, degree: "subsequence.motifs.Degree", root: int) -> int: - - """ - Resolve a 1-based scale degree against the composition key + scale. - - The tonic anchors at its nearest instance to ``root`` (ties resolve - upward); the degree then builds from the anchored tonic, so a written - melody keeps its contour. Steps beyond the scale length carry into - higher octaves (8 = tonic an octave up in seven-note scales). - """ - - if self.key is None: - raise ValueError("Scale degrees resolve against a key — set Composition(key=...)") - - mode = self.scale or "ionian" - pcs = subsequence.intervals.scale_pitch_classes(subsequence.chords.key_name_to_pc(self.key), mode) - - idx = (degree.step - 1) % len(pcs) - carry = (degree.step - 1) // len(pcs) - - diff = (pcs[0] - root) % 12 - tonic = root + diff if diff <= 6 else root + diff - 12 - offset = (pcs[idx] - pcs[0]) % 12 - - midi = tonic + offset + 12 * (carry + degree.octave) + degree.chroma - - if not 0 <= midi <= 127: - raise ValueError( - f"Degree {degree.step} resolves to MIDI {midi}, outside 0–127 — " - f"adjust root= or the degree's octaves" - ) - - return midi - - def _emit_control (self, control: "subsequence.motifs.ControlEvent", beat: float, resolution: typing.Optional[int]) -> None: - - """Emit one stored control gesture through the matching builder verb.""" - - signal = control.signal - onset = beat + control.beat - extra: typing.Dict[str, typing.Any] = {} if resolution is None else {"resolution": resolution} - - if isinstance(signal, subsequence.motifs.CC): - if control.end is None: - self.cc(signal.control, int(round(control.start)), beat=onset) - else: - self.cc_ramp(signal.control, int(round(control.start)), int(round(control.end)), beat_start=onset, beat_end=onset + control.span, shape=control.shape, **extra) - - elif isinstance(signal, subsequence.motifs.PitchBend): - if control.end is None: - self.pitch_bend(control.start, beat=onset) - else: - self.pitch_bend_ramp(control.start, control.end, beat_start=onset, beat_end=onset + control.span, shape=control.shape, **extra) - - elif isinstance(signal, subsequence.motifs.NRPN): - if control.end is None: - self.nrpn(signal.parameter, int(round(control.start)), beat=onset, fine=signal.fine, null_reset=signal.null_reset) - else: - self.nrpn_ramp(signal.parameter, int(round(control.start)), int(round(control.end)), beat_start=onset, beat_end=onset + control.span, shape=control.shape, fine=signal.fine, null_reset=signal.null_reset, **extra) - - elif isinstance(signal, subsequence.motifs.RPN): - if control.end is None: - self.rpn(signal.parameter, int(round(control.start)), beat=onset, fine=signal.fine, null_reset=signal.null_reset) - else: - self.rpn_ramp(signal.parameter, int(round(control.start)), int(round(control.end)), beat_start=onset, beat_end=onset + control.span, shape=control.shape, fine=signal.fine, null_reset=signal.null_reset, **extra) - - elif isinstance(signal, subsequence.motifs.OSC): - if control.end is None: - self.osc(signal.address, control.start, beat=onset) - else: - self.osc_ramp(signal.address, control.start, control.end, beat_start=onset, beat_end=onset + control.span, shape=control.shape, **extra) - - else: - raise TypeError(f"Unknown control signal: {type(signal).__name__}") - - def phrase ( - self, - value: typing.Any, - root: int = 60, - velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, - fit: typing.Optional[float] = None, - resolution: typing.Optional[int] = None, - align: str = "pattern", - offset: float = 0.0, - ) -> "PatternBuilder": - - """Place this cycle's window of a Phrase — position computed, never stored. - - The playback position is stateless arithmetic over the engine's own - counters: ``pos = (p.cycle * pattern_length + offset) % phrase.length`` - — deterministic under live reload, ``form_jump``, and render, with - zero new state. A pattern shorter than the phrase walks through it - cycle by cycle; deliberately mismatched lengths are phase drift - (polymeter against the phrase). When the cycle window crosses the - phrase's end, the phrase loops. - - Patterns that should own the phrase's length call - ``p.set_length(phrase.length)`` once instead. - - Parameters: - value: A Phrase (or any value with ``.length``/``.slice``; a - Motif places its window directly). - root: Register anchor for degree resolution (see ``motif()``). - velocity: Optional override applied to every note. - fit: Passed through to ``motif()`` (active with the melody - engine stage). - resolution: Control-ramp pulse density (see ``motif()``). - align: ``"pattern"`` (default) counts pattern cycles; - ``"section"`` uses the bar within the current form section, - so the phrase restarts when the section does. - offset: Beats added to the computed position (a phase shift). - - Example: - ```python - @comp.pattern(channel=4, bars=2) - def lead (p): - p.phrase(lead_line, root=72) - ``` - """ - - length = getattr(value, "length", None) - - if length is None or not hasattr(value, "slice"): - raise TypeError(f"phrase() places Phrase-like values (.length/.slice) — got {type(value).__name__}") - if length <= 0: - raise ValueError("cannot place an empty phrase") - - if align == "pattern": - position = (self.cycle * float(self._pattern.length) + offset) % length - elif align == "section": - if self.section is None: - raise ValueError('phrase(align="section") needs a form — call composition.form(...)') - position = (self.section.bar * float(self.time_signature[0]) + offset) % length - else: - raise ValueError(f'align must be "pattern" or "section" — got {align!r}') - - window_beats = float(self._pattern.length) - placed = 0.0 - - while placed < window_beats - 1e-9: - - take = min(window_beats - placed, length - position) - piece = value.slice(position, position + take) - fragment = piece.flatten() if hasattr(piece, "flatten") else piece - - self.motif(fragment, beat=placed, root=root, velocity=velocity, fit=fit, resolution=resolution) - - placed += take - position = 0.0 # crossed the phrase end — loop to its start - - return self - - def section_motif (self, part: typing.Optional[str] = None) -> typing.Optional[typing.Any]: - - """The Motif/Phrase bound to the current section (and part), or ``None``. - - Reads the ``composition.section_motifs()`` registry for the section - currently playing. A section with no binding returns ``None`` — - bind material or rest; no fallback guessing:: - - @comp.pattern(channel=4, bars=2) - def lead (p): - line = p.section_motif("lead") - if line is not None: - p.phrase(line, root=72) - """ - - if self.section is None or self._section_motifs is None: - return None - - return self._section_motifs.get((self.section.name, part)) - - def capture (self, beat: float = 0.0, span: float = 4.0) -> "subsequence.motifs.Motif": - - """ - Read the notes placed so far back out as a :class:`~subsequence.motifs.Motif`. - - The captured motif is **absolute MIDI and lossy by design**: relative - specs (degrees, chord tones) do not survive resolution, timing is - pulse-truncated, probabilities have already rolled, and control - gestures are not captured. The round trip is generate → place → - capture → hand-edit → rebind. - - Parameters: - beat: Window start within the pattern. - span: Window length in beats (also the captured motif's length). - """ - - ppq = subsequence.constants.MIDI_QUARTER_NOTE - lo, hi = int(beat * ppq), int((beat + span) * ppq) - events = [] - - for pulse in sorted(self._pattern.steps): - - if not lo <= pulse < hi: - continue - - for placed in self._pattern.steps[pulse].notes: - events.append(subsequence.motifs.MotifEvent( - beat = pulse / ppq - beat, - pitch = placed.pitch, - velocity = placed.velocity, - duration = max(placed.duration, 1) / ppq, - )) - - return subsequence.motifs.Motif(events=tuple(events), length=span) - - def sequence (self, steps: typing.List[int], pitches: typing.Union[int, str, typing.List[typing.Union[int, str]]], velocities: typing.Union[int, typing.Tuple[int, int], typing.List[int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, durations: typing.Union[float, typing.List[float]] = 0.1, grid: typing.Optional[int] = None, probability: float = 1.0, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> "PatternBuilder": - - """ - A multi-parameter step sequencer. - - Define which grid steps fire, and then provide a list of pitches, - velocities, and durations. If you provide a list for any parameter, - Subsequence will step through it as it places each note. - - Parameters: - steps: List of grid indices to trigger. An empty list is a - no-op — no notes are placed and the builder is returned - unchanged (handy when probabilistic gating rejects every step). - pitches: Pitch or list of pitches. - velocities: Velocity (default 100), ``(low, high)`` tuple for - a fresh random draw per step, or a list of velocities - matched to the steps one-to-one (a short list repeats its - final value, a long list is truncated — both warn). - durations: Duration or list of durations (default 0.1). - grid: Grid resolution. Defaults to the pattern's - ``default_grid`` (derived from the decorator's ``beats``/``steps`` - and ``unit``). - probability: Chance (0.0 to 1.0) that each step will play. - seed: Fix the probability gating for this call (an int); omit to - use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - """ - - if not steps: - return self - - rng = self._rng_from(seed, rng) - - if grid is None: - grid = self._default_grid - - if grid <= 0: - return self - - n = len(steps) - pitches_list = _expand_sequence_param("pitches", pitches, n) - # Treat a (low, high) tuple as a single random-range descriptor - # rather than a 2-element list to cycle through. - if isinstance(velocities, tuple): - velocities_list = [velocities] * n - else: - velocities_list = _expand_sequence_param("velocities", velocities, n) - durations_list = _expand_sequence_param("durations", durations, n) - - step_duration = self._pattern.length / grid - - for i, step_idx in enumerate(steps): - - if probability < 1.0 and rng.random() >= probability: - continue - - beat = step_idx * step_duration - self.note(pitch=pitches_list[i], beat=beat, velocity=velocities_list[i], duration=durations_list[i]) - return self - - def seq (self, notation: str, pitch: typing.Union[str, int, None] = None, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> "PatternBuilder": - - """ - Build a pattern using an expressive string-based 'mini-notation'. - - The notation distributes events evenly across the current pattern length. - - **Syntax:** - - - ``x y z``: Items separated by spaces are distributed across the bar. - - ``[a b]``: Groups items into a single subdivided step. - - ``~`` or ``.``: A rest. - - ``_``: Extends the previous note (sustain). - - ``x?0.6``: Probability suffix — fires with the given probability (0.0–1.0). - - Parameters: - notation: The mini-notation string. - pitch: If provided, all symbols in the string are triggers for - this specific pitch. If ``None``, symbols are interpreted as - pitches (e.g., "60" or "kick"). - velocity: MIDI velocity (default 100), or a ``(low, high)`` - tuple for a fresh random draw per event. - seed: Fix the ``?`` probability gating for this call (an int); - omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # Simple kick rhythm - p.seq("kick . [kick kick] .") - - # Subdivided melody - p.seq("60 [62 64] 67 60") - - # Ghost snare: snare on 2 and 4, ghost note 50% of the time - p.seq(". snare?0.5 . snare") - ``` - """ - - rng = self._rng_from(seed, rng) - - events = subsequence.mini_notation.parse(notation, total_duration=float(self._pattern.length)) - - for event in events: - - # Apply probability before placing the note. - if event.probability < 1.0 and rng.random() >= event.probability: - continue - - current_pitch = pitch - - # If no global pitch provided, use the symbol as the pitch - if current_pitch is None: - # Try converting to int if it looks like a number - if event.symbol.isdigit(): - current_pitch = int(event.symbol) - else: - current_pitch = event.symbol - - self.note( - pitch = current_pitch, - beat = event.time, - duration = event.duration, - velocity = velocity - ) - return self - - def repeat (self, pitch: typing.Union[int, str], spacing: float, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, duration: float = 0.25) -> "PatternBuilder": - - """ - Repeat a note at a fixed beat interval for the whole pattern. - - The classic 'Note Repeat' of MPC, Push, and Maschine fame: one - pitch firing at a steady rate — running hi-hats, a pulsing bass - note, a metronome click. - - Parameters: - pitch: MIDI note number or drum name. - spacing: Time between each note in beats (0.25 = sixteenth notes). - velocity: MIDI velocity (default 100), or a ``(low, high)`` - tuple for a fresh random draw per note. - duration: Note duration in beats. - - Example: - ```python - p.repeat("hh", spacing=0.25) # sixteenth notes - p.repeat("hh", spacing=0.25, velocity=(40, 80)) # humanised - ``` - """ - - if spacing <= 0: - raise ValueError("Spacing must be positive") - - beat = 0.0 - - while beat < self._pattern.length: - self.note(pitch=pitch, beat=beat, velocity=velocity, duration=duration) - beat += spacing - return self - - def arpeggio ( - self, - notes: typing.Any, - root: typing.Optional[int] = None, - velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, - count: typing.Optional[int] = None, - inversion: int = 0, - beat: float = 0.0, - span: typing.Optional[float] = None, - spacing: float = 0.25, - duration: typing.Optional[float] = None, - direction: str = "up", - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None - ) -> "PatternBuilder": - - """ - Arpeggiate a chord (or a list of pitches) — cycle the notes one at a time - at regular beat intervals. - - Like ``chord()`` and ``strum()``, the first argument can be a chord — the - ``chord`` passed to your pattern function, or any chord from - ``p.progression()`` — and ``root`` / ``count`` / ``inversion`` voice it - exactly as they do. So "play this as a chord, a strum, or an arpeggio" is a - one-word verb swap:: - - for chord, start, length in p.progression("phrygian_minor", harmonic_rhythm=...): - p.arpeggio(chord, root=48, beat=start, span=length, spacing=0.25, count=4) - - Pass a list of pitches instead to arpeggiate something that isn't a chord (a - scale fragment, a custom voicing). Unlike a held ``chord()``, an arpeggio is - a stream of single notes, so it has no ``sustain`` / ``legato`` / ``detached`` - — use ``duration`` for how long each note rings and ``span`` for how much of - the bar the figure fills. - - An empty pitch list rests (places nothing), so a live arpeggiator over - ``p.held_notes()`` is simply silent when no keys are held:: - - p.arpeggio(p.held_notes(), direction="up") - - Parameters: - notes: A chord to arpeggiate (anything with a ``.tones()`` method — the - pattern's ``chord``, or a chord from ``p.progression()``), or a list - of MIDI note numbers (e.g. ``60``) / drum-name strings when the - pattern has a ``drum_note_map``. For pitched note *names* use the - integer constants in ``subsequence.constants.midi_notes`` (e.g. - ``notes.C4``). In the list form, a drum name the map lacks is - dropped (warned once); a string with no map at all still raises. - root: MIDI root note for the chord form (e.g. 48), exactly as ``chord()``. - Required for a chord; not used for a plain pitch list. - velocity: MIDI velocity for all notes (default 100 — arpeggios sit in the - melodic-line velocity bucket, not the softened-chord bucket; pass - ``velocity=90`` to match ``chord()``), or a ``(low, high)`` tuple for - a fresh random draw per note. - count: Number of voices for the chord form (cycles tones into higher - octaves if larger than the chord's natural size). Chord form only. - inversion: Chord inversion for the chord form (ignored when voice leading - is on). Chord form only. - beat: Beat to start the figure at (default 0.0 = the start of the - pattern). Use it to place an arpeggio over one progression chord. - span: How many beats the figure fills, starting at ``beat`` (default: to - the end of the pattern). Pass the chord's ``length`` from a - progression loop to confine the arpeggio to its slot. - spacing: Time between each note in beats (default 0.25 = 16th note). - duration: Note duration in beats. Defaults to ``spacing`` (each note - fills its slot exactly). - direction: Order in which the notes are cycled: - - - ``"up"`` — lowest to highest, then wrap (default). - - ``"down"`` — highest to lowest, then wrap. - - ``"up_down"`` — ascend then descend (ping-pong), cycling. - - ``"random"`` — shuffled once per call using *rng*. - - seed: Fix the ``direction="random"`` shuffle for this call (an - int); omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - # Arpeggiate the pattern's current chord, four voices ascending - p.arpeggio(chord, root=60, count=4, spacing=0.25) - - # A plain list of pitches — ping-pong: C E G E C E G E ... - p.arpeggio([60, 64, 67], spacing=0.25, direction="up_down") - - # One chord of a progression, confined to its slot, humanised - p.arpeggio(chord, root=48, beat=start, span=length, velocity=(60, 95)) - ``` - """ - - if beat < 0: - raise ValueError("arpeggio beat must be >= 0 — use a positive start within the pattern") - - if spacing <= 0: - raise ValueError("Spacing must be positive") - - # Resolve the first argument into a concrete pitch list. A chord-like object - # (it has .tones()) is voiced via root/count/inversion exactly as chord() does; - # anything else is treated as an explicit list of pitches (today's behaviour). - resolved: typing.List[int] - - if hasattr(notes, "tones"): - if root is None: - raise ValueError("arpeggio(, …) needs a root — e.g. arpeggio(chord, root=48); pass a root MIDI note, or hand a list of pitches instead") - resolved = notes.tones(root=root, inversion=inversion, count=count) - else: - if root is not None or count is not None or inversion != 0: - raise ValueError("arpeggio root=, count=, and inversion= only apply to the chord form — arpeggio(chord, root=48, count=4); with a plain pitch list, drop them") - if not notes: - return self # nothing held (e.g. p.arpeggio(p.held_notes()) with no keys down) — rest - resolved = [r for r in (self._resolve_pitch_lenient(p) for p in notes) if r is not None] - if not resolved: - return self # every named voice was dropped (this device lacks them all) - - if direction == "up": - pass # already in ascending order as supplied - elif direction == "down": - resolved = list(reversed(resolved)) - elif direction == "up_down": - if len(resolved) > 1: - resolved = resolved + list(reversed(resolved[1:-1])) - elif direction == "random": - rng = self._rng_from(seed, rng) - resolved = list(resolved) - rng.shuffle(resolved) - else: - raise ValueError(f"direction must be 'up', 'down', 'up_down', or 'random', got '{direction}'") - - if duration is None: - duration = spacing - - # Window the figure to [beat, beat + span), clamped to the pattern end so a - # positioned arpeggio (e.g. one chord of a progression) stays in its slot. - pattern_length = float(self._pattern.length) - - if span is None: - end = pattern_length - else: - if span <= 0: - raise ValueError(f"span must be positive, got {span:g}") - end = beat + span - - end = min(end, pattern_length) - - # Place notes one at a time via self.note() so a (low, high) - # velocity tuple produces a fresh random draw per arp note. - position = beat - i = 0 - while position < end: - self.note( - pitch = resolved[i % len(resolved)], - beat = position, - velocity = velocity, - duration = duration, - ) - position += spacing - i += 1 - return self - - def _warn_positioned_articulation (self, method: str, beat: float) -> None: - - """Warn (once per pattern) that ``sustain``/``detached`` ring from the pattern - length, not from ``beat``. - - ``chord``/``strum`` size ``sustain``/``detached`` against the whole pattern (the - one-chord-fills-the-bar model). With a non-zero ``beat`` — e.g. placing several - chords across a progression — that almost always rings the chord far past its - slot, so we flag it. Deduped on the pattern so a hot-reloading builder warns once. - """ - - if self._pattern._warned_positioned_articulation: - return - self._pattern._warned_positioned_articulation = True - logger.warning( - "%s(beat=%g, …) was called with sustain= or detached= set — those size the ring " - "from the pattern length, not from beat, so the chord can sustain past its slot. " - "For a positioned chord (e.g. over a progression) set duration= explicitly instead.", - method, beat, - ) - - def chord (self, chord_obj: typing.Any, root: int, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, sustain: bool = False, duration: float = 1.0, inversion: int = 0, count: typing.Optional[int] = None, legato: typing.Optional[float] = None, detached: typing.Optional[float] = None, beat: float = 0.0) -> "PatternBuilder": - - """ - Place a chord at ``beat`` (the start of the pattern by default). - - Note: If the pattern was registered with ``voice_leading=True``, - this method automatically chooses the best inversion. - - Parameters: - chord_obj: The chord to play (usually the ``chord`` parameter - passed to your pattern function). - root: MIDI root note (e.g., 60 for Middle C). - velocity: MIDI velocity (default 90), or a ``(low, high)`` - tuple for a fresh random draw per chord tone (each - voice gets a slightly different velocity — useful for - humanising the "fingers" feel). - sustain: If True, the notes last for the entire pattern duration. - Mutually exclusive with ``legato`` and ``detached``. - duration: Note duration in beats (default 1.0). Ignored when - ``legato`` or ``detached`` is set, since those recalculate - durations. - inversion: Specific chord inversion (ignored if voice leading is on). - count: Number of notes to play (cycles tones if higher than - the chord's natural size). - legato: If given, calls ``p.legato(ratio)`` after placing the - chord, stretching each note to fill ``ratio`` of the gap to - the next note. Mutually exclusive with ``sustain`` and - ``detached``. - detached: If given, the chord rings until ``detached`` beats - before the next cycle — equivalent to setting - ``duration = pattern.length - detached``. Use this for a - declarative polyphony-safety margin so the chord always - releases before the next chord begins. Mutually exclusive - with ``sustain`` and ``legato``. - beat: Beat offset to place the chord at (default 0.0 = the start of the - pattern). ``sustain`` and ``detached`` still measure their ring from the - pattern length, not from ``beat`` — when placing several positioned chords - (e.g. over a progression) set ``duration`` explicitly instead. - - Example:: - - # Shorthand for: p.chord(...) then p.legato(0.9) - p.chord(chord, root=root, velocity=85, count=4, legato=0.9) - - # Hold the chord almost the full cycle, releasing 0.25 beats - # before the next chord begins. - p.chord(chord, root=root, velocity=85, count=5, detached=0.25) - """ - - set_count = (1 if sustain else 0) + (1 if legato is not None else 0) + (1 if detached is not None else 0) - if set_count > 1: - raise ValueError("sustain=, legato=, and detached= are mutually exclusive — use one or the other") - - if beat != 0.0 and (sustain or detached is not None): - self._warn_positioned_articulation("chord", beat) - - pitches = chord_obj.tones(root=root, inversion=inversion, count=count) - - if sustain: - duration = float(self._pattern.length) - elif detached is not None: - duration = float(self._pattern.length) - detached - if duration <= 0: - raise ValueError(f"detached ({detached}) must be less than the pattern length ({self._pattern.length:g} beats) so the chord keeps a positive duration") - - for pitch in pitches: - self._pattern.add_note_beats( - beat_position = beat, - pitch = pitch, - velocity = self._resolve_velocity(velocity), - duration_beats = duration - ) - - if legato is not None: - self.legato(legato) - return self - - def strum (self, chord_obj: typing.Any, root: int, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, sustain: bool = False, duration: float = 1.0, inversion: int = 0, count: typing.Optional[int] = None, spacing: float = 0.05, direction: str = "up", legato: typing.Optional[float] = None, detached: typing.Optional[float] = None, beat: float = 0.0) -> "PatternBuilder": - - """ - Play a chord with a small time offset between each note (strum effect). - - Works exactly like ``chord()`` but staggers the notes instead of - playing them simultaneously. The first note lands on ``beat`` (0 by default); - subsequent notes are delayed by ``spacing`` beats each. - - Parameters: - chord_obj: The chord to play (usually the ``chord`` parameter - passed to your pattern function). - root: MIDI root note (e.g., 60 for Middle C). - velocity: MIDI velocity (default 90), or a ``(low, high)`` - tuple for a fresh random draw per strum note. - sustain: If True, the notes last for the entire pattern duration. - Mutually exclusive with ``legato`` and ``detached``. - duration: Note duration in beats (default 1.0). Ignored when - ``legato`` or ``detached`` is set, since those recalculate - durations. - inversion: Specific chord inversion (ignored if voice leading is on). - count: Number of notes to play (cycles tones if higher than - the chord's natural size). - spacing: Time in beats between each note onset (default 0.05). - direction: ``"up"`` for low-to-high, ``"down"`` for high-to-low. - beat: Beat offset for the first note (default 0.0); the stagger is added - on top. ``sustain``/``detached`` ring from the pattern length, not from - ``beat`` — set ``duration`` explicitly when placing positioned strums. - legato: If given, calls ``p.legato(ratio)`` after placing the - chord, stretching each note to fill ``ratio`` of the gap to - the next note. Mutually exclusive with ``sustain`` and - ``detached``. - detached: If given, every strum note rings with a uniform - duration of ``pattern.length - detached - (count - 1) * spacing``. - The last note ends exactly ``detached`` beats before the - next cycle; earlier notes end proportionally sooner, so - releases are staggered in the same shape as the placements - (the hand lifts the way it landed). Polyphony-safe: - guarantees nothing from this strum is still sounding when - the next chord begins. Mutually exclusive with ``sustain`` - and ``legato``. - - Example:: - - # Gentle upward strum with legato - p.strum(chord, root=52, velocity=85, spacing=0.06, legato=0.95) - - # Fast downward strum - p.strum(chord, root=52, direction="down", spacing=0.03) - - # Five-voice strum with a 0.25-beat safety gap before the - # next chord — won't exhaust polyphony on a 5-voice synth. - p.strum(chord, root=48, count=5, spacing=0.1, detached=0.25) - """ - - set_count = (1 if sustain else 0) + (1 if legato is not None else 0) + (1 if detached is not None else 0) - if set_count > 1: - raise ValueError("sustain=, legato=, and detached= are mutually exclusive — use one or the other") - - if beat != 0.0 and (sustain or detached is not None): - self._warn_positioned_articulation("strum", beat) - - if spacing <= 0: - raise ValueError("spacing must be positive") - - if direction not in ("up", "down"): - raise ValueError(f"direction must be 'up' or 'down', got '{direction}'") - - pitches = chord_obj.tones(root=root, inversion=inversion, count=count) - - if direction == "down": - pitches = list(reversed(pitches)) - - if sustain: - duration = float(self._pattern.length) - elif detached is not None: - duration = float(self._pattern.length) - detached - (len(pitches) - 1) * spacing - if duration <= 0: - raise ValueError(f"detached ({detached}) plus the strum stagger exceeds the pattern length ({self._pattern.length:g} beats) — reduce detached, spacing, or count") - - for i, pitch in enumerate(pitches): - self.note(pitch=pitch, beat=beat + i * spacing, velocity=velocity, duration=duration) - - if legato is not None: - self.legato(legato) - return self - - def progression (self, source: subsequence.progressions.ProgressionSource, harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, key: typing.Optional[str] = None, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> subsequence.progressions.Progression: - - """Realise a chord progression across the pattern, returning it to place yourself. - - Returns a freshly realised :class:`~subsequence.progressions.Progression` - — an iterable of ``(chord, start, length)`` events laying a progression - end-to-end across the pattern's length, each chord given a length drawn - from *harmonic_rhythm* (the musical term for how often the chords - change). You loop over it and play each chord however you like — - block, strummed, or arpeggiated:: - - for chord, start, length in p.progression("phrygian_minor", - harmonic_rhythm=between(WHOLE, 3 * WHOLE, step=WHOLE), seed=7): - p.strum(chord, root=48, beat=start, duration=length - 0.25, spacing=0.04, count=4) - - This is the **part-level** progression seam: it re-realises a fresh - value each rebuild (the breathing behaviour), runs entirely outside - the global harmonic clock — so a part can inhabit its own harmonic - world (polytonality) or move faster than the clock's span floor — - and never advances engine state. - - For a one-call block-chord part with no loop, use ``composition.chords()``. - - Parameters: - source: A built-in chord-graph style name (e.g. ``"phrygian_minor"``) to - *generate* a progression; an explicit element list — ints where - diatonic, name or roman strings (``["Cm7", 6, "bVII"]``), ``Chord`` - objects — cycled to fill the pattern; or a - :class:`~subsequence.progressions.Progression` value (its spans - cycled, decoration preserved). - harmonic_rhythm: How long each chord lasts, in beats. One of: a single - number (static); a list of lengths (a shaped rhythm such as - ``[WHOLE, HALF, HALF]``, cycled per chord); or ``between(low, high, - step=...)`` for a bounded, optionally-quantised random length. - key: Key for styles and key-relative elements (degrees/romans); - defaults to the composition's key. - seed: If given, the progression is realised from a fresh ``Random(seed)`` - so it is identical on every cycle (a fixed phrase). When omitted, the - pattern's own RNG is used, so it can vary per cycle (still reproducible - under a composition seed). - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Returns: - A ``Progression`` you can iterate as ``(chord, start, length)`` tuples - (or read via ``.events()`` / ``print()``). - """ - - rng = self._rng_from(seed, rng) - resolved_key = key if key is not None else self.key - return subsequence.progressions.realize( - source = source, - harmonic_rhythm = harmonic_rhythm, - key = resolved_key, - length = float(self._pattern.length), - rng = rng, - scale = self.scale or "ionian", - ) - - def broken_chord (self, chord_obj: typing.Any, root: int, order: typing.List[int], spacing: float = 0.25, velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, duration: typing.Optional[float] = None, inversion: int = 0, beat: float = 0.0, span: typing.Optional[float] = None) -> "PatternBuilder": - - """ - Play a chord as an arpeggio in a specific or random order. - - This generates the chord tones and maps them according to the provided - ``order`` list of indices, then delegates to ``arpeggio()``. It is ideal - for broken chords or random chord-tone melodies. - - Because the order is a list of node indices, the number of generated tones - is automatically set to ``max(order) + 1`` to ensure all indices are valid. - Higher indices will cycle into the next octave. - - Parameters: - chord_obj: The chord to play (usually from ``p.section.chord``). - root: MIDI root note (e.g., 60 for Middle C). - order: List of indices into the chord tones array, dictating playback order. - spacing: Time between each note in beats (default 0.25 = 16th note). - velocity: MIDI velocity for all notes (default 90 — broken_chord is a - chord voice, so it sits in the softer chord velocity bucket like - ``chord()`` and ``strum()``), or a ``(low, high)`` tuple for a - fresh random draw per note. - duration: Note duration in beats. Defaults to ``spacing``. - inversion: Specific chord inversion (ignored if voice leading is on). - beat: Beat to start the broken chord at (default 0.0). - span: How many beats to fill from ``beat`` (default: to the end of the - pattern). Like ``arpeggio()``, use it to place a broken chord over - one chord of a progression. - - Example:: - - # A 5-note broken chord using a predefined pattern - p.broken_chord(chord, root=60, order=[4, 0, 2, 1, 3], spacing=0.25) - - # A fully random broken chord using the pattern's deterministic RNG - order = list(range(5)) - p.rng.shuffle(order) - p.broken_chord(chord, root=60, order=order) - """ - - if not order: - raise ValueError("order list cannot be empty") - - for idx in order: - if not isinstance(idx, int) or idx < 0: - raise ValueError("order must contain only non-negative integers") - - required_count = max(order) + 1 - tones = chord_obj.tones(root=root, inversion=inversion, count=required_count) - pitches = [tones[i] for i in order] - - self.arpeggio(notes=pitches, spacing=spacing, velocity=velocity, duration=duration, direction="up", beat=beat, span=span) - return self - - def swing (self, percent: float = 57.0, grid: float = 0.25, strength: float = 1.0) -> "PatternBuilder": - - """ - Apply swing feel to all notes in the pattern. - - A shortcut for ``p.groove(Groove.swing(percent, grid), strength)``. Swing is a - groove where every other grid note is delayed - the simplest way to - give a mechanical pattern a pushed, human feel. - - 50% is perfectly straight (no swing). 57% is the Ableton default - (a gentle shuffle). 67% is classic triplet swing. - - Parameters: - percent: Swing amount as a percentage (50-75 is the useful range). - 50 = straight, 57 = moderate shuffle, 67 ≈ triplet swing. - grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes). - strength: How much swing to apply (0.0-1.0). 0.0 = no effect, - 1.0 = full swing at the given percent. Useful for dialling - back the feel without changing the swing percentage. - - Example:: - - p.hit_steps("hh", range(16), velocity=80) - p.swing(57) # gentle 16th-note shuffle - p.swing(57, strength=0.5) # half-strength — subtler feel - """ - - self.groove(subsequence.groove.Groove.swing(percent=percent, grid=grid), strength=strength) - return self - - - def groove (self, template: subsequence.groove.Groove, strength: float = 1.0) -> "PatternBuilder": - - """ - Apply a groove template to all notes in the pattern. - - A groove is a repeating pattern of per-step timing offsets and - optional velocity adjustments. It gives a pattern its characteristic - rhythmic feel - swing, shuffle, MPC pocket, or any custom shape. - - Construct a groove with one of the factory methods: - - - ``Groove.swing(percent)`` - simple swing by percentage - (or use the ``p.swing()`` shortcut for common cases) - - ``Groove.from_agr(path)`` - import timing from an Ableton .agr file - - ``Groove(offsets=[...], grid=0.25, velocities=[...])`` - fully custom - - ``p.groove()`` is a post-build transform - call it after all notes - have been placed. It pairs well with ``p.randomize()`` for - structured feel plus organic micro-variation. - - Parameters: - template: A ``Groove`` instance defining the timing/velocity template. - strength: How much of the groove to apply (0.0-1.0). 0.0 = no - effect, 1.0 = full groove. Blends timing offsets and velocity - deviation proportionally - equivalent to Ableton's - TimingAmount and VelocityAmount dials. - - Example:: - - groove = subsequence.Groove.swing(percent=57) - - @composition.pattern(channel=10, beats=4) - def drums (p): - p.hit_steps("kick", [0, 8], velocity=100) - p.hit_steps("hh", range(16), velocity=80) - p.groove(groove) # full strength - p.groove(groove, strength=0.5) # half-strength blend - """ - - self._pattern.steps = subsequence.groove.apply_groove( - self._pattern.steps, template, strength=strength - ) - return self - - # These methods transform existing notes after they have been placed. - # Call them at the end of your builder function, after all notes are - # in position. They operate on self._pattern.steps (the pulse-position - # dict) and can be chained in any order. - - def dropout (self, probability: float, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> "PatternBuilder": - - """ - Randomly remove notes from the pattern. - - This operates on all notes currently placed in the builder. - - Parameters: - probability: The chance (0.0 to 1.0) of each pulse POSITION being - removed — all notes sharing that position (a chord's voices, - layered drums) live or die together. - seed: Fix the dropout for this call (an int); omit to use the - pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - """ - - rng = self._rng_from(seed, rng) - - positions_to_remove = [] - - for position in list(self._pattern.steps.keys()): - - if rng.random() < probability: - positions_to_remove.append(position) - - for position in positions_to_remove: - del self._pattern.steps[position] - return self - - def velocity_shape (self, low: int = subsequence.constants.velocity.VELOCITY_SHAPE_LOW, high: int = subsequence.constants.velocity.VELOCITY_SHAPE_HIGH) -> "PatternBuilder": - - - """ - Apply organic velocity variation to all notes in the pattern. - - Uses a van der Corput sequence to distribute velocities evenly - across the specified range, which often sounds more 'human' than - purely random velocity variation. - - Parameters: - low: Minimum velocity (default 64). - high: Maximum velocity (default 127). - """ - - positions = sorted(self._pattern.steps.keys()) - - if not positions: - return self - - vdc_values = subsequence.sequence_utils.generate_van_der_corput_sequence(len(positions)) - - for position, vdc_value in zip(positions, vdc_values): - - step = self._pattern.steps[position] - - for note in step.notes: - note.velocity = int(low + (high - low) * vdc_value) - return self - - def duck_map ( - self, - steps: typing.Iterable[int], - floor: float = 0.0, - grid: typing.Optional[int] = None, - ) -> typing.List[float]: - - """ - Build a per-step velocity multiplier list for sidechain-style ducking. - - Returns a list of floats, one per grid step: ``floor`` at each trigger - step in ``steps``, ``1.0`` everywhere else. Pass the result to - ``p.data`` for another pattern to read, then apply with - ``p.scale_velocities()``. - - Parameters: - steps: Grid indices that trigger ducking (e.g. kick hit positions). - floor: Multiplier written at trigger steps. ``0.0`` = full silence, - ``1.0`` = no effect. Values in between give partial ducking. - grid: Grid resolution (defaults to ``p.grid``). - - Returns: - ``List[float]`` of length ``grid``. - - Example:: - - # Full duck on kick hits - p.data["kick_sc"] = p.duck_map(kick_steps) - - # Softer duck - p.data["kick_sc"] = p.duck_map(kick_steps, floor=0.3) - - # Velocity-proportional: deeper duck for harder kicks - p.data["kick_sc"] = p.duck_map(kick_steps, floor=1.0 - (velocity / 127)) - """ - - if grid is None: - grid = self._default_grid - - trigger = set(steps) - return [floor if s in trigger else 1.0 for s in range(grid)] - - def build_velocity_ramp ( - self, - low: int, - high: int, - shape: str = "linear", - grid: typing.Optional[int] = None, - ) -> typing.List[int]: - - """ - Build a per-step velocity list that ramps from *low* to *high*. - - A musician-friendly shortcut for the common pattern of generating - a fixed-length velocity sweep using an easing curve. Returns - ``List[int]`` ready to pass directly to ``velocities=`` parameters. - - Parameters: - low: Velocity at the first step (0–127). - high: Velocity at the last step (0–127). - shape: Easing curve name (see ``subsequence.easing``). Common - values: ``"linear"``, ``"ease_in"``, ``"ease_out"``, - ``"ease_in_out"``. Defaults to ``"linear"``. - grid: Number of steps (defaults to ``p.grid``). - - Returns: - ``List[int]`` of length ``grid``, values clamped to 0–127. - - Example:: - - # Snare roll that swells into a downbeat - p.sequence( - steps=range(16), - pitches="snare_1", - durations=0.1, - velocities=p.build_velocity_ramp(25, 100, "ease_in"), - ) - - # Fade-out ghost fill - p.ghost_fill("snare_1", 1, - velocity=p.build_velocity_ramp(80, 20, "ease_out"), - bias="sixteenths", no_overlap=True) - """ - - if grid is None: - grid = self._default_grid - - return [ - max(0, min(127, int(v))) - for v in subsequence.easing.ramp(grid, float(low), float(high), shape) - ] - - def scale_velocities ( - self, - factors: typing.Sequence[float], - grid: typing.Optional[int] = None, - ) -> "PatternBuilder": - - """ - Scale note velocities by a per-step multiplier list. - - Each note's velocity is multiplied by the factor at the corresponding - grid step index. A factor of ``1.0`` leaves the velocity unchanged; - ``0.0`` silences the note; ``0.5`` halves it. - - Parameters: - factors: Per-step multipliers, one float per grid step. - Values outside ``[0.0, 1.0]`` are valid — result is clamped to - ``[0, 127]`` after scaling. - grid: Grid resolution (defaults to ``p.grid``). Must match the - length of ``factors``. - - Returns: - ``self`` for fluent chaining. - - Example:: - - # Sidechain ducking: silence bass on kick steps, full volume elsewhere. - kick_steps = {0, 4, 8, 12} - p.data["kick_sc"] = [0.0 if s in kick_steps else 1.0 for s in range(p.grid)] - - # In the bass pattern: - p.scale_velocities(p.data.get("kick_sc", [1.0] * p.grid)) - """ - - if grid is None: - grid = self._default_grid - - if grid <= 0: - return self - - step_duration = self._pattern.length / grid - pulses_per_step = step_duration * subsequence.constants.MIDI_QUARTER_NOTE - - for pulse, step in self._pattern.steps.items(): - # A note in the last half-step rounds up to idx == grid, which is - # really the wrap back to step 0 of the next cycle (patterns are - # cyclic) — wrap it so a note pushed late by swing/randomize scales - # by factors[0] rather than silently keeping its velocity. - idx = int(round(pulse / pulses_per_step)) % grid - - if 0 <= idx < len(factors): - for note in step.notes: - note.velocity = max(0, min(127, int(note.velocity * factors[idx]))) - - return self - - def randomize ( - self, - timing: float = 0.03, - velocity: float = 0.0, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None - ) -> "PatternBuilder": - - """ - Add random variations to note timing and velocity. - - Introduces small imperfections — the micro-variations that distinguish - a played performance from a perfectly quantized sequence. - - Called with no arguments, only timing variation is applied - (velocity defaults to 0.0 — no change). Pass a velocity value - to also randomise dynamics: - - # Timing only (default) - p.randomize() - - # Both axes - p.randomize(timing=0.04, velocity=0.08) - - # Stronger feel - p.randomize(timing=0.08, velocity=0.15) - - Resolution note: the sequencer runs at 24 PPQN. At 120 BPM, one - pulse ≈ 20ms. Timing shifts smaller than roughly 0.04 beats may - have no audible effect because they round to zero pulses. - Recommended range: timing=0.02–0.08, velocity=0.05–0.15. - - When the composition has a seed set, ``p.rng`` is deterministic, - so ``p.randomize()`` produces the same result on every run. - - Parameters: - timing: Maximum timing offset in beats (e.g. 0.05 = ±1.2 - pulses at 24 PPQN). Notes shift by a random amount - within ``[-timing, +timing]`` beats. Clamped to - pulse 0 at the lower bound. - velocity: Maximum velocity scale factor (0.0 to 1.0). Each - note's velocity is multiplied by a random value in - ``[1 - velocity, 1 + velocity]``, clamped to 1–127. - seed: Fix the variations for this call (an int); omit to use the - pattern's RNG (seeded when the composition has a seed). - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - """ - - rng = self._rng_from(seed, rng) - - max_timing_pulses = timing * subsequence.constants.MIDI_QUARTER_NOTE - new_steps: typing.Dict[int, subsequence.pattern.Step] = {} - - for pulse, step in self._pattern.steps.items(): - - if timing != 0.0: - offset = rng.uniform(-max_timing_pulses, max_timing_pulses) - new_pulse = max(0, int(round(pulse + offset))) - else: - new_pulse = pulse - - if new_pulse not in new_steps: - new_steps[new_pulse] = subsequence.pattern.Step() - - # Process notes: randomise velocity once per note, then place in new bucket. - for note in step.notes: - if velocity != 0.0: - scale = rng.uniform(1.0 - velocity, 1.0 + velocity) - note.velocity = max(1, min(127, int(round(note.velocity * scale)))) - - new_steps[new_pulse].notes.append(note) - - self._pattern.steps = new_steps - return self - - def legato (self, ratio: float = 1.0) -> "PatternBuilder": - - """ - Adjust note durations to fill the gap until the next note. - - Parameters: - ratio: How much of the gap to fill (0.0 to 1.0). - 1.0 is full legato, < 1.0 is staccato. - """ - - if not self._pattern.steps: - return self - - sorted_positions = sorted(self._pattern.steps.keys()) - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - - for i, position in enumerate(sorted_positions): - - # Calculate gap to next note - if i < len(sorted_positions) - 1: - gap = sorted_positions[i + 1] - position - else: - # Wrap around: gap is distance to end + distance to first note - gap = (total_pulses - position) + sorted_positions[0] - - # Apply ratio and enforce minimum duration - new_duration = max(1, int(gap * ratio)) - - step = self._pattern.steps[position] - for note in step.notes: - note.duration = new_duration - return self - - def duration (self, beats: float) -> "PatternBuilder": - - """ - Set every note's duration to a fixed length in beats. - - This overrides any existing note durations, acting as a global - 'gate time' relative to the beat (1.0 = a quarter note). Short - values clip notes tight; long values let them ring. For a - guaranteed gap before each next onset regardless of note spacing, - use :meth:`detached`; for a classic staccato articulation, either - a short fixed value (``p.duration(0.1)``) or ``p.detached()`` works. - - Parameters: - beats: Fixed note duration in beats (relative to a quarter note). - 0.5 = eighth-note length, 0.25 = sixteenth-note length. Must be positive. - """ - - if beats <= 0: - raise ValueError("Note duration (beats) must be positive") - - duration_pulses = int(beats * subsequence.constants.MIDI_QUARTER_NOTE) - duration_pulses = max(1, duration_pulses) - - for step in self._pattern.steps.values(): - for note in step.notes: - note.duration = duration_pulses - return self - - def detached (self, beats: float = 0.05) -> "PatternBuilder": - - """ - Shorten note durations so a guaranteed silence precedes the next onset. - - The complement of :meth:`legato`. For every placed note, the duration - is shrunk so that at least ``beats`` beats of silence remain before - the next note begins (wrapping around to the first note for the last - one). Use this when you want a clean detached articulation, or as a - polyphony-safety margin between chord transitions on a monophonic or - voice-limited synth. - - Parameters: - beats: Minimum gap in beats before the next onset (default 0.05 - — roughly 25 ms at 120 BPM). Must be positive. - - Example:: - - # Bassline on a mono synth: each 16th note ends 0.05 beats - # before the next, so the synth never retriggers mid-note. - p.arpeggio(chord.tones(36, count=4), spacing=0.25).detached() - - # Explicit larger gap for a longer release tail. - p.melody(state, spacing=0.25).detached(0.1) - """ - - if beats <= 0: - raise ValueError("detached beats must be positive") - - if not self._pattern.steps: - return self - - sorted_positions = sorted(self._pattern.steps.keys()) - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - detached_pulses = int(beats * subsequence.constants.MIDI_QUARTER_NOTE) - - for i, position in enumerate(sorted_positions): - - # Calculate gap to next note (wrap-around for the last one) - if i < len(sorted_positions) - 1: - gap = sorted_positions[i + 1] - position - else: - gap = (total_pulses - position) + sorted_positions[0] - - new_duration = max(1, gap - detached_pulses) - - for note in self._pattern.steps[position].notes: - note.duration = new_duration - return self - - def snap_to_scale (self, key: str, mode: str = "ionian", strength: float = 1.0, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None) -> "PatternBuilder": - - """ - Snap all notes in the pattern to the nearest pitch in a scale. - - Useful after generative or sensor-driven pitch work (random walks, - mapping data values to note numbers, etc.) to ensure every note lands - on a musically valid scale degree. The snap is applied in - place; notes already on a scale degree are left unchanged. - - When a note falls equidistant between two scale tones, the upward - direction is preferred. - - Parameters: - key: Root note name (e.g. ``"C"``, ``"F#"``, ``"Bb"``). - mode: Scale mode. Any key in :data:`subsequence.intervals.DIATONIC_MODE_MAP` - is accepted: ``"ionian"`` (default), ``"dorian"``, ``"minor"``, - ``"harmonic_minor"``, etc. - strength: Probability that each note is snapped (0.0–1.0). - At 1.0 (default), every note snaps to the scale. - At 0.0, no notes are affected. - Values in between create melodies that are mostly in key - with occasional chromatic passing tones. Uses the - pattern's seeded RNG for reproducibility. - seed: Fix the partial-strength snapping for this call (an int); - omit to use the pattern's RNG. - rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). - - Example: - ```python - @composition.pattern(channel=1, beats=4) - def melody (p): - for beat in range(16): - pitch = 60 + random.randint(-5, 5) - p.note(pitch, beat=beat * 0.25) - p.snap_to_scale("G", "dorian", strength=0.8) - ``` - """ - - rng = self._rng_from(seed, rng) - - key_pc = subsequence.chords.key_name_to_pc(key) - scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) - - for step in self._pattern.steps.values(): - for note in step.notes: - if strength >= 1.0 or rng.random() < strength: - note.pitch = subsequence.intervals.quantize_pitch(note.pitch, scale_pcs) - return self - - def apply_tuning ( - self, - tuning: "subsequence.tuning.Tuning", - bend_range: float = 2.0, - channels: typing.Optional[typing.List[int]] = None, - reference_note: int = 60, - ) -> "PatternBuilder": - - """Apply a microtonal tuning to this pattern via pitch bend injection. - - For each note in the pattern, the nearest 12-TET MIDI pitch is - computed and a pitchwheel ``CcEvent`` is injected at the note's onset - to shift the synthesiser to the exact tuned frequency. Existing pitch - bend events (from ``p.portamento()``, ``p.slide()``, etc.) are shifted - additively so they still work correctly within the tuned pitch space. - - For polyphonic patterns, supply a ``channels`` pool. Notes will be - spread across those channels so each can carry an independent pitch - bend. For monophonic patterns, leave ``channels=None``. - - The synthesiser's pitch-bend range must match ``bend_range``. Most - synths default to ±2 semitones. For tunings that deviate more than - one semitone from 12-TET, increase ``bend_range`` (e.g., 12 or 24) - and configure the synth to match. - - Parameters: - tuning: The :class:`~subsequence.tuning.Tuning` to apply. - bend_range: Synth pitch-bend range in semitones (default ±2). - channels: Channel pool for polyphonic rotation. ``None`` keeps - all notes on the pattern's own channel. - reference_note: MIDI note number that maps to scale degree 0. - Default 60 (middle C). - - Example: - ```python - from subsequence import Tuning - - meantone = Tuning.from_scl("meanquar.scl") - - @composition.pattern(channel=1, beats=4) - def melody (p): - p.seq("x x x x", pitch=60) - p.apply_tuning(meantone, bend_range=2.0) - ``` - """ - import subsequence.tuning - subsequence.tuning.apply_tuning_to_pattern( - self._pattern, - tuning, - bend_range=bend_range, - channels=channels, - reference_note=reference_note, - ) - self._tuning_applied = True - return self - - def reverse (self) -> "PatternBuilder": - - """ - Flip the pattern backwards in time (retrograde). - """ - - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - old_steps = self._pattern.steps - new_steps: typing.Dict[int, subsequence.pattern.Step] = {} - - for position, step in old_steps.items(): - # Reflect around the bar so onsets stay on the grid and the downbeat - # is fixed — a true retrograde reverses the inter-onset intervals - # (e.g. [0, 24] → [0, 72] in a 96-pulse bar, not the off-grid - # [71, 95] the old (total-1)-position produced). - new_position = (total_pulses - position) % total_pulses - - if new_position not in new_steps: - new_steps[new_position] = subsequence.pattern.Step() - - new_steps[new_position].notes.extend(step.notes) - - self._pattern.steps = new_steps - return self - - def stretch (self, factor: float) -> "PatternBuilder": - - """ - Stretch the pattern in time, scaling note positions and durations. - - ``stretch(2.0)`` makes everything twice as long (half speed) — what - theorists call *augmentation*; ``stretch(0.5)`` squeezes the pattern - into half the time (double speed) — *diminution*. Any positive - factor works: ``stretch(2/3)`` compresses a dotted feel into - straight time, for example. - - Notes whose start lands past the end of the pattern are dropped, - and compression leaves the freed space empty — the pattern is not - tiled to fill it. Durations scale without clipping, so a stretched - note may ring past the pattern's end exactly like a legato note, - and ``stretch(1.0)`` is a true no-op. Positions and durations - truncate to the pulse grid (matching ``note()``'s beat-to-pulse - truncation). - - Parameters: - factor: Time multiplier. Greater than 1.0 slows the pattern - down, less than 1.0 speeds it up. Must be positive. - """ - - if factor <= 0: - raise ValueError("Stretch factor must be positive") - - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - old_steps = self._pattern.steps - new_steps: typing.Dict[int, subsequence.pattern.Step] = {} - - for position, step in old_steps.items(): - new_position = int(position * factor) - - if new_position >= total_pulses: - continue - - if new_position not in new_steps: - new_steps[new_position] = subsequence.pattern.Step() - - new_steps[new_position].notes.extend( - dataclasses.replace( - note, - duration = max(1, int(note.duration * factor)), - ) - for note in step.notes - ) - - self._pattern.steps = new_steps - return self - - def rotate (self, steps: int, grid: typing.Optional[int] = None) -> "PatternBuilder": - - """ - Rotate the pattern by a number of grid steps, wrapping around. - - Notes pushed past the end of the pattern re-enter at the start - (and vice versa for negative values) — the step-sequencer rotation - familiar from Euclidean rhythm tools. - - Parameters: - steps: Positive values rotate later in time, negative values earlier. - grid: The grid resolution. Defaults to the pattern's - ``default_grid`` (derived from the decorator's ``beats``/``steps`` - and ``step_duration``). - """ - - if grid is None: - grid = self._default_grid - - if grid <= 0: - return self - - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - pulses_per_step = total_pulses / grid - shift_pulses = int(steps * pulses_per_step) - - old_steps = self._pattern.steps - new_steps: typing.Dict[int, subsequence.pattern.Step] = {} - - for position, step in old_steps.items(): - new_position = (position + shift_pulses) % total_pulses - - if new_position not in new_steps: - new_steps[new_position] = subsequence.pattern.Step() - - new_steps[new_position].notes.extend(step.notes) - - self._pattern.steps = new_steps - return self - - def transpose (self, semitones: int) -> "PatternBuilder": - - """ - Shift all note pitches up or down. - - Parameters: - semitones: Positive for up, negative for down. - """ - - for step in self._pattern.steps.values(): - - for note in step.notes: - note.pitch = max(0, min(127, note.pitch + semitones)) - return self - - def invert (self, pivot: int = 60) -> "PatternBuilder": - - """ - Invert all pitches around a pivot note. - """ - - for step in self._pattern.steps.values(): - - for note in step.notes: - note.pitch = max(0, min(127, pivot + (pivot - note.pitch))) - return self - - def every (self, n: int, fn: typing.Callable[["PatternBuilder"], None]) -> "PatternBuilder": - - """ - Apply a transformation every Nth cycle. - - Parameters: - n: The cycle frequency (e.g., 4 = every 4th bar). - fn: A function (often a lambda) that receives the builder and - calls further methods. - - Example: - ```python - # Reverse every 4th bar - p.every(4, lambda p: p.reverse()) - ``` - """ - - if n < 1: - raise ValueError(f"every() cycle length must be at least 1 bar — got {n} (every(1, ...) applies the change every bar)") - - if self.cycle % n == 0: - fn(self) - return self - - def bar_cycle (self, length: int) -> BarCycle: - - """Return the current bar's position within a repeating cycle of bars. - - A thin wrapper around ``p.bar % length`` that replaces opaque modulo - arithmetic with readable, musician-friendly properties. - - Parameters: - length: The cycle length in bars (e.g., 4, 8, 16). - - Returns: - A :class:`BarCycle` with ``.bar``, ``.first``, ``.last``, - and ``.progress`` properties. - - Example: - ```python - # Every 4 bars (replaces: if p.bar % 4 == 0) - if p.bar_cycle(4).first: - p.hit_steps("snare_1", [0, 8], velocity=110) - - # Last bar of every 16-bar cycle (replaces: if p.bar % 16 == 15) - if p.bar_cycle(16).last: - p.euclidean("hi_hat_open", 3) + def _fit_snap( + self, pitch: int, event_beat: float, fit: float, weights: typing.List[float] + ) -> int: + """The fit dial: snap a strong-beat pitch to the nearest chord tone, with probability *fit*. - # Build intensity over an 8-bar arc - intensity = p.bar_cycle(8).progress # 0.0 → 0.875 - p.velocity_shape(low=int(40 + 40 * intensity), high=100) - ``` - """ + Strong beats are the metric-weight table's >= 0.5 positions + (downbeats and beats); off-grid events take the nearest grid + position's weight. Inactive without a chord context. + """ - if length < 1: - raise ValueError(f"bar_cycle() cycle length must be at least 1 bar — got {length}") + if self.harmony is None: + return pitch - return BarCycle(bar=self.bar % length, length=length) + bar_beats = float(self.time_signature[0]) + grid = len(weights) + step = (event_beat % bar_beats) * grid / bar_beats + weight = weights[int(round(step)) % grid] + + if weight < 0.5: + return pitch + if self.rng.random() >= fit: + return pitch + + chord = self.harmony.chord_at(event_beat) + + if chord is None: + return pitch + + chord_pcs = {tone % 12 for tone in chord.tones(pitch)} + + if pitch % 12 in chord_pcs: + return pitch + + # Nearest chord tone, ties upward. + for delta in (1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6): + if (pitch + delta) % 12 in chord_pcs and 0 <= pitch + delta <= 127: + return pitch + delta + + return pitch + + def _resolve_chord_tone_pitch( + self, tone: "subsequence.motifs.ChordTone", root: int, event_beat: float + ) -> int: + """Resolve a 1-based chord-tone index against the chord under the event. + + Reads the harmony window (``p.harmony.chord_at(event_beat)``): indices + walk the sounding chord's tones nearest ``root``, cycling into higher + octaves past the chord's natural size, plus whole-octave shifts. + """ + + if self.harmony is None: + raise ValueError( + "ChordTone pitches resolve against the harmonic clock — " + "call composition.harmony(...) (a style or a bound progression)" + ) + + chord = self.harmony.chord_at(event_beat) + + if chord is None: + raise ValueError( + f"No chord is known at beat {event_beat:g} of this cycle — " + "the harmony window does not cover it" + ) + + tones = chord.tones(root, count=tone.index) + midi = int(tones[tone.index - 1]) + 12 * tone.octave + + if not 0 <= midi <= 127: + raise ValueError( + f"Chord tone {tone.index} resolves to MIDI {midi}, outside 0–127 — " + "adjust root= or the tone's octaves" + ) + + return midi + + def _resolve_degree_pitch( + self, degree: "subsequence.motifs.Degree", root: int + ) -> int: + """ + Resolve a 1-based scale degree against the composition key + scale. + + The tonic anchors at its nearest instance to ``root`` (ties resolve + upward); the degree then builds from the anchored tonic, so a written + melody keeps its contour. Steps beyond the scale length carry into + higher octaves (8 = tonic an octave up in seven-note scales). + """ + + if self.key is None: + raise ValueError( + "Scale degrees resolve against a key — set Composition(key=...)" + ) + + mode = self.scale or "ionian" + pcs = subsequence.intervals.scale_pitch_classes( + subsequence.chords.key_name_to_pc(self.key), mode + ) + + idx = (degree.step - 1) % len(pcs) + carry = (degree.step - 1) // len(pcs) + + diff = (pcs[0] - root) % 12 + tonic = root + diff if diff <= 6 else root + diff - 12 + offset = (pcs[idx] - pcs[0]) % 12 + + midi = tonic + offset + 12 * (carry + degree.octave) + degree.chroma + + if not 0 <= midi <= 127: + raise ValueError( + f"Degree {degree.step} resolves to MIDI {midi}, outside 0–127 — " + f"adjust root= or the degree's octaves" + ) + + return midi + + def _emit_control( + self, + control: "subsequence.motifs.ControlEvent", + beat: float, + resolution: typing.Optional[int], + ) -> None: + """Emit one stored control gesture through the matching builder verb.""" + + signal = control.signal + onset = beat + control.beat + extra: typing.Dict[str, typing.Any] = ( + {} if resolution is None else {"resolution": resolution} + ) + + if isinstance(signal, subsequence.motifs.CC): + if control.end is None: + self.cc(signal.control, int(round(control.start)), beat=onset) + else: + self.cc_ramp( + signal.control, + int(round(control.start)), + int(round(control.end)), + beat_start=onset, + beat_end=onset + control.span, + shape=control.shape, + **extra, + ) + + elif isinstance(signal, subsequence.motifs.PitchBend): + if control.end is None: + self.pitch_bend(control.start, beat=onset) + else: + self.pitch_bend_ramp( + control.start, + control.end, + beat_start=onset, + beat_end=onset + control.span, + shape=control.shape, + **extra, + ) + + elif isinstance(signal, subsequence.motifs.NRPN): + if control.end is None: + self.nrpn( + signal.parameter, + int(round(control.start)), + beat=onset, + fine=signal.fine, + null_reset=signal.null_reset, + ) + else: + self.nrpn_ramp( + signal.parameter, + int(round(control.start)), + int(round(control.end)), + beat_start=onset, + beat_end=onset + control.span, + shape=control.shape, + fine=signal.fine, + null_reset=signal.null_reset, + **extra, + ) + + elif isinstance(signal, subsequence.motifs.RPN): + if control.end is None: + self.rpn( + signal.parameter, + int(round(control.start)), + beat=onset, + fine=signal.fine, + null_reset=signal.null_reset, + ) + else: + self.rpn_ramp( + signal.parameter, + int(round(control.start)), + int(round(control.end)), + beat_start=onset, + beat_end=onset + control.span, + shape=control.shape, + fine=signal.fine, + null_reset=signal.null_reset, + **extra, + ) + + elif isinstance(signal, subsequence.motifs.OSC): + if control.end is None: + self.osc(signal.address, control.start, beat=onset) + else: + self.osc_ramp( + signal.address, + control.start, + control.end, + beat_start=onset, + beat_end=onset + control.span, + shape=control.shape, + **extra, + ) + + else: + raise TypeError(f"Unknown control signal: {type(signal).__name__}") + + def phrase( + self, + value: typing.Any, + root: int = 60, + velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, + fit: typing.Optional[float] = None, + resolution: typing.Optional[int] = None, + align: str = "pattern", + offset: float = 0.0, + ) -> "PatternBuilder": + """Place this cycle's window of a Phrase — position computed, never stored. + + The playback position is stateless arithmetic over the engine's own + counters: ``pos = (p.cycle * pattern_length + offset) % phrase.length`` + — deterministic under live reload, ``form_jump``, and render, with + zero new state. A pattern shorter than the phrase walks through it + cycle by cycle; deliberately mismatched lengths are phase drift + (polymeter against the phrase). When the cycle window crosses the + phrase's end, the phrase loops. + + Patterns that should own the phrase's length call + ``p.set_length(phrase.length)`` once instead. + + Parameters: + value: A Phrase (or any value with ``.length``/``.slice``; a + Motif places its window directly). + root: Register anchor for degree resolution (see ``motif()``). + velocity: Optional override applied to every note. + fit: Passed through to ``motif()`` (active with the melody + engine stage). + resolution: Control-ramp pulse density (see ``motif()``). + align: ``"pattern"`` (default) counts pattern cycles; + ``"section"`` uses the bar within the current form section, + so the phrase restarts when the section does. + offset: Beats added to the computed position (a phase shift). + + Example: + ```python + @comp.pattern(channel=4, bars=2) + def lead (p): + p.phrase(lead_line, root=72) + ``` + """ + + length = getattr(value, "length", None) + + if length is None or not hasattr(value, "slice"): + raise TypeError( + f"phrase() places Phrase-like values (.length/.slice) — got {type(value).__name__}" + ) + if length <= 0: + raise ValueError("cannot place an empty phrase") + + if align == "pattern": + position = (self.cycle * float(self._pattern.length) + offset) % length + elif align == "section": + if self.section is None: + raise ValueError( + 'phrase(align="section") needs a form — call composition.form(...)' + ) + position = ( + self.section.bar * float(self.time_signature[0]) + offset + ) % length + else: + raise ValueError(f'align must be "pattern" or "section" — got {align!r}') + + window_beats = float(self._pattern.length) + placed = 0.0 + + while placed < window_beats - 1e-9: + take = min(window_beats - placed, length - position) + piece = value.slice(position, position + take) + fragment = piece.flatten() if hasattr(piece, "flatten") else piece + + self.motif( + fragment, + beat=placed, + root=root, + velocity=velocity, + fit=fit, + resolution=resolution, + ) + + placed += take + position = 0.0 # crossed the phrase end — loop to its start + + return self + + def section_motif( + self, part: typing.Optional[str] = None + ) -> typing.Optional[typing.Any]: + """The Motif/Phrase bound to the current section (and part), or ``None``. + + Reads the ``composition.section_motifs()`` registry for the section + currently playing. A section with no binding returns ``None`` — + bind material or rest; no fallback guessing:: + + @comp.pattern(channel=4, bars=2) + def lead (p): + line = p.section_motif("lead") + if line is not None: + p.phrase(line, root=72) + """ + + if self.section is None or self._section_motifs is None: + return None + + return self._section_motifs.get((self.section.name, part)) + + def capture( + self, beat: float = 0.0, span: float = 4.0 + ) -> "subsequence.motifs.Motif": + """ + Read the notes placed so far back out as a :class:`~subsequence.motifs.Motif`. + + The captured motif is **absolute MIDI and lossy by design**: relative + specs (degrees, chord tones) do not survive resolution, timing is + pulse-truncated, probabilities have already rolled, and control + gestures are not captured. The round trip is generate → place → + capture → hand-edit → rebind. + + Parameters: + beat: Window start within the pattern. + span: Window length in beats (also the captured motif's length). + """ + + ppq = subsequence.constants.MIDI_QUARTER_NOTE + lo, hi = int(beat * ppq), int((beat + span) * ppq) + events = [] + + for pulse in sorted(self._pattern.steps): + if not lo <= pulse < hi: + continue + + for placed in self._pattern.steps[pulse].notes: + events.append( + subsequence.motifs.MotifEvent( + beat=pulse / ppq - beat, + pitch=placed.pitch, + velocity=placed.velocity, + duration=max(placed.duration, 1) / ppq, + ) + ) + + return subsequence.motifs.Motif(events=tuple(events), length=span) + + def sequence( + self, + steps: typing.List[int], + pitches: typing.Union[int, str, typing.List[typing.Union[int, str]]], + velocities: typing.Union[ + int, typing.Tuple[int, int], typing.List[int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + durations: typing.Union[float, typing.List[float]] = 0.1, + grid: typing.Optional[int] = None, + probability: float = 1.0, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "PatternBuilder": + """ + A multi-parameter step sequencer. + + Define which grid steps fire, and then provide a list of pitches, + velocities, and durations. If you provide a list for any parameter, + Subsequence will step through it as it places each note. + + Parameters: + steps: List of grid indices to trigger. An empty list is a + no-op — no notes are placed and the builder is returned + unchanged (handy when probabilistic gating rejects every step). + pitches: Pitch or list of pitches. + velocities: Velocity (default 100), ``(low, high)`` tuple for + a fresh random draw per step, or a list of velocities + matched to the steps one-to-one (a short list repeats its + final value, a long list is truncated — both warn). + durations: Duration or list of durations (default 0.1). + grid: Grid resolution. Defaults to the pattern's + ``default_grid`` (derived from the decorator's ``beats``/``steps`` + and ``unit``). + probability: Chance (0.0 to 1.0) that each step will play. + seed: Fix the probability gating for this call (an int); omit to + use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + """ + + if not steps: + return self + + rng = self._rng_from(seed, rng) + + if grid is None: + grid = self._default_grid + + if grid <= 0: + return self + + n = len(steps) + pitches_list = _expand_sequence_param("pitches", pitches, n) + # Treat a (low, high) tuple as a single random-range descriptor + # rather than a 2-element list to cycle through. + if isinstance(velocities, tuple): + velocities_list = [velocities] * n + else: + velocities_list = _expand_sequence_param("velocities", velocities, n) + durations_list = _expand_sequence_param("durations", durations, n) + + step_duration = self._pattern.length / grid + + for i, step_idx in enumerate(steps): + if probability < 1.0 and rng.random() >= probability: + continue + + beat = step_idx * step_duration + self.note( + pitch=pitches_list[i], + beat=beat, + velocity=velocities_list[i], + duration=durations_list[i], + ) + return self + + def seq( + self, + notation: str, + pitch: typing.Union[str, int, None] = None, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "PatternBuilder": + """ + Build a pattern using an expressive string-based 'mini-notation'. + + The notation distributes events evenly across the current pattern length. + + **Syntax:** + + - ``x y z``: Items separated by spaces are distributed across the bar. + - ``[a b]``: Groups items into a single subdivided step. + - ``~`` or ``.``: A rest. + - ``_``: Extends the previous note (sustain). + - ``x?0.6``: Probability suffix — fires with the given probability (0.0–1.0). + + Parameters: + notation: The mini-notation string. + pitch: If provided, all symbols in the string are triggers for + this specific pitch. If ``None``, symbols are interpreted as + pitches (e.g., "60" or "kick"). + velocity: MIDI velocity (default 100), or a ``(low, high)`` + tuple for a fresh random draw per event. + seed: Fix the ``?`` probability gating for this call (an int); + omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # Simple kick rhythm + p.seq("kick . [kick kick] .") + + # Subdivided melody + p.seq("60 [62 64] 67 60") + + # Ghost snare: snare on 2 and 4, ghost note 50% of the time + p.seq(". snare?0.5 . snare") + ``` + """ + + rng = self._rng_from(seed, rng) + + events = subsequence.mini_notation.parse( + notation, total_duration=float(self._pattern.length) + ) + + for event in events: + # Apply probability before placing the note. + if event.probability < 1.0 and rng.random() >= event.probability: + continue + + current_pitch = pitch + + # If no global pitch provided, use the symbol as the pitch + if current_pitch is None: + # Try converting to int if it looks like a number + if event.symbol.isdigit(): + current_pitch = int(event.symbol) + else: + current_pitch = event.symbol + + self.note( + pitch=current_pitch, + beat=event.time, + duration=event.duration, + velocity=velocity, + ) + return self + + def repeat( + self, + pitch: typing.Union[int, str], + spacing: float, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + duration: float = 0.25, + ) -> "PatternBuilder": + """ + Repeat a note at a fixed beat interval for the whole pattern. + + The classic 'Note Repeat' of MPC, Push, and Maschine fame: one + pitch firing at a steady rate — running hi-hats, a pulsing bass + note, a metronome click. + + Parameters: + pitch: MIDI note number or drum name. + spacing: Time between each note in beats (0.25 = sixteenth notes). + velocity: MIDI velocity (default 100), or a ``(low, high)`` + tuple for a fresh random draw per note. + duration: Note duration in beats. + + Example: + ```python + p.repeat("hh", spacing=0.25) # sixteenth notes + p.repeat("hh", spacing=0.25, velocity=(40, 80)) # humanised + ``` + """ + + if spacing <= 0: + raise ValueError("Spacing must be positive") + + beat = 0.0 + + while beat < self._pattern.length: + self.note(pitch=pitch, beat=beat, velocity=velocity, duration=duration) + beat += spacing + return self + + def arpeggio( + self, + notes: typing.Any, + root: typing.Optional[int] = None, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_VELOCITY, + count: typing.Optional[int] = None, + inversion: int = 0, + beat: float = 0.0, + span: typing.Optional[float] = None, + spacing: float = 0.25, + duration: typing.Optional[float] = None, + direction: str = "up", + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "PatternBuilder": + """ + Arpeggiate a chord (or a list of pitches) — cycle the notes one at a time + at regular beat intervals. + + Like ``chord()`` and ``strum()``, the first argument can be a chord — the + ``chord`` passed to your pattern function, or any chord from + ``p.progression()`` — and ``root`` / ``count`` / ``inversion`` voice it + exactly as they do. So "play this as a chord, a strum, or an arpeggio" is a + one-word verb swap:: + + for chord, start, length in p.progression("phrygian_minor", harmonic_rhythm=...): + p.arpeggio(chord, root=48, beat=start, span=length, spacing=0.25, count=4) + + Pass a list of pitches instead to arpeggiate something that isn't a chord (a + scale fragment, a custom voicing). Unlike a held ``chord()``, an arpeggio is + a stream of single notes, so it has no ``sustain`` / ``legato`` / ``detached`` + — use ``duration`` for how long each note rings and ``span`` for how much of + the bar the figure fills. + + An empty pitch list rests (places nothing), so a live arpeggiator over + ``p.held_notes()`` is simply silent when no keys are held:: + + p.arpeggio(p.held_notes(), direction="up") + + Parameters: + notes: A chord to arpeggiate (anything with a ``.tones()`` method — the + pattern's ``chord``, or a chord from ``p.progression()``), or a list + of MIDI note numbers (e.g. ``60``) / drum-name strings when the + pattern has a ``drum_note_map``. For pitched note *names* use the + integer constants in ``subsequence.constants.midi_notes`` (e.g. + ``notes.C4``). In the list form, a drum name the map lacks is + dropped (warned once); a string with no map at all still raises. + root: MIDI root note for the chord form (e.g. 48), exactly as ``chord()``. + Required for a chord; not used for a plain pitch list. + velocity: MIDI velocity for all notes (default 100 — arpeggios sit in the + melodic-line velocity bucket, not the softened-chord bucket; pass + ``velocity=90`` to match ``chord()``), or a ``(low, high)`` tuple for + a fresh random draw per note. + count: Number of voices for the chord form (cycles tones into higher + octaves if larger than the chord's natural size). Chord form only. + inversion: Chord inversion for the chord form (ignored when voice leading + is on). Chord form only. + beat: Beat to start the figure at (default 0.0 = the start of the + pattern). Use it to place an arpeggio over one progression chord. + span: How many beats the figure fills, starting at ``beat`` (default: to + the end of the pattern). Pass the chord's ``length`` from a + progression loop to confine the arpeggio to its slot. + spacing: Time between each note in beats (default 0.25 = 16th note). + duration: Note duration in beats. Defaults to ``spacing`` (each note + fills its slot exactly). + direction: Order in which the notes are cycled: + + - ``"up"`` — lowest to highest, then wrap (default). + - ``"down"`` — highest to lowest, then wrap. + - ``"up_down"`` — ascend then descend (ping-pong), cycling. + - ``"random"`` — shuffled once per call using *rng*. + + seed: Fix the ``direction="random"`` shuffle for this call (an + int); omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + # Arpeggiate the pattern's current chord, four voices ascending + p.arpeggio(chord, root=60, count=4, spacing=0.25) + + # A plain list of pitches — ping-pong: C E G E C E G E ... + p.arpeggio([60, 64, 67], spacing=0.25, direction="up_down") + + # One chord of a progression, confined to its slot, humanised + p.arpeggio(chord, root=48, beat=start, span=length, velocity=(60, 95)) + ``` + """ + + if beat < 0: + raise ValueError( + "arpeggio beat must be >= 0 — use a positive start within the pattern" + ) + + if spacing <= 0: + raise ValueError("Spacing must be positive") + + # Resolve the first argument into a concrete pitch list. A chord-like object + # (it has .tones()) is voiced via root/count/inversion exactly as chord() does; + # anything else is treated as an explicit list of pitches (today's behaviour). + resolved: typing.List[int] + + if hasattr(notes, "tones"): + if root is None: + raise ValueError( + "arpeggio(, …) needs a root — e.g. arpeggio(chord, root=48); pass a root MIDI note, or hand a list of pitches instead" + ) + resolved = notes.tones(root=root, inversion=inversion, count=count) + else: + if root is not None or count is not None or inversion != 0: + raise ValueError( + "arpeggio root=, count=, and inversion= only apply to the chord form — arpeggio(chord, root=48, count=4); with a plain pitch list, drop them" + ) + if not notes: + return self # nothing held (e.g. p.arpeggio(p.held_notes()) with no keys down) — rest + resolved = [ + r + for r in (self._resolve_pitch_lenient(p) for p in notes) + if r is not None + ] + if not resolved: + return ( + self # every named voice was dropped (this device lacks them all) + ) + + if direction == "up": + pass # already in ascending order as supplied + elif direction == "down": + resolved = list(reversed(resolved)) + elif direction == "up_down": + if len(resolved) > 1: + resolved = resolved + list(reversed(resolved[1:-1])) + elif direction == "random": + rng = self._rng_from(seed, rng) + resolved = list(resolved) + rng.shuffle(resolved) + else: + raise ValueError( + f"direction must be 'up', 'down', 'up_down', or 'random', got '{direction}'" + ) + + if duration is None: + duration = spacing + + # Window the figure to [beat, beat + span), clamped to the pattern end so a + # positioned arpeggio (e.g. one chord of a progression) stays in its slot. + pattern_length = float(self._pattern.length) + + if span is None: + end = pattern_length + else: + if span <= 0: + raise ValueError(f"span must be positive, got {span:g}") + end = beat + span + + end = min(end, pattern_length) + + # Place notes one at a time via self.note() so a (low, high) + # velocity tuple produces a fresh random draw per arp note. + position = beat + i = 0 + while position < end: + self.note( + pitch=resolved[i % len(resolved)], + beat=position, + velocity=velocity, + duration=duration, + ) + position += spacing + i += 1 + return self + + def _warn_positioned_articulation(self, method: str, beat: float) -> None: + """Warn (once per pattern) that ``sustain``/``detached`` ring from the pattern + length, not from ``beat``. + + ``chord``/``strum`` size ``sustain``/``detached`` against the whole pattern (the + one-chord-fills-the-bar model). With a non-zero ``beat`` — e.g. placing several + chords across a progression — that almost always rings the chord far past its + slot, so we flag it. Deduped on the pattern so a hot-reloading builder warns once. + """ + + if self._pattern._warned_positioned_articulation: + return + self._pattern._warned_positioned_articulation = True + logger.warning( + "%s(beat=%g, …) was called with sustain= or detached= set — those size the ring " + "from the pattern length, not from beat, so the chord can sustain past its slot. " + "For a positioned chord (e.g. over a progression) set duration= explicitly instead.", + method, + beat, + ) + + def chord( + self, + chord_obj: typing.Any, + root: int, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, + sustain: bool = False, + duration: float = 1.0, + inversion: int = 0, + count: typing.Optional[int] = None, + legato: typing.Optional[float] = None, + detached: typing.Optional[float] = None, + beat: float = 0.0, + ) -> "PatternBuilder": + """ + Place a chord at ``beat`` (the start of the pattern by default). + + Note: If the pattern was registered with ``voice_leading=True``, + this method automatically chooses the best inversion. + + Parameters: + chord_obj: The chord to play (usually the ``chord`` parameter + passed to your pattern function). + root: MIDI root note (e.g., 60 for Middle C). + velocity: MIDI velocity (default 90), or a ``(low, high)`` + tuple for a fresh random draw per chord tone (each + voice gets a slightly different velocity — useful for + humanising the "fingers" feel). + sustain: If True, the notes last for the entire pattern duration. + Mutually exclusive with ``legato`` and ``detached``. + duration: Note duration in beats (default 1.0). Ignored when + ``legato`` or ``detached`` is set, since those recalculate + durations. + inversion: Specific chord inversion (ignored if voice leading is on). + count: Number of notes to play (cycles tones if higher than + the chord's natural size). + legato: If given, calls ``p.legato(ratio)`` after placing the + chord, stretching each note to fill ``ratio`` of the gap to + the next note. Mutually exclusive with ``sustain`` and + ``detached``. + detached: If given, the chord rings until ``detached`` beats + before the next cycle — equivalent to setting + ``duration = pattern.length - detached``. Use this for a + declarative polyphony-safety margin so the chord always + releases before the next chord begins. Mutually exclusive + with ``sustain`` and ``legato``. + beat: Beat offset to place the chord at (default 0.0 = the start of the + pattern). ``sustain`` and ``detached`` still measure their ring from the + pattern length, not from ``beat`` — when placing several positioned chords + (e.g. over a progression) set ``duration`` explicitly instead. + + Example:: + + # Shorthand for: p.chord(...) then p.legato(0.9) + p.chord(chord, root=root, velocity=85, count=4, legato=0.9) + + # Hold the chord almost the full cycle, releasing 0.25 beats + # before the next chord begins. + p.chord(chord, root=root, velocity=85, count=5, detached=0.25) + """ + + set_count = ( + (1 if sustain else 0) + + (1 if legato is not None else 0) + + (1 if detached is not None else 0) + ) + if set_count > 1: + raise ValueError( + "sustain=, legato=, and detached= are mutually exclusive — use one or the other" + ) + + if beat != 0.0 and (sustain or detached is not None): + self._warn_positioned_articulation("chord", beat) + + pitches = chord_obj.tones(root=root, inversion=inversion, count=count) + + if sustain: + duration = float(self._pattern.length) + elif detached is not None: + duration = float(self._pattern.length) - detached + if duration <= 0: + raise ValueError( + f"detached ({detached}) must be less than the pattern length ({self._pattern.length:g} beats) so the chord keeps a positive duration" + ) + + for pitch in pitches: + self._pattern.add_note_beats( + beat_position=beat, + pitch=pitch, + velocity=self._resolve_velocity(velocity), + duration_beats=duration, + ) + + if legato is not None: + self.legato(legato) + return self + + def strum( + self, + chord_obj: typing.Any, + root: int, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, + sustain: bool = False, + duration: float = 1.0, + inversion: int = 0, + count: typing.Optional[int] = None, + spacing: float = 0.05, + direction: str = "up", + legato: typing.Optional[float] = None, + detached: typing.Optional[float] = None, + beat: float = 0.0, + ) -> "PatternBuilder": + """ + Play a chord with a small time offset between each note (strum effect). + + Works exactly like ``chord()`` but staggers the notes instead of + playing them simultaneously. The first note lands on ``beat`` (0 by default); + subsequent notes are delayed by ``spacing`` beats each. + + Parameters: + chord_obj: The chord to play (usually the ``chord`` parameter + passed to your pattern function). + root: MIDI root note (e.g., 60 for Middle C). + velocity: MIDI velocity (default 90), or a ``(low, high)`` + tuple for a fresh random draw per strum note. + sustain: If True, the notes last for the entire pattern duration. + Mutually exclusive with ``legato`` and ``detached``. + duration: Note duration in beats (default 1.0). Ignored when + ``legato`` or ``detached`` is set, since those recalculate + durations. + inversion: Specific chord inversion (ignored if voice leading is on). + count: Number of notes to play (cycles tones if higher than + the chord's natural size). + spacing: Time in beats between each note onset (default 0.05). + direction: ``"up"`` for low-to-high, ``"down"`` for high-to-low. + beat: Beat offset for the first note (default 0.0); the stagger is added + on top. ``sustain``/``detached`` ring from the pattern length, not from + ``beat`` — set ``duration`` explicitly when placing positioned strums. + legato: If given, calls ``p.legato(ratio)`` after placing the + chord, stretching each note to fill ``ratio`` of the gap to + the next note. Mutually exclusive with ``sustain`` and + ``detached``. + detached: If given, every strum note rings with a uniform + duration of ``pattern.length - detached - (count - 1) * spacing``. + The last note ends exactly ``detached`` beats before the + next cycle; earlier notes end proportionally sooner, so + releases are staggered in the same shape as the placements + (the hand lifts the way it landed). Polyphony-safe: + guarantees nothing from this strum is still sounding when + the next chord begins. Mutually exclusive with ``sustain`` + and ``legato``. + + Example:: + + # Gentle upward strum with legato + p.strum(chord, root=52, velocity=85, spacing=0.06, legato=0.95) + + # Fast downward strum + p.strum(chord, root=52, direction="down", spacing=0.03) + + # Five-voice strum with a 0.25-beat safety gap before the + # next chord — won't exhaust polyphony on a 5-voice synth. + p.strum(chord, root=48, count=5, spacing=0.1, detached=0.25) + """ + + set_count = ( + (1 if sustain else 0) + + (1 if legato is not None else 0) + + (1 if detached is not None else 0) + ) + if set_count > 1: + raise ValueError( + "sustain=, legato=, and detached= are mutually exclusive — use one or the other" + ) + + if beat != 0.0 and (sustain or detached is not None): + self._warn_positioned_articulation("strum", beat) + + if spacing <= 0: + raise ValueError("spacing must be positive") + + if direction not in ("up", "down"): + raise ValueError(f"direction must be 'up' or 'down', got '{direction}'") + + pitches = chord_obj.tones(root=root, inversion=inversion, count=count) + + if direction == "down": + pitches = list(reversed(pitches)) + + if sustain: + duration = float(self._pattern.length) + elif detached is not None: + duration = ( + float(self._pattern.length) - detached - (len(pitches) - 1) * spacing + ) + if duration <= 0: + raise ValueError( + f"detached ({detached}) plus the strum stagger exceeds the pattern length ({self._pattern.length:g} beats) — reduce detached, spacing, or count" + ) + + for i, pitch in enumerate(pitches): + self.note( + pitch=pitch, + beat=beat + i * spacing, + velocity=velocity, + duration=duration, + ) + + if legato is not None: + self.legato(legato) + return self + + def progression( + self, + source: subsequence.progressions.ProgressionSource, + harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, + key: typing.Optional[str] = None, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> subsequence.progressions.Progression: + """Realise a chord progression across the pattern, returning it to place yourself. + + Returns a freshly realised :class:`~subsequence.progressions.Progression` + — an iterable of ``(chord, start, length)`` events laying a progression + end-to-end across the pattern's length, each chord given a length drawn + from *harmonic_rhythm* (the musical term for how often the chords + change). You loop over it and play each chord however you like — + block, strummed, or arpeggiated:: + + for chord, start, length in p.progression("phrygian_minor", + harmonic_rhythm=between(WHOLE, 3 * WHOLE, step=WHOLE), seed=7): + p.strum(chord, root=48, beat=start, duration=length - 0.25, spacing=0.04, count=4) + + This is the **part-level** progression seam: it re-realises a fresh + value each rebuild (the breathing behaviour), runs entirely outside + the global harmonic clock — so a part can inhabit its own harmonic + world (polytonality) or move faster than the clock's span floor — + and never advances engine state. + + For a one-call block-chord part with no loop, use ``composition.chords()``. + + Parameters: + source: A built-in chord-graph style name (e.g. ``"phrygian_minor"``) to + *generate* a progression; an explicit element list — ints where + diatonic, name or roman strings (``["Cm7", 6, "bVII"]``), ``Chord`` + objects — cycled to fill the pattern; or a + :class:`~subsequence.progressions.Progression` value (its spans + cycled, decoration preserved). + harmonic_rhythm: How long each chord lasts, in beats. One of: a single + number (static); a list of lengths (a shaped rhythm such as + ``[WHOLE, HALF, HALF]``, cycled per chord); or ``between(low, high, + step=...)`` for a bounded, optionally-quantised random length. + key: Key for styles and key-relative elements (degrees/romans); + defaults to the composition's key. + seed: If given, the progression is realised from a fresh ``Random(seed)`` + so it is identical on every cycle (a fixed phrase). When omitted, the + pattern's own RNG is used, so it can vary per cycle (still reproducible + under a composition seed). + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Returns: + A ``Progression`` you can iterate as ``(chord, start, length)`` tuples + (or read via ``.events()`` / ``print()``). + """ + + rng = self._rng_from(seed, rng) + resolved_key = key if key is not None else self.key + return subsequence.progressions.realize( + source=source, + harmonic_rhythm=harmonic_rhythm, + key=resolved_key, + length=float(self._pattern.length), + rng=rng, + scale=self.scale or "ionian", + ) + + def broken_chord( + self, + chord_obj: typing.Any, + root: int, + order: typing.List[int], + spacing: float = 0.25, + velocity: typing.Union[ + int, typing.Tuple[int, int] + ] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, + duration: typing.Optional[float] = None, + inversion: int = 0, + beat: float = 0.0, + span: typing.Optional[float] = None, + ) -> "PatternBuilder": + """ + Play a chord as an arpeggio in a specific or random order. + + This generates the chord tones and maps them according to the provided + ``order`` list of indices, then delegates to ``arpeggio()``. It is ideal + for broken chords or random chord-tone melodies. + + Because the order is a list of node indices, the number of generated tones + is automatically set to ``max(order) + 1`` to ensure all indices are valid. + Higher indices will cycle into the next octave. + + Parameters: + chord_obj: The chord to play (usually from ``p.section.chord``). + root: MIDI root note (e.g., 60 for Middle C). + order: List of indices into the chord tones array, dictating playback order. + spacing: Time between each note in beats (default 0.25 = 16th note). + velocity: MIDI velocity for all notes (default 90 — broken_chord is a + chord voice, so it sits in the softer chord velocity bucket like + ``chord()`` and ``strum()``), or a ``(low, high)`` tuple for a + fresh random draw per note. + duration: Note duration in beats. Defaults to ``spacing``. + inversion: Specific chord inversion (ignored if voice leading is on). + beat: Beat to start the broken chord at (default 0.0). + span: How many beats to fill from ``beat`` (default: to the end of the + pattern). Like ``arpeggio()``, use it to place a broken chord over + one chord of a progression. + + Example:: + + # A 5-note broken chord using a predefined pattern + p.broken_chord(chord, root=60, order=[4, 0, 2, 1, 3], spacing=0.25) + + # A fully random broken chord using the pattern's deterministic RNG + order = list(range(5)) + p.rng.shuffle(order) + p.broken_chord(chord, root=60, order=order) + """ + + if not order: + raise ValueError("order list cannot be empty") + + for idx in order: + if not isinstance(idx, int) or idx < 0: + raise ValueError("order must contain only non-negative integers") + + required_count = max(order) + 1 + tones = chord_obj.tones(root=root, inversion=inversion, count=required_count) + pitches = [tones[i] for i in order] + + self.arpeggio( + notes=pitches, + spacing=spacing, + velocity=velocity, + duration=duration, + direction="up", + beat=beat, + span=span, + ) + return self + + def swing( + self, percent: float = 57.0, grid: float = 0.25, strength: float = 1.0 + ) -> "PatternBuilder": + """ + Apply swing feel to all notes in the pattern. + + A shortcut for ``p.groove(Groove.swing(percent, grid), strength)``. Swing is a + groove where every other grid note is delayed - the simplest way to + give a mechanical pattern a pushed, human feel. + + 50% is perfectly straight (no swing). 57% is the Ableton default + (a gentle shuffle). 67% is classic triplet swing. + + Parameters: + percent: Swing amount as a percentage (50-75 is the useful range). + 50 = straight, 57 = moderate shuffle, 67 ≈ triplet swing. + grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes). + strength: How much swing to apply (0.0-1.0). 0.0 = no effect, + 1.0 = full swing at the given percent. Useful for dialling + back the feel without changing the swing percentage. + + Example:: + + p.hit_steps("hh", range(16), velocity=80) + p.swing(57) # gentle 16th-note shuffle + p.swing(57, strength=0.5) # half-strength — subtler feel + """ + + self.groove( + subsequence.groove.Groove.swing(percent=percent, grid=grid), + strength=strength, + ) + return self + + def groove( + self, template: subsequence.groove.Groove, strength: float = 1.0 + ) -> "PatternBuilder": + """ + Apply a groove template to all notes in the pattern. + + A groove is a repeating pattern of per-step timing offsets and + optional velocity adjustments. It gives a pattern its characteristic + rhythmic feel - swing, shuffle, MPC pocket, or any custom shape. + + Construct a groove with one of the factory methods: + + - ``Groove.swing(percent)`` - simple swing by percentage + (or use the ``p.swing()`` shortcut for common cases) + - ``Groove.from_agr(path)`` - import timing from an Ableton .agr file + - ``Groove(offsets=[...], grid=0.25, velocities=[...])`` - fully custom + + ``p.groove()`` is a post-build transform - call it after all notes + have been placed. It pairs well with ``p.randomize()`` for + structured feel plus organic micro-variation. + + Parameters: + template: A ``Groove`` instance defining the timing/velocity template. + strength: How much of the groove to apply (0.0-1.0). 0.0 = no + effect, 1.0 = full groove. Blends timing offsets and velocity + deviation proportionally - equivalent to Ableton's + TimingAmount and VelocityAmount dials. + + Example:: + + groove = subsequence.Groove.swing(percent=57) + + @composition.pattern(channel=10, beats=4) + def drums (p): + p.hit_steps("kick", [0, 8], velocity=100) + p.hit_steps("hh", range(16), velocity=80) + p.groove(groove) # full strength + p.groove(groove, strength=0.5) # half-strength blend + """ + + self._pattern.steps = subsequence.groove.apply_groove( + self._pattern.steps, template, strength=strength + ) + return self + + # These methods transform existing notes after they have been placed. + # Call them at the end of your builder function, after all notes are + # in position. They operate on self._pattern.steps (the pulse-position + # dict) and can be chained in any order. + + def dropout( + self, + probability: float, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "PatternBuilder": + """ + Randomly remove notes from the pattern. + + This operates on all notes currently placed in the builder. + + Parameters: + probability: The chance (0.0 to 1.0) of each pulse POSITION being + removed — all notes sharing that position (a chord's voices, + layered drums) live or die together. + seed: Fix the dropout for this call (an int); omit to use the + pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + """ + + rng = self._rng_from(seed, rng) + + positions_to_remove = [] + + for position in list(self._pattern.steps.keys()): + if rng.random() < probability: + positions_to_remove.append(position) + + for position in positions_to_remove: + del self._pattern.steps[position] + return self + + def velocity_shape( + self, + low: int = subsequence.constants.velocity.VELOCITY_SHAPE_LOW, + high: int = subsequence.constants.velocity.VELOCITY_SHAPE_HIGH, + ) -> "PatternBuilder": + """ + Apply organic velocity variation to all notes in the pattern. + + Uses a van der Corput sequence to distribute velocities evenly + across the specified range, which often sounds more 'human' than + purely random velocity variation. + + Parameters: + low: Minimum velocity (default 64). + high: Maximum velocity (default 127). + """ + + positions = sorted(self._pattern.steps.keys()) + + if not positions: + return self + + vdc_values = subsequence.sequence_utils.generate_van_der_corput_sequence( + len(positions) + ) + + for position, vdc_value in zip(positions, vdc_values): + step = self._pattern.steps[position] + + for note in step.notes: + note.velocity = int(low + (high - low) * vdc_value) + return self + + def duck_map( + self, + steps: typing.Iterable[int], + floor: float = 0.0, + grid: typing.Optional[int] = None, + ) -> typing.List[float]: + """ + Build a per-step velocity multiplier list for sidechain-style ducking. + + Returns a list of floats, one per grid step: ``floor`` at each trigger + step in ``steps``, ``1.0`` everywhere else. Pass the result to + ``p.data`` for another pattern to read, then apply with + ``p.scale_velocities()``. + + Parameters: + steps: Grid indices that trigger ducking (e.g. kick hit positions). + floor: Multiplier written at trigger steps. ``0.0`` = full silence, + ``1.0`` = no effect. Values in between give partial ducking. + grid: Grid resolution (defaults to ``p.grid``). + + Returns: + ``List[float]`` of length ``grid``. + + Example:: + + # Full duck on kick hits + p.data["kick_sc"] = p.duck_map(kick_steps) + + # Softer duck + p.data["kick_sc"] = p.duck_map(kick_steps, floor=0.3) + + # Velocity-proportional: deeper duck for harder kicks + p.data["kick_sc"] = p.duck_map(kick_steps, floor=1.0 - (velocity / 127)) + """ + + if grid is None: + grid = self._default_grid + + trigger = set(steps) + return [floor if s in trigger else 1.0 for s in range(grid)] + + def build_velocity_ramp( + self, + low: int, + high: int, + shape: str = "linear", + grid: typing.Optional[int] = None, + ) -> typing.List[int]: + """ + Build a per-step velocity list that ramps from *low* to *high*. + + A musician-friendly shortcut for the common pattern of generating + a fixed-length velocity sweep using an easing curve. Returns + ``List[int]`` ready to pass directly to ``velocities=`` parameters. + + Parameters: + low: Velocity at the first step (0–127). + high: Velocity at the last step (0–127). + shape: Easing curve name (see ``subsequence.easing``). Common + values: ``"linear"``, ``"ease_in"``, ``"ease_out"``, + ``"ease_in_out"``. Defaults to ``"linear"``. + grid: Number of steps (defaults to ``p.grid``). + + Returns: + ``List[int]`` of length ``grid``, values clamped to 0–127. + + Example:: + + # Snare roll that swells into a downbeat + p.sequence( + steps=range(16), + pitches="snare_1", + durations=0.1, + velocities=p.build_velocity_ramp(25, 100, "ease_in"), + ) + + # Fade-out ghost fill + p.ghost_fill("snare_1", 1, + velocity=p.build_velocity_ramp(80, 20, "ease_out"), + bias="sixteenths", no_overlap=True) + """ + + if grid is None: + grid = self._default_grid + + return [ + max(0, min(127, int(v))) + for v in subsequence.easing.ramp(grid, float(low), float(high), shape) + ] + + def scale_velocities( + self, + factors: typing.Sequence[float], + grid: typing.Optional[int] = None, + ) -> "PatternBuilder": + """ + Scale note velocities by a per-step multiplier list. + + Each note's velocity is multiplied by the factor at the corresponding + grid step index. A factor of ``1.0`` leaves the velocity unchanged; + ``0.0`` silences the note; ``0.5`` halves it. + + Parameters: + factors: Per-step multipliers, one float per grid step. + Values outside ``[0.0, 1.0]`` are valid — result is clamped to + ``[0, 127]`` after scaling. + grid: Grid resolution (defaults to ``p.grid``). Must match the + length of ``factors``. + + Returns: + ``self`` for fluent chaining. + + Example:: + + # Sidechain ducking: silence bass on kick steps, full volume elsewhere. + kick_steps = {0, 4, 8, 12} + p.data["kick_sc"] = [0.0 if s in kick_steps else 1.0 for s in range(p.grid)] + + # In the bass pattern: + p.scale_velocities(p.data.get("kick_sc", [1.0] * p.grid)) + """ + + if grid is None: + grid = self._default_grid + + if grid <= 0: + return self + + step_duration = self._pattern.length / grid + pulses_per_step = step_duration * subsequence.constants.MIDI_QUARTER_NOTE + + for pulse, step in self._pattern.steps.items(): + # A note in the last half-step rounds up to idx == grid, which is + # really the wrap back to step 0 of the next cycle (patterns are + # cyclic) — wrap it so a note pushed late by swing/randomize scales + # by factors[0] rather than silently keeping its velocity. + idx = int(round(pulse / pulses_per_step)) % grid + + if 0 <= idx < len(factors): + for note in step.notes: + note.velocity = max(0, min(127, int(note.velocity * factors[idx]))) + + return self + + def randomize( + self, + timing: float = 0.03, + velocity: float = 0.0, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "PatternBuilder": + """ + Add random variations to note timing and velocity. + + Introduces small imperfections — the micro-variations that distinguish + a played performance from a perfectly quantized sequence. + + Called with no arguments, only timing variation is applied + (velocity defaults to 0.0 — no change). Pass a velocity value + to also randomise dynamics: + + # Timing only (default) + p.randomize() + + # Both axes + p.randomize(timing=0.04, velocity=0.08) + + # Stronger feel + p.randomize(timing=0.08, velocity=0.15) + + Resolution note: the sequencer runs at 24 PPQN. At 120 BPM, one + pulse ≈ 20ms. Timing shifts smaller than roughly 0.04 beats may + have no audible effect because they round to zero pulses. + Recommended range: timing=0.02–0.08, velocity=0.05–0.15. + + When the composition has a seed set, ``p.rng`` is deterministic, + so ``p.randomize()`` produces the same result on every run. + + Parameters: + timing: Maximum timing offset in beats (e.g. 0.05 = ±1.2 + pulses at 24 PPQN). Notes shift by a random amount + within ``[-timing, +timing]`` beats. Clamped to + pulse 0 at the lower bound. + velocity: Maximum velocity scale factor (0.0 to 1.0). Each + note's velocity is multiplied by a random value in + ``[1 - velocity, 1 + velocity]``, clamped to 1–127. + seed: Fix the variations for this call (an int); omit to use the + pattern's RNG (seeded when the composition has a seed). + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + """ + + rng = self._rng_from(seed, rng) + + max_timing_pulses = timing * subsequence.constants.MIDI_QUARTER_NOTE + new_steps: typing.Dict[int, subsequence.pattern.Step] = {} + + for pulse, step in self._pattern.steps.items(): + if timing != 0.0: + offset = rng.uniform(-max_timing_pulses, max_timing_pulses) + new_pulse = max(0, int(round(pulse + offset))) + else: + new_pulse = pulse + + if new_pulse not in new_steps: + new_steps[new_pulse] = subsequence.pattern.Step() + + # Process notes: randomise velocity once per note, then place in new bucket. + for note in step.notes: + if velocity != 0.0: + scale = rng.uniform(1.0 - velocity, 1.0 + velocity) + note.velocity = max(1, min(127, int(round(note.velocity * scale)))) + + new_steps[new_pulse].notes.append(note) + + self._pattern.steps = new_steps + return self + + def legato(self, ratio: float = 1.0) -> "PatternBuilder": + """ + Adjust note durations to fill the gap until the next note. + + Parameters: + ratio: How much of the gap to fill (0.0 to 1.0). + 1.0 is full legato, < 1.0 is staccato. + """ + + if not self._pattern.steps: + return self + + sorted_positions = sorted(self._pattern.steps.keys()) + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + + for i, position in enumerate(sorted_positions): + # Calculate gap to next note + if i < len(sorted_positions) - 1: + gap = sorted_positions[i + 1] - position + else: + # Wrap around: gap is distance to end + distance to first note + gap = (total_pulses - position) + sorted_positions[0] + + # Apply ratio and enforce minimum duration + new_duration = max(1, int(gap * ratio)) + + step = self._pattern.steps[position] + for note in step.notes: + note.duration = new_duration + return self + + def duration(self, beats: float) -> "PatternBuilder": + """ + Set every note's duration to a fixed length in beats. + + This overrides any existing note durations, acting as a global + 'gate time' relative to the beat (1.0 = a quarter note). Short + values clip notes tight; long values let them ring. For a + guaranteed gap before each next onset regardless of note spacing, + use :meth:`detached`; for a classic staccato articulation, either + a short fixed value (``p.duration(0.1)``) or ``p.detached()`` works. + + Parameters: + beats: Fixed note duration in beats (relative to a quarter note). + 0.5 = eighth-note length, 0.25 = sixteenth-note length. Must be positive. + """ + + if beats <= 0: + raise ValueError("Note duration (beats) must be positive") + + duration_pulses = int(beats * subsequence.constants.MIDI_QUARTER_NOTE) + duration_pulses = max(1, duration_pulses) + + for step in self._pattern.steps.values(): + for note in step.notes: + note.duration = duration_pulses + return self + + def detached(self, beats: float = 0.05) -> "PatternBuilder": + """ + Shorten note durations so a guaranteed silence precedes the next onset. + + The complement of :meth:`legato`. For every placed note, the duration + is shrunk so that at least ``beats`` beats of silence remain before + the next note begins (wrapping around to the first note for the last + one). Use this when you want a clean detached articulation, or as a + polyphony-safety margin between chord transitions on a monophonic or + voice-limited synth. + + Parameters: + beats: Minimum gap in beats before the next onset (default 0.05 + — roughly 25 ms at 120 BPM). Must be positive. + + Example:: + + # Bassline on a mono synth: each 16th note ends 0.05 beats + # before the next, so the synth never retriggers mid-note. + p.arpeggio(chord.tones(36, count=4), spacing=0.25).detached() + + # Explicit larger gap for a longer release tail. + p.melody(state, spacing=0.25).detached(0.1) + """ + + if beats <= 0: + raise ValueError("detached beats must be positive") + + if not self._pattern.steps: + return self + + sorted_positions = sorted(self._pattern.steps.keys()) + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + detached_pulses = int(beats * subsequence.constants.MIDI_QUARTER_NOTE) + + for i, position in enumerate(sorted_positions): + # Calculate gap to next note (wrap-around for the last one) + if i < len(sorted_positions) - 1: + gap = sorted_positions[i + 1] - position + else: + gap = (total_pulses - position) + sorted_positions[0] + + new_duration = max(1, gap - detached_pulses) + + for note in self._pattern.steps[position].notes: + note.duration = new_duration + return self + + def snap_to_scale( + self, + key: str, + mode: str = "ionian", + strength: float = 1.0, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + ) -> "PatternBuilder": + """ + Snap all notes in the pattern to the nearest pitch in a scale. + + Useful after generative or sensor-driven pitch work (random walks, + mapping data values to note numbers, etc.) to ensure every note lands + on a musically valid scale degree. The snap is applied in + place; notes already on a scale degree are left unchanged. + + When a note falls equidistant between two scale tones, the upward + direction is preferred. + + Parameters: + key: Root note name (e.g. ``"C"``, ``"F#"``, ``"Bb"``). + mode: Scale mode. Any key in :data:`subsequence.intervals.DIATONIC_MODE_MAP` + is accepted: ``"ionian"`` (default), ``"dorian"``, ``"minor"``, + ``"harmonic_minor"``, etc. + strength: Probability that each note is snapped (0.0–1.0). + At 1.0 (default), every note snaps to the scale. + At 0.0, no notes are affected. + Values in between create melodies that are mostly in key + with occasional chromatic passing tones. Uses the + pattern's seeded RNG for reproducibility. + seed: Fix the partial-strength snapping for this call (an int); + omit to use the pattern's RNG. + rng: Advanced determinism form — a ``random.Random`` (wins over ``seed=``). + + Example: + ```python + @composition.pattern(channel=1, beats=4) + def melody (p): + for beat in range(16): + pitch = 60 + random.randint(-5, 5) + p.note(pitch, beat=beat * 0.25) + p.snap_to_scale("G", "dorian", strength=0.8) + ``` + """ + + rng = self._rng_from(seed, rng) + + key_pc = subsequence.chords.key_name_to_pc(key) + scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) + + for step in self._pattern.steps.values(): + for note in step.notes: + if strength >= 1.0 or rng.random() < strength: + note.pitch = subsequence.intervals.quantize_pitch( + note.pitch, scale_pcs + ) + return self + + def apply_tuning( + self, + tuning: "subsequence.tuning.Tuning", + bend_range: float = 2.0, + channels: typing.Optional[typing.List[int]] = None, + reference_note: int = 60, + ) -> "PatternBuilder": + """Apply a microtonal tuning to this pattern via pitch bend injection. + + For each note in the pattern, the nearest 12-TET MIDI pitch is + computed and a pitchwheel ``CcEvent`` is injected at the note's onset + to shift the synthesiser to the exact tuned frequency. Existing pitch + bend events (from ``p.portamento()``, ``p.slide()``, etc.) are shifted + additively so they still work correctly within the tuned pitch space. + + For polyphonic patterns, supply a ``channels`` pool. Notes will be + spread across those channels so each can carry an independent pitch + bend. For monophonic patterns, leave ``channels=None``. + + The synthesiser's pitch-bend range must match ``bend_range``. Most + synths default to ±2 semitones. For tunings that deviate more than + one semitone from 12-TET, increase ``bend_range`` (e.g., 12 or 24) + and configure the synth to match. + + Parameters: + tuning: The :class:`~subsequence.tuning.Tuning` to apply. + bend_range: Synth pitch-bend range in semitones (default ±2). + channels: Channel pool for polyphonic rotation. ``None`` keeps + all notes on the pattern's own channel. + reference_note: MIDI note number that maps to scale degree 0. + Default 60 (middle C). + + Example: + ```python + from subsequence import Tuning + + meantone = Tuning.from_scl("meanquar.scl") + + @composition.pattern(channel=1, beats=4) + def melody (p): + p.seq("x x x x", pitch=60) + p.apply_tuning(meantone, bend_range=2.0) + ``` + """ + import subsequence.tuning + + subsequence.tuning.apply_tuning_to_pattern( + self._pattern, + tuning, + bend_range=bend_range, + channels=channels, + reference_note=reference_note, + ) + self._tuning_applied = True + return self + + def reverse(self) -> "PatternBuilder": + """ + Flip the pattern backwards in time (retrograde). + """ + + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + old_steps = self._pattern.steps + new_steps: typing.Dict[int, subsequence.pattern.Step] = {} + + for position, step in old_steps.items(): + # Reflect around the bar so onsets stay on the grid and the downbeat + # is fixed — a true retrograde reverses the inter-onset intervals + # (e.g. [0, 24] → [0, 72] in a 96-pulse bar, not the off-grid + # [71, 95] the old (total-1)-position produced). + new_position = (total_pulses - position) % total_pulses + + if new_position not in new_steps: + new_steps[new_position] = subsequence.pattern.Step() + + new_steps[new_position].notes.extend(step.notes) + + self._pattern.steps = new_steps + return self + + def stretch(self, factor: float) -> "PatternBuilder": + """ + Stretch the pattern in time, scaling note positions and durations. + + ``stretch(2.0)`` makes everything twice as long (half speed) — what + theorists call *augmentation*; ``stretch(0.5)`` squeezes the pattern + into half the time (double speed) — *diminution*. Any positive + factor works: ``stretch(2/3)`` compresses a dotted feel into + straight time, for example. + + Notes whose start lands past the end of the pattern are dropped, + and compression leaves the freed space empty — the pattern is not + tiled to fill it. Durations scale without clipping, so a stretched + note may ring past the pattern's end exactly like a legato note, + and ``stretch(1.0)`` is a true no-op. Positions and durations + truncate to the pulse grid (matching ``note()``'s beat-to-pulse + truncation). + + Parameters: + factor: Time multiplier. Greater than 1.0 slows the pattern + down, less than 1.0 speeds it up. Must be positive. + """ + + if factor <= 0: + raise ValueError("Stretch factor must be positive") + + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + old_steps = self._pattern.steps + new_steps: typing.Dict[int, subsequence.pattern.Step] = {} + + for position, step in old_steps.items(): + new_position = int(position * factor) + + if new_position >= total_pulses: + continue + + if new_position not in new_steps: + new_steps[new_position] = subsequence.pattern.Step() + + new_steps[new_position].notes.extend( + dataclasses.replace( + note, + duration=max(1, int(note.duration * factor)), + ) + for note in step.notes + ) + + self._pattern.steps = new_steps + return self + + def rotate(self, steps: int, grid: typing.Optional[int] = None) -> "PatternBuilder": + """ + Rotate the pattern by a number of grid steps, wrapping around. + + Notes pushed past the end of the pattern re-enter at the start + (and vice versa for negative values) — the step-sequencer rotation + familiar from Euclidean rhythm tools. + + Parameters: + steps: Positive values rotate later in time, negative values earlier. + grid: The grid resolution. Defaults to the pattern's + ``default_grid`` (derived from the decorator's ``beats``/``steps`` + and ``step_duration``). + """ + + if grid is None: + grid = self._default_grid + + if grid <= 0: + return self + + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + pulses_per_step = total_pulses / grid + shift_pulses = int(steps * pulses_per_step) + + old_steps = self._pattern.steps + new_steps: typing.Dict[int, subsequence.pattern.Step] = {} + + for position, step in old_steps.items(): + new_position = (position + shift_pulses) % total_pulses + + if new_position not in new_steps: + new_steps[new_position] = subsequence.pattern.Step() + + new_steps[new_position].notes.extend(step.notes) + + self._pattern.steps = new_steps + return self + + def transpose(self, semitones: int) -> "PatternBuilder": + """ + Shift all note pitches up or down. + + Parameters: + semitones: Positive for up, negative for down. + """ + + for step in self._pattern.steps.values(): + for note in step.notes: + note.pitch = max(0, min(127, note.pitch + semitones)) + return self + + def invert(self, pivot: int = 60) -> "PatternBuilder": + """ + Invert all pitches around a pivot note. + """ + + for step in self._pattern.steps.values(): + for note in step.notes: + note.pitch = max(0, min(127, pivot + (pivot - note.pitch))) + return self + + def every( + self, n: int, fn: typing.Callable[["PatternBuilder"], None] + ) -> "PatternBuilder": + """ + Apply a transformation every Nth cycle. + + Parameters: + n: The cycle frequency (e.g., 4 = every 4th bar). + fn: A function (often a lambda) that receives the builder and + calls further methods. + + Example: + ```python + # Reverse every 4th bar + p.every(4, lambda p: p.reverse()) + ``` + """ + + if n < 1: + raise ValueError( + f"every() cycle length must be at least 1 bar — got {n} (every(1, ...) applies the change every bar)" + ) + + if self.cycle % n == 0: + fn(self) + return self + + def bar_cycle(self, length: int) -> BarCycle: + """Return the current bar's position within a repeating cycle of bars. + + A thin wrapper around ``p.bar % length`` that replaces opaque modulo + arithmetic with readable, musician-friendly properties. + + Parameters: + length: The cycle length in bars (e.g., 4, 8, 16). + + Returns: + A :class:`BarCycle` with ``.bar``, ``.first``, ``.last``, + and ``.progress`` properties. + + Example: + ```python + # Every 4 bars (replaces: if p.bar % 4 == 0) + if p.bar_cycle(4).first: + p.hit_steps("snare_1", [0, 8], velocity=110) + + # Last bar of every 16-bar cycle (replaces: if p.bar % 16 == 15) + if p.bar_cycle(16).last: + p.euclidean("hi_hat_open", 3) + + # Build intensity over an 8-bar arc + intensity = p.bar_cycle(8).progress # 0.0 → 0.875 + p.velocity_shape(low=int(40 + 40 * intensity), high=100) + ``` + """ + + if length < 1: + raise ValueError( + f"bar_cycle() cycle length must be at least 1 bar — got {length}" + ) + + return BarCycle(bar=self.bar % length, length=length) diff --git a/subsequence/pattern_midi.py b/subsequence/pattern_midi.py index e688e4c..80eb4ae 100644 --- a/subsequence/pattern_midi.py +++ b/subsequence/pattern_midi.py @@ -15,1194 +15,1216 @@ class PatternMidiMixin: - - """MIDI control, OSC, and note-correlated pitch bend methods for PatternBuilder. - - All methods here operate on ``self._pattern`` (a ``Pattern`` instance), - which is set by ``PatternBuilder.__init__``. - """ - - # ── Instance attributes provided by PatternBuilder at runtime ──────── - _pattern: subsequence.pattern.Pattern - _default_grid: int - _cc_name_map: typing.Optional[typing.Dict[str, int]] - _nrpn_name_map: typing.Optional[typing.Dict[str, int]] - - if typing.TYPE_CHECKING: - import subsequence.pattern_builder # noqa: F401 — type-checking only - def _resolve_cc (self, control: typing.Union[int, str]) -> int: ... - def _resolve_nrpn (self, parameter: typing.Union[int, str]) -> int: ... - def _resolve_rpn (self, parameter: typing.Union[int, str]) -> int: ... - - # ── Shared ramp helper ────────────────────────────────────────────────── - - def _ramp_pulse_span ( - self, - pulse_start: int, - pulse_end: int, - start: float, - end: float, - shape: typing.Union[str, subsequence.easing.EasingFn], - resolution: int, - event_fn: typing.Callable[[int, float], None], - ) -> None: - - """Walk from pulse_start to pulse_end, calling event_fn(pulse, value) at each step. - - The pulse-domain kernel shared by every ramp on this mixin — the - beat-based ramps via :meth:`_ramp_pulses` and the note-correlated pitch - bends via :meth:`_generate_bend_events`. ``event_fn`` receives the pulse - position and the linearly-interpolated (then eased) value, and is - responsible for creating and appending the event. - """ - - span = pulse_end - pulse_start - - if span <= 0: - return - - if resolution < 1: - raise ValueError("resolution must be at least 1 pulse") - - easing_fn = subsequence.easing.get_easing(shape) - pulse = pulse_start - - while pulse <= pulse_end: - t = (pulse - pulse_start) / span - eased_t = easing_fn(t) - interpolated = start + (end - start) * eased_t - event_fn(pulse, interpolated) - pulse += resolution - - # The loop lands on pulse_end only when resolution divides the span — - # otherwise emit the target explicitly, so a ramp always reaches the - # value it was asked to reach. - if span % resolution != 0: - event_fn(pulse_end, start + (end - start) * easing_fn(1.0)) - - def _ramp_pulses ( - self, - beat_start: float, - beat_end: float, - start: float, - end: float, - shape: typing.Union[str, subsequence.easing.EasingFn], - resolution: int, - event_fn: typing.Callable[[int, float], None], - ) -> None: - - """Walk from beat_start to beat_end, calling event_fn(pulse, value) at each step. - - The beat-based entry to :meth:`_ramp_pulse_span`, shared by - ``cc_ramp()``, ``pitch_bend_ramp()``, ``nrpn_ramp()``/``rpn_ramp()``, - and ``osc_ramp()``. - """ - - pulse_start = int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE) - pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE) - - self._ramp_pulse_span(pulse_start, pulse_end, start, end, shape, resolution, event_fn) - - # ── CC messages ───────────────────────────────────────────────────────── - - def cc (self, control: typing.Union[int, str], value: int, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Send a single CC message at a beat position. - - Parameters: - control: MIDI CC number (0–127), or a string name resolved - via the pattern's ``cc_name_map``. - value: CC value (0–127); out-of-range values are clamped. - beat: Beat position within the pattern. - """ - - cc_num: int = self._resolve_cc(control) - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - - # Clamp to the 7-bit CC range like every sibling (cc_ramp / program_change - # / pitch_bend) so a computed out-of-range value is corrected here rather - # than silently dropped and logged when mido rejects it at dispatch time. - clamped_value = max(0, min(127, int(round(value)))) - - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = cc_num, - value = clamped_value - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def cc_ramp ( - self, - control: typing.Union[int, str], - start: int, - end: int, - beat_start: float = 0.0, - beat_end: typing.Optional[float] = None, - resolution: int = 1, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear" - ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Interpolate a CC value over a beat range. - - Parameters: - control: MIDI CC number (0–127), or a string name resolved - via the pattern's ``cc_name_map``. - start: Starting CC value (0–127). - end: Ending CC value (0–127). - beat_start: Beat position to begin the ramp. - beat_end: Beat position to end the ramp. Defaults to pattern length. - resolution: Pulses between CC messages (1 = every pulse, ~20ms at 120 BPM). - Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound - stepped at slow tempos. - shape: Easing curve — a name string (e.g. ``"exponential"``) or any - callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. - See :mod:`subsequence.easing` for available shapes. - """ - - cc_num: int = self._resolve_cc(control) - - if beat_end is None: - beat_end = self._pattern.length - - def _event (pulse: int, val: float) -> None: - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = cc_num, - value = max(0, min(127, int(round(val)))) - ) - ) - - self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - # ── Pitch bend ────────────────────────────────────────────────────────── - - def pitch_bend (self, value: float, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Send a single pitch bend message at a beat position. - - Parameters: - value: Pitch bend amount, normalised from -1.0 to 1.0. - beat: Beat position within the pattern. - """ - - # The asymmetric clamp is correct: MIDI's 14-bit bend range is -8192..+8191. - midi_value = max(-8192, min(8191, int(round(value * 8192)))) - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'pitchwheel', - value = midi_value - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def pitch_bend_ramp ( - self, - start: float, - end: float, - beat_start: float = 0.0, - beat_end: typing.Optional[float] = None, - resolution: int = 1, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear" - ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Interpolate pitch bend over a beat range. - - Parameters: - start: Starting pitch bend (-1.0 to 1.0). - end: Ending pitch bend (-1.0 to 1.0). - beat_start: Beat position to begin the ramp. - beat_end: Beat position to end the ramp. Defaults to pattern length. - resolution: Pulses between pitch bend messages (1 = every pulse). - Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound - stepped at slow tempos. - shape: Easing curve — a name string (e.g. ``"ease_out"``) or any - callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. - See :mod:`subsequence.easing` for available shapes. - """ - - if beat_end is None: - beat_end = self._pattern.length - - def _event (pulse: int, val: float) -> None: - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'pitchwheel', - value = max(-8192, min(8191, int(round(val * 8192)))) - ) - ) - - self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - # ── RPN / NRPN parameter control ──────────────────────────────────────── - # RPN (Registered) and NRPN (Non-Registered) Parameter Numbers are the - # standard MIDI conventions for addressing parameters beyond the 128 CC - # slots, with optional 14-bit value precision. Both are sequences of - # regular control_change messages co-scheduled at the same pulse: - # CC 99 / 98 NRPN parameter MSB / LSB (or 101 / 100 for RPN) - # CC 6 / 38 Data Entry MSB / LSB - # CC 101=127, 100=127 NULL — defensive deselect - # The Sequencer's MidiEvent.sequence tie-breaker preserves emission order - # at the same pulse, so the synth assigns the value to the right parameter. - - def _append_param_select (self, pulse: int, parameter: int, msb_cc: int, lsb_cc: int) -> None: - - """Emit the two-CC parameter-select pair (NRPN: 99/98, RPN: 101/100). - - Events are emitted on the pattern's channel — leaving ``CcEvent.channel`` - unset (None) lets the sequencer fall through to ``pattern.channel`` - at dispatch time, which is the normal behaviour for every other CC - method on this mixin. - """ - - param_msb, param_lsb = pymididefs.cc.pack_14bit(parameter) - - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = msb_cc, - value = param_msb, - ) - ) - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = lsb_cc, - value = param_lsb, - ) - ) - - def _append_data_entry (self, pulse: int, value: int, fine: bool) -> None: - - """Emit Data Entry MSB (and LSB if fine=True) for a parameter value.""" - - if fine: - value_msb, value_lsb = pymididefs.cc.pack_14bit(value) - else: - if not 0 <= value <= 127: - raise ValueError(f"NRPN/RPN value must be 0–127 when fine=False, got {value}") - value_msb = value - value_lsb = None - - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = pymididefs.cc.DATA_ENTRY_MSB, - value = value_msb, - ) - ) - - if value_lsb is not None: - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = pymididefs.cc.DATA_ENTRY_LSB, - value = value_lsb, - ) - ) - - def _validate_ramp_endpoints (self, start: int, end: int, fine: bool) -> None: - - """Reject out-of-range NRPN/RPN ramp endpoints up front. - - Mirrors the strict behaviour of ``_append_data_entry`` for one-shots - so a typo (e.g. forgetting ``fine=True`` with a 14-bit value) raises - immediately rather than silently clamping at every ramp step. - """ - - limit = 16383 if fine else 127 - - for label, value in (("start", start), ("end", end)): - if not 0 <= value <= limit: - raise ValueError(f"NRPN/RPN ramp {label} must be 0–{limit} (fine={fine}), got {value}") - - def _append_null_reset (self, pulse: int) -> None: - - """Emit the RPN NULL sentinel (CC 101=127, CC 100=127) to deselect. - - Defensive practice: prevents a stray later CC 6 / 38 from being - applied to whichever parameter was last selected on this channel. - """ - - null_msb, null_lsb = pymididefs.cc.pack_14bit(pymididefs.rpn.NULL_PARAMETER) - - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = pymididefs.cc.RPN_MSB, - value = null_msb, - ) - ) - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = pymididefs.cc.RPN_LSB, - value = null_lsb, - ) - ) - - def nrpn ( - self, - parameter: typing.Union[int, str], - value: int, - beat: float = 0.0, - fine: bool = False, - null_reset: bool = True, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Send a single NRPN parameter write at a beat position. - - NRPN (Non-Registered Parameter Number) addresses synth-specific - parameters that don't fit into the 128 standard CC slots — Sequential, - Korg, Roland, Elektron and others use it heavily for filter cutoff, - envelope amounts, oscillator detune, and similar deep parameters. - Many such parameters need values beyond 0–127 (e.g. 0–1023, 0–254); - set ``fine=True`` for full 14-bit precision. - - Emitted on the pattern's MIDI channel. To target a different channel - (e.g. a per-channel RPN config), define a separate pattern on that - channel or use ``composition.trigger(channel=…)`` for a one-shot. - - Parameters: - parameter: 14-bit NRPN parameter number (0–16383), or a string - resolved via the pattern's ``nrpn_name_map``. - value: Parameter value. 0–127 if ``fine=False``; 0–16383 if - ``fine=True``. - beat: Beat position within the pattern. - fine: If True, send 14-bit value via Data Entry MSB+LSB - (CC 6 + CC 38). If False (default), send only Data Entry - MSB — sufficient for the common 0–127 range. - null_reset: If True (default), follow with the RPN null sentinel - to deselect the active parameter and prevent stray later - CC 6 / 38 messages from hitting it. - - Example: - ```python - # Sequential Take 5 fine-tune (14-bit, range 0–1400) - p.nrpn(9, 700, fine=True) - - # Roland JV-1080 reverb level (7-bit) - p.nrpn(0x0140, 80) - ``` - """ - - param = self._resolve_nrpn(parameter) - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - - self._append_param_select(pulse, param, pymididefs.cc.NRPN_MSB, pymididefs.cc.NRPN_LSB) - self._append_data_entry(pulse, value, fine) - - if null_reset: - self._append_null_reset(pulse) - - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def rpn ( - self, - parameter: typing.Union[int, str], - value: int, - beat: float = 0.0, - fine: bool = False, - null_reset: bool = True, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Send a single RPN parameter write at a beat position. - - RPN (Registered Parameter Number) addresses the small standardised - set of parameters defined by the MIDI specification — pitch bend - range, master tuning, modulation depth — supported by virtually any - MIDI synth. String names resolve via ``pymididefs.rpn.RPN_MAP`` - out of the box, no map needed. - - Standard RPN names: ``pitch_bend_sensitivity``, - ``channel_fine_tuning``, ``channel_coarse_tuning``, - ``tuning_program_select``, ``tuning_bank_select``, - ``modulation_depth_range``. - - Emitted on the pattern's MIDI channel. - - Parameters: - parameter: 14-bit RPN parameter number (0–16383), or one of the - standard string names above. - value: Parameter value. 0–127 if ``fine=False``; 0–16383 if - ``fine=True``. Pitch bend sensitivity uses MSB = semitones - and LSB = cents, so set ``fine=True`` for sub-semitone control. - beat: Beat position within the pattern. - fine: If True, send 14-bit value via Data Entry MSB+LSB. - null_reset: If True (default), follow with the RPN null sentinel. - - Example: - ```python - # Set pitch bend range to ±12 semitones - p.rpn("pitch_bend_sensitivity", 12) - - # 4 semitones plus 50 cents - p.rpn("pitch_bend_sensitivity", 4 * 128 + 50, fine=True) - ``` - """ - - param = self._resolve_rpn(parameter) - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - - self._append_param_select(pulse, param, pymididefs.cc.RPN_MSB, pymididefs.cc.RPN_LSB) - self._append_data_entry(pulse, value, fine) - - if null_reset: - self._append_null_reset(pulse) - - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def nrpn_ramp ( - self, - parameter: typing.Union[int, str], - start: int, - end: int, - beat_start: float = 0.0, - beat_end: typing.Optional[float] = None, - resolution: int = 4, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", - fine: bool = True, - null_reset: bool = True, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Interpolate an NRPN value over a beat range. - - The parameter is selected once at ``beat_start``; subsequent steps - emit only Data Entry messages. Synths track the most recently - selected NRPN per the spec, so re-selecting per step would just - waste bandwidth. If ``null_reset=True`` the RPN null sentinel is - appended once at ``beat_end``. - - **Mid-ramp parameter persistence:** between ``beat_start`` and - ``beat_end`` the synth still has this NRPN selected. Avoid issuing - ``p.cc(6, …)`` or ``p.cc(38, …)`` on the same channel during the - ramp window — they would land on the ramped parameter rather than - acting as plain data-entry CCs. - - Bandwidth note: with ``fine=True`` (default) every step emits two - CCs. Default ``resolution=4`` is one update every four pulses - (~83 ms at 120 BPM, where one pulse is ~21 ms), which keeps the bus - lightly loaded. Increase - ``resolution`` (e.g. ``8``) on slow DIN-MIDI links if you hear - other messages getting delayed. - - Emitted on the pattern's MIDI channel. - - Parameters: - parameter: 14-bit NRPN parameter number, or a string resolved - via the pattern's ``nrpn_name_map``. - start: Starting value (0–16383 when ``fine=True``, 0–127 when False). - end: Ending value. - beat_start: Beat position to begin the ramp. - beat_end: Beat position to end the ramp. Defaults to pattern length. - resolution: Pulses between Data Entry messages (default 4). - shape: Easing curve — string name or callable [0, 1] → [0, 1]. - fine: If True (default), use full 14-bit Data Entry MSB+LSB. - null_reset: If True (default), append the null sentinel at the - end of the ramp (not per step). - """ - - param = self._resolve_nrpn(parameter) - self._validate_ramp_endpoints(start, end, fine) - - if beat_end is None: - beat_end = self._pattern.length - - pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE) - - self._append_param_select(int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), param, pymididefs.cc.NRPN_MSB, pymididefs.cc.NRPN_LSB) - - def _event (pulse: int, val: float) -> None: - # Clamp guards against custom easing callables that overshoot [0, 1]. - if fine: - value = max(0, min(16383, int(round(val)))) - else: - value = max(0, min(127, int(round(val)))) - self._append_data_entry(pulse, value, fine) - - self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event) - - if null_reset: - self._append_null_reset(pulse_end) - - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def rpn_ramp ( - self, - parameter: typing.Union[int, str], - start: int, - end: int, - beat_start: float = 0.0, - beat_end: typing.Optional[float] = None, - resolution: int = 4, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", - fine: bool = True, - null_reset: bool = True, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Interpolate an RPN value over a beat range. - - Identical to :meth:`nrpn_ramp` but uses CC 101 / 100 for parameter - selection. String names resolve via ``pymididefs.rpn.RPN_MAP``. - The same mid-ramp persistence note applies: avoid plain ``p.cc(6, …)`` - on this channel during the ramp window. - """ - - param = self._resolve_rpn(parameter) - self._validate_ramp_endpoints(start, end, fine) - - if beat_end is None: - beat_end = self._pattern.length - - pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE) - - self._append_param_select(int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), param, pymididefs.cc.RPN_MSB, pymididefs.cc.RPN_LSB) - - def _event (pulse: int, val: float) -> None: - # Clamp guards against custom easing callables that overshoot [0, 1]. - if fine: - value = max(0, min(16383, int(round(val)))) - else: - value = max(0, min(127, int(round(val)))) - self._append_data_entry(pulse, value, fine) - - self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event) - - if null_reset: - self._append_null_reset(pulse_end) - - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - # ── Program change and SysEx ───────────────────────────────────────────── - - def program_change ( - self, - program: int, - beat: float = 0.0, - bank_msb: typing.Optional[int] = None, - bank_lsb: typing.Optional[int] = None, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Send a Program Change message, optionally preceded by bank select. - - Switches the instrument patch on this pattern's MIDI channel. - Program numbers follow the General MIDI numbering (0–127, where - e.g. 0 = Acoustic Grand Piano, 40 = Violin, 33 = Electric Bass). - - To select a patch in a specific bank, provide ``bank_msb`` and/or - ``bank_lsb``. The bank select CC messages (CC 0 for MSB, CC 32 for - LSB) are sent at the same beat position immediately before the - program change, in the order the synthesizer expects. - - Parameters: - program: Program (patch) number (0–127). - beat: Beat position within the pattern (default 0.0). - bank_msb: Bank select coarse (CC 0), 0–127. ``None`` = omit. - bank_lsb: Bank select fine (CC 32), 0–127. ``None`` = omit. - - Example: - ```python - @composition.pattern(channel=1, beats=4) - def strings (p): - # GM — no bank needed - p.program_change(48) - - # Roland JV-1080 bank 1, patch 48 - p.program_change(48, bank_msb=81, bank_lsb=0) - - # Change patch only at the first bar of each section - if p.section.bar == 0: - p.program_change(48, bank_msb=1) - ``` - """ - - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - - if bank_msb is not None: - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = 0, - value = max(0, min(127, int(round(bank_msb)))), - ) - ) - - if bank_lsb is not None: - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'control_change', - control = 32, - value = max(0, min(127, int(round(bank_lsb)))), - ) - ) - - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'program_change', - value = max(0, min(127, int(round(program)))), - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def sysex (self, data: typing.Union[bytes, typing.List[int]], beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Send a System Exclusive (SysEx) message at a beat position. - - SysEx messages allow deep integration with synthesizers and other - hardware: patch dumps, parameter control, and vendor-specific commands. - The ``data`` argument should contain only the inner payload bytes, - without the surrounding ``0xF0`` / ``0xF7`` framing — mido adds those - automatically. - - Parameters: - data: SysEx payload as ``bytes`` or a list of integers (0–127). - beat: Beat position within the pattern (default 0.0). - - Example: - ```python - # GM System On — reset a GM-compatible device to defaults - p.sysex([0x7E, 0x7F, 0x09, 0x01]) - ``` - """ - - # Validate at build time: MIDI sysex payloads are 7-bit. A byte over - # 127 would be rejected by mido at dispatch and the message silently - # dropped every cycle with a misleading "device disconnected" log. - invalid = [b for b in data if not 0 <= b <= 127] - - if invalid: - raise ValueError( - f"sysex data bytes must be 0-127 (7-bit MIDI data) — got {invalid[:4]}. " - "Mask computed values (checksums, packed parameters) with & 0x7F first." - ) - - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'sysex', - data = bytes(data) - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - # ── OSC messages ───────────────────────────────────────────────────────── - - def osc (self, address: str, *args: typing.Any, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Send an OSC message at a beat position. - - Requires ``composition.osc()`` to be called before ``composition.play()``. - If no OSC server is configured the event is silently dropped. - - Parameters: - address: OSC address path (e.g. ``"/mixer/fader/1"``). - ``*args``: OSC arguments — float, int, str, or bytes. - beat: Beat position within the pattern (default 0.0). - - Example: - ```python - # Enable a chorus effect at beat 2 - p.osc("/fx/chorus/enable", 1, beat=2.0) - - # Set a mixer pan value immediately - p.osc("/mixer/pan/1", -0.5) - ``` - """ - - pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) - - self._pattern.osc_events.append( - subsequence.pattern.OscEvent( - pulse = pulse, - address = address, - args = args - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def osc_ramp ( - self, - address: str, - start: float, - end: float, - beat_start: float = 0.0, - beat_end: typing.Optional[float] = None, - resolution: int = 4, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear" - ) -> "subsequence.pattern_builder.PatternBuilder": - - """ - Interpolate an OSC float value over a beat range. - - Generates one OSC message per ``resolution`` pulses, sending the - interpolated value to ``address`` at each step. Useful for smoothly - automating mixer faders, effect parameters, and other continuous controls - on a remote machine. - - Requires ``composition.osc()`` to be called before ``composition.play()``. - If no OSC server is configured the events are silently dropped. - - Parameters: - address: OSC address path (e.g. ``"/mixer/fader/1"``). - start: Starting float value. - end: Ending float value. - beat_start: Beat position to begin the ramp (default 0.0). - beat_end: Beat position to end the ramp. Defaults to pattern length. - resolution: Pulses between OSC messages (default 4 — approximately - 6 messages per beat at 120 BPM, which is smooth for fader - automation while keeping UDP traffic light). Use ``resolution=1`` - for pulse-level precision. - shape: Easing curve — a name string (e.g. ``"ease_in"``) or any - callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. - See :mod:`subsequence.easing` for available shapes. - - Example: - ```python - # Fade a mixer fader up over 4 beats - p.osc_ramp("/mixer/fader/1", start=0.0, end=1.0) - - # Ease in a reverb send over the last 2 beats - p.osc_ramp("/fx/reverb/wet", 0.0, 0.8, beat_start=2, beat_end=4, shape="ease_in") - ``` - """ - - if beat_end is None: - beat_end = self._pattern.length - - def _event (pulse: int, val: float) -> None: - self._pattern.osc_events.append( - subsequence.pattern.OscEvent( - pulse = pulse, - address = address, - args = (val,) - ) - ) - - self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - # ── Note-correlated pitch bend ──────────────────────────────────────────── - - def _generate_bend_events ( - self, - start_value: float, - end_value: float, - pulse_start: int, - pulse_end: int, - resolution: int, - shape: typing.Union[str, subsequence.easing.EasingFn], - ) -> None: - - """Generate a series of pitchwheel CcEvents between two pulse positions. - - Used by ``bend()``, ``portamento()``, and ``slide()``. Delegates the - span/resolution walk (including the emit-the-endpoint rule) to - :meth:`_ramp_pulse_span` and contributes only the pitchwheel - conversion — normalised value scaled to 14-bit and clamped — appending - events directly to ``self._pattern.cc_events``. - - Parameters: - start_value: Normalised bend at the start of the ramp (-1.0 to 1.0). - end_value: Normalised bend at the end of the ramp (-1.0 to 1.0). - pulse_start: Absolute pulse position to start the ramp. - pulse_end: Absolute pulse position to end the ramp. - resolution: Number of pulses between consecutive events. - shape: Easing curve name or callable. - """ - - def _event (pulse: int, val: float) -> None: - midi_value = max(-8192, min(8191, int(round(val * 8192)))) - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = pulse, - message_type = 'pitchwheel', - value = midi_value, - ) - ) - - self._ramp_pulse_span(pulse_start, pulse_end, start_value, end_value, shape, resolution, _event) - - def bend ( - self, - note: int, - amount: float, - start: float = 0.0, - end: float = 1.0, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", - resolution: int = 1, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Bend a specific note by index. - - Generates a pitch bend ramp that covers a fraction of the target note's - duration, then resets to 0.0 at the next note's onset. Call this - *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final. - - Parameters: - note: Note index (0 = first, -1 = last, etc.). - amount: Target bend normalised to -1.0..1.0 (positive = up). - With a standard ±2-semitone pitch wheel range, 0.5 = 1 semitone. - start: Fraction of the note's duration at which the ramp begins - (0.0 = note onset, default). - end: Fraction of the note's duration at which the ramp ends - (1.0 = note end, default). - shape: Easing curve — a name string (e.g. ``"ease_in"``) or any - callable mapping [0, 1] → [0, 1]. Defaults to ``"linear"``. - resolution: Pulses between pitch bend messages. - - Raises: - IndexError: If *note* is out of range for the current pattern. - - Example: - ```python - p.sequence(steps=[0, 4, 8, 12], pitches=midi_notes.E1) - p.legato(0.95) - - # Bend the last note up one semitone (with ±2 st range), easing in - p.bend(note=-1, amount=0.5, shape="ease_in") - - # Bend the second note down, starting halfway through - p.bend(note=1, amount=-0.3, start=0.5) - ``` - """ - - if not self._pattern.steps: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - sorted_positions = sorted(self._pattern.steps.keys()) - - # Resolve note index (supports negative indexing) - position = sorted_positions[note] - note_idx = note if note >= 0 else len(sorted_positions) + note - - # Duration: use the longest note at this step - step = self._pattern.steps[position] - note_duration = max(n.duration for n in step.notes) - - # Clamp start/end fractions and compute pulse range for the ramp - start_clamped = max(0.0, min(1.0, start)) - end_clamped = max(0.0, min(1.0, end)) - bend_start_pulse = position + int(note_duration * start_clamped) - bend_end_pulse = position + int(note_duration * end_clamped) - - self._generate_bend_events(0.0, amount, bend_start_pulse, bend_end_pulse, resolution, shape) - - # Reset bend at the next note's onset. For the last note that is the - # NEXT cycle's first onset (total + first), not pulse 0 - a bend tail - # spilling past the cycle end was cancelled mid-flight by a pulse-0 - # reset, leaving the next cycle's first note bent. - if note_idx < len(sorted_positions) - 1: - reset_pulse = sorted_positions[note_idx + 1] - else: - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - reset_pulse = total_pulses + sorted_positions[0] - - reset_midi = max(-8192, min(8191, int(round(0.0 * 8192)))) - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = reset_pulse, - message_type = 'pitchwheel', - value = reset_midi, - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def portamento ( - self, - time: float = 0.15, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", - resolution: int = 1, - bend_range: typing.Optional[float] = 2.0, - wrap: bool = True, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """Glide between all consecutive notes using pitch bend. - - Generates a pitch bend ramp in the tail of each note, bending toward - the next note's pitch, then resets at the next note's onset. Call this - *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final. - - Most effective on mono instruments where pitch bend is per-channel. - - Parameters: - time: Fraction of each note's duration used for the glide - (default 0.15 — last 15% of the note). - shape: Easing curve. Defaults to ``"linear"``. - resolution: Pulses between pitch bend messages. - bend_range: Instrument's pitch wheel range in semitones - (default 2.0 — standard ±2 st). Pairs with intervals larger - than this value are skipped. Pass ``None`` to disable range - checking and always generate the bend (large intervals are - clamped to ±1.0). - wrap: If ``True`` (default), glide from the last note toward the - first note of the next cycle. - - Example: - ```python - p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) - p.legato(0.95) - - # Gentle glide across all note transitions - p.portamento(time=0.15, shape="ease_in_out") - - # Wide bend range (synth set to ±12 semitones) - p.portamento(time=0.2, bend_range=12) - - # No range limit — bend as far as MIDI allows - p.portamento(time=0.1, bend_range=None) - ``` - """ - - if bend_range is not None and bend_range <= 0: - raise ValueError( - f"bend_range must be a positive number of semitones (your instrument's " - f"pitch-wheel range) — got {bend_range}. Pass None to disable range checking." - ) - - if not self._pattern.steps: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - sorted_positions = sorted(self._pattern.steps.keys()) - n = len(sorted_positions) - - def _lowest_pitch (pos: int) -> int: - return min(note.pitch for note in self._pattern.steps[pos].notes) - - def _longest_duration (pos: int) -> int: - return max(note.duration for note in self._pattern.steps[pos].notes) - - for i in range(n): - a_pos = sorted_positions[i] - is_last = (i == n - 1) - - if is_last: - if not wrap: - continue - b_pos = sorted_positions[0] - else: - b_pos = sorted_positions[i + 1] - - interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos) - - if bend_range is not None and abs(interval) > bend_range: - continue - - normaliser = bend_range if bend_range is not None else 2.0 - amount = max(-1.0, min(1.0, interval / normaliser)) - - a_duration = _longest_duration(a_pos) - glide_start_pulse = a_pos + int(a_duration * (1.0 - time)) - glide_end_pulse = a_pos + a_duration - - self._generate_bend_events(0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape) - - # Reset at the destination note's onset. For the wrap-around pair - # that is the NEXT cycle's first onset (total + first), not pulse 0 - # - a glide spilling past the cycle end was cancelled mid-flight by - # the pulse-0 reset, leaving the first note fully bent. - if is_last: - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - reset_pulse = total_pulses + sorted_positions[0] - else: - reset_pulse = b_pos - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = reset_pulse, - message_type = 'pitchwheel', - value = 0, - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - def slide ( - self, - notes: typing.Optional[typing.List[int]] = None, - steps: typing.Optional[typing.List[int]] = None, - time: float = 0.15, - shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", - resolution: int = 1, - bend_range: typing.Optional[float] = 2.0, - wrap: bool = True, - extend: bool = True, - ) -> "subsequence.pattern_builder.PatternBuilder": - - """TB-303-style selective slide into specific notes. - - Like ``portamento()`` but only applies to flagged destination notes. - Specify target notes by index (``notes=[1, 3]``) or by step grid - position (``steps=[4, 12]``). If ``extend=True`` (default) the - preceding note's duration is extended to meet the slide target, matching - the 303's behaviour where slide notes do not retrigger. - - Call this *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations - are final. - - Parameters: - notes: List of note indices to slide *into* (0 = first). - Supports negative indexing. Mutually exclusive with *steps*. - steps: List of step grid indices to slide *into*. - Converted to pulse positions using ``self._default_grid``. - Mutually exclusive with *notes*. - time: Fraction of the preceding note's duration used for the glide. - shape: Easing curve. Defaults to ``"linear"``. - resolution: Pulses between pitch bend messages. - bend_range: Instrument's pitch wheel range in semitones - (default 2.0). Pairs with larger intervals are skipped. - Pass ``None`` to disable range checking. - wrap: If ``True`` (default), include a wrap-around slide from the - last note back toward the first. - extend: If ``True`` (default), extend the preceding note's duration - to reach the slide target's onset — 303-style legato through - the glide. - - Raises: - ValueError: If neither or both of *notes* and *steps* are provided, - or a note index falls outside the pattern's notes. - - Example: - ```python - p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) - p.legato(0.95) - - # Slide into the 2nd and 4th notes - p.slide(notes=[1, 3], time=0.2, shape="ease_in") - - # Same using step grid indices - p.slide(steps=[4, 12], time=0.2, shape="ease_in") - - # Slide without extending the preceding note - p.slide(notes=[1, 3], extend=False) - ``` - """ - - if notes is None and steps is None: - raise ValueError("slide() requires either 'notes' or 'steps'") - - if notes is not None and steps is not None: - raise ValueError("slide() takes notes= or steps=, not both — they name the same slide targets two different ways") - - if bend_range is not None and bend_range <= 0: - raise ValueError( - f"bend_range must be a positive number of semitones (your instrument's " - f"pitch-wheel range) — got {bend_range}. Pass None to disable range checking." - ) - - if not self._pattern.steps: - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) - - sorted_positions = sorted(self._pattern.steps.keys()) - total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) - n = len(sorted_positions) - - # Resolve flagged pulse positions - if notes is not None: - flagged: typing.Set[int] = set() - for idx in notes: - if not -n <= idx < n: - raise ValueError(f"slide() note index {idx} is outside this pattern's {n} notes") - - flagged.add(sorted_positions[idx]) - else: - # steps is not None. Resolve each grid step to the SAME pulse the - # placement methods use — int(step * (length / grid) * PPQ) — so the - # flag lands on the note even when the grid doesn't divide the bar - # evenly. Floored uniform spacing (total_pulses // grid) drifts out of - # alignment on non-divisor grids, silently flagging nothing. - step_beats = self._pattern.length / self._default_grid - flagged = set() - for s in (steps or []): - flagged.add(int(s * step_beats * subsequence.constants.MIDI_QUARTER_NOTE)) - - def _lowest_pitch (pos: int) -> int: - return min(note.pitch for note in self._pattern.steps[pos].notes) - - def _longest_duration (pos: int) -> int: - return max(note.duration for note in self._pattern.steps[pos].notes) - - for i in range(n): - a_pos = sorted_positions[i] - is_last = (i == n - 1) - - if is_last: - if not wrap: - continue - b_pos = sorted_positions[0] - else: - b_pos = sorted_positions[i + 1] - - # Only generate glide if the destination is flagged - if b_pos not in flagged: - continue - - interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos) - - if bend_range is not None and abs(interval) > bend_range: - continue - - normaliser = bend_range if bend_range is not None else 2.0 - amount = max(-1.0, min(1.0, interval / normaliser)) - - # Optionally extend preceding note to meet the target onset (303 style) - if extend: - if is_last: - gap = (total_pulses - a_pos) + sorted_positions[0] - else: - gap = b_pos - a_pos - for note in self._pattern.steps[a_pos].notes: - note.duration = gap - - # Read the duration AFTER any extension so the glide occupies the - # tail of the note as actually played and lands on the target - # onset. (Reading it before the extend block made the bend jump - # near the note's start and then hold flat - the opposite of a - # slide.) - a_duration = _longest_duration(a_pos) - - glide_start_pulse = a_pos + int(a_duration * (1.0 - time)) - glide_end_pulse = a_pos + a_duration - - self._generate_bend_events(0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape) - - # Reset at the destination note's onset. For the wrap-around pair - # the destination is the NEXT cycle's first onset (total_pulses + - # first onset): resetting at pulse 0 fired while a spilled glide - # was still in flight, so the destination note played fully bent. - reset_pulse = b_pos if not is_last else total_pulses + sorted_positions[0] - self._pattern.cc_events.append( - subsequence.pattern.CcEvent( - pulse = reset_pulse, - message_type = 'pitchwheel', - value = 0, - ) - ) - return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + """MIDI control, OSC, and note-correlated pitch bend methods for PatternBuilder. + + All methods here operate on ``self._pattern`` (a ``Pattern`` instance), + which is set by ``PatternBuilder.__init__``. + """ + + # ── Instance attributes provided by PatternBuilder at runtime ──────── + _pattern: subsequence.pattern.Pattern + _default_grid: int + _cc_name_map: typing.Optional[typing.Dict[str, int]] + _nrpn_name_map: typing.Optional[typing.Dict[str, int]] + + if typing.TYPE_CHECKING: + import subsequence.pattern_builder # noqa: F401 — type-checking only + + def _resolve_cc(self, control: typing.Union[int, str]) -> int: ... + def _resolve_nrpn(self, parameter: typing.Union[int, str]) -> int: ... + def _resolve_rpn(self, parameter: typing.Union[int, str]) -> int: ... + + # ── Shared ramp helper ────────────────────────────────────────────────── + + def _ramp_pulse_span( + self, + pulse_start: int, + pulse_end: int, + start: float, + end: float, + shape: typing.Union[str, subsequence.easing.EasingFn], + resolution: int, + event_fn: typing.Callable[[int, float], None], + ) -> None: + """Walk from pulse_start to pulse_end, calling event_fn(pulse, value) at each step. + + The pulse-domain kernel shared by every ramp on this mixin — the + beat-based ramps via :meth:`_ramp_pulses` and the note-correlated pitch + bends via :meth:`_generate_bend_events`. ``event_fn`` receives the pulse + position and the linearly-interpolated (then eased) value, and is + responsible for creating and appending the event. + """ + + span = pulse_end - pulse_start + + if span <= 0: + return + + if resolution < 1: + raise ValueError("resolution must be at least 1 pulse") + + easing_fn = subsequence.easing.get_easing(shape) + pulse = pulse_start + + while pulse <= pulse_end: + t = (pulse - pulse_start) / span + eased_t = easing_fn(t) + interpolated = start + (end - start) * eased_t + event_fn(pulse, interpolated) + pulse += resolution + + # The loop lands on pulse_end only when resolution divides the span — + # otherwise emit the target explicitly, so a ramp always reaches the + # value it was asked to reach. + if span % resolution != 0: + event_fn(pulse_end, start + (end - start) * easing_fn(1.0)) + + def _ramp_pulses( + self, + beat_start: float, + beat_end: float, + start: float, + end: float, + shape: typing.Union[str, subsequence.easing.EasingFn], + resolution: int, + event_fn: typing.Callable[[int, float], None], + ) -> None: + """Walk from beat_start to beat_end, calling event_fn(pulse, value) at each step. + + The beat-based entry to :meth:`_ramp_pulse_span`, shared by + ``cc_ramp()``, ``pitch_bend_ramp()``, ``nrpn_ramp()``/``rpn_ramp()``, + and ``osc_ramp()``. + """ + + pulse_start = int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE) + pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE) + + self._ramp_pulse_span( + pulse_start, pulse_end, start, end, shape, resolution, event_fn + ) + + # ── CC messages ───────────────────────────────────────────────────────── + + def cc( + self, control: typing.Union[int, str], value: int, beat: float = 0.0 + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Send a single CC message at a beat position. + + Parameters: + control: MIDI CC number (0–127), or a string name resolved + via the pattern's ``cc_name_map``. + value: CC value (0–127); out-of-range values are clamped. + beat: Beat position within the pattern. + """ + + cc_num: int = self._resolve_cc(control) + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + + # Clamp to the 7-bit CC range like every sibling (cc_ramp / program_change + # / pitch_bend) so a computed out-of-range value is corrected here rather + # than silently dropped and logged when mido rejects it at dispatch time. + clamped_value = max(0, min(127, int(round(value)))) + + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=cc_num, + value=clamped_value, + ) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def cc_ramp( + self, + control: typing.Union[int, str], + start: int, + end: int, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + resolution: int = 1, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Interpolate a CC value over a beat range. + + Parameters: + control: MIDI CC number (0–127), or a string name resolved + via the pattern's ``cc_name_map``. + start: Starting CC value (0–127). + end: Ending CC value (0–127). + beat_start: Beat position to begin the ramp. + beat_end: Beat position to end the ramp. Defaults to pattern length. + resolution: Pulses between CC messages (1 = every pulse, ~20ms at 120 BPM). + Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound + stepped at slow tempos. + shape: Easing curve — a name string (e.g. ``"exponential"``) or any + callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. + See :mod:`subsequence.easing` for available shapes. + """ + + cc_num: int = self._resolve_cc(control) + + if beat_end is None: + beat_end = self._pattern.length + + def _event(pulse: int, val: float) -> None: + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=cc_num, + value=max(0, min(127, int(round(val)))), + ) + ) + + self._ramp_pulses( + beat_start, beat_end, float(start), float(end), shape, resolution, _event + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + # ── Pitch bend ────────────────────────────────────────────────────────── + + def pitch_bend( + self, value: float, beat: float = 0.0 + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Send a single pitch bend message at a beat position. + + Parameters: + value: Pitch bend amount, normalised from -1.0 to 1.0. + beat: Beat position within the pattern. + """ + + # The asymmetric clamp is correct: MIDI's 14-bit bend range is -8192..+8191. + midi_value = max(-8192, min(8191, int(round(value * 8192)))) + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, message_type="pitchwheel", value=midi_value + ) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def pitch_bend_ramp( + self, + start: float, + end: float, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + resolution: int = 1, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Interpolate pitch bend over a beat range. + + Parameters: + start: Starting pitch bend (-1.0 to 1.0). + end: Ending pitch bend (-1.0 to 1.0). + beat_start: Beat position to begin the ramp. + beat_end: Beat position to end the ramp. Defaults to pattern length. + resolution: Pulses between pitch bend messages (1 = every pulse). + Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound + stepped at slow tempos. + shape: Easing curve — a name string (e.g. ``"ease_out"``) or any + callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. + See :mod:`subsequence.easing` for available shapes. + """ + + if beat_end is None: + beat_end = self._pattern.length + + def _event(pulse: int, val: float) -> None: + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="pitchwheel", + value=max(-8192, min(8191, int(round(val * 8192)))), + ) + ) + + self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + # ── RPN / NRPN parameter control ──────────────────────────────────────── + # RPN (Registered) and NRPN (Non-Registered) Parameter Numbers are the + # standard MIDI conventions for addressing parameters beyond the 128 CC + # slots, with optional 14-bit value precision. Both are sequences of + # regular control_change messages co-scheduled at the same pulse: + # CC 99 / 98 NRPN parameter MSB / LSB (or 101 / 100 for RPN) + # CC 6 / 38 Data Entry MSB / LSB + # CC 101=127, 100=127 NULL — defensive deselect + # The Sequencer's MidiEvent.sequence tie-breaker preserves emission order + # at the same pulse, so the synth assigns the value to the right parameter. + + def _append_param_select( + self, pulse: int, parameter: int, msb_cc: int, lsb_cc: int + ) -> None: + """Emit the two-CC parameter-select pair (NRPN: 99/98, RPN: 101/100). + + Events are emitted on the pattern's channel — leaving ``CcEvent.channel`` + unset (None) lets the sequencer fall through to ``pattern.channel`` + at dispatch time, which is the normal behaviour for every other CC + method on this mixin. + """ + + param_msb, param_lsb = pymididefs.cc.pack_14bit(parameter) + + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=msb_cc, + value=param_msb, + ) + ) + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=lsb_cc, + value=param_lsb, + ) + ) + + def _append_data_entry(self, pulse: int, value: int, fine: bool) -> None: + """Emit Data Entry MSB (and LSB if fine=True) for a parameter value.""" + + if fine: + value_msb, value_lsb = pymididefs.cc.pack_14bit(value) + else: + if not 0 <= value <= 127: + raise ValueError( + f"NRPN/RPN value must be 0–127 when fine=False, got {value}" + ) + value_msb = value + value_lsb = None + + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=pymididefs.cc.DATA_ENTRY_MSB, + value=value_msb, + ) + ) + + if value_lsb is not None: + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=pymididefs.cc.DATA_ENTRY_LSB, + value=value_lsb, + ) + ) + + def _validate_ramp_endpoints(self, start: int, end: int, fine: bool) -> None: + """Reject out-of-range NRPN/RPN ramp endpoints up front. + + Mirrors the strict behaviour of ``_append_data_entry`` for one-shots + so a typo (e.g. forgetting ``fine=True`` with a 14-bit value) raises + immediately rather than silently clamping at every ramp step. + """ + + limit = 16383 if fine else 127 + + for label, value in (("start", start), ("end", end)): + if not 0 <= value <= limit: + raise ValueError( + f"NRPN/RPN ramp {label} must be 0–{limit} (fine={fine}), got {value}" + ) + + def _append_null_reset(self, pulse: int) -> None: + """Emit the RPN NULL sentinel (CC 101=127, CC 100=127) to deselect. + + Defensive practice: prevents a stray later CC 6 / 38 from being + applied to whichever parameter was last selected on this channel. + """ + + null_msb, null_lsb = pymididefs.cc.pack_14bit(pymididefs.rpn.NULL_PARAMETER) + + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=pymididefs.cc.RPN_MSB, + value=null_msb, + ) + ) + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=pymididefs.cc.RPN_LSB, + value=null_lsb, + ) + ) + + def nrpn( + self, + parameter: typing.Union[int, str], + value: int, + beat: float = 0.0, + fine: bool = False, + null_reset: bool = True, + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Send a single NRPN parameter write at a beat position. + + NRPN (Non-Registered Parameter Number) addresses synth-specific + parameters that don't fit into the 128 standard CC slots — Sequential, + Korg, Roland, Elektron and others use it heavily for filter cutoff, + envelope amounts, oscillator detune, and similar deep parameters. + Many such parameters need values beyond 0–127 (e.g. 0–1023, 0–254); + set ``fine=True`` for full 14-bit precision. + + Emitted on the pattern's MIDI channel. To target a different channel + (e.g. a per-channel RPN config), define a separate pattern on that + channel or use ``composition.trigger(channel=…)`` for a one-shot. + + Parameters: + parameter: 14-bit NRPN parameter number (0–16383), or a string + resolved via the pattern's ``nrpn_name_map``. + value: Parameter value. 0–127 if ``fine=False``; 0–16383 if + ``fine=True``. + beat: Beat position within the pattern. + fine: If True, send 14-bit value via Data Entry MSB+LSB + (CC 6 + CC 38). If False (default), send only Data Entry + MSB — sufficient for the common 0–127 range. + null_reset: If True (default), follow with the RPN null sentinel + to deselect the active parameter and prevent stray later + CC 6 / 38 messages from hitting it. + + Example: + ```python + # Sequential Take 5 fine-tune (14-bit, range 0–1400) + p.nrpn(9, 700, fine=True) + + # Roland JV-1080 reverb level (7-bit) + p.nrpn(0x0140, 80) + ``` + """ + + param = self._resolve_nrpn(parameter) + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + + self._append_param_select( + pulse, param, pymididefs.cc.NRPN_MSB, pymididefs.cc.NRPN_LSB + ) + self._append_data_entry(pulse, value, fine) + + if null_reset: + self._append_null_reset(pulse) + + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def rpn( + self, + parameter: typing.Union[int, str], + value: int, + beat: float = 0.0, + fine: bool = False, + null_reset: bool = True, + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Send a single RPN parameter write at a beat position. + + RPN (Registered Parameter Number) addresses the small standardised + set of parameters defined by the MIDI specification — pitch bend + range, master tuning, modulation depth — supported by virtually any + MIDI synth. String names resolve via ``pymididefs.rpn.RPN_MAP`` + out of the box, no map needed. + + Standard RPN names: ``pitch_bend_sensitivity``, + ``channel_fine_tuning``, ``channel_coarse_tuning``, + ``tuning_program_select``, ``tuning_bank_select``, + ``modulation_depth_range``. + + Emitted on the pattern's MIDI channel. + + Parameters: + parameter: 14-bit RPN parameter number (0–16383), or one of the + standard string names above. + value: Parameter value. 0–127 if ``fine=False``; 0–16383 if + ``fine=True``. Pitch bend sensitivity uses MSB = semitones + and LSB = cents, so set ``fine=True`` for sub-semitone control. + beat: Beat position within the pattern. + fine: If True, send 14-bit value via Data Entry MSB+LSB. + null_reset: If True (default), follow with the RPN null sentinel. + + Example: + ```python + # Set pitch bend range to ±12 semitones + p.rpn("pitch_bend_sensitivity", 12) + + # 4 semitones plus 50 cents + p.rpn("pitch_bend_sensitivity", 4 * 128 + 50, fine=True) + ``` + """ + + param = self._resolve_rpn(parameter) + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + + self._append_param_select( + pulse, param, pymididefs.cc.RPN_MSB, pymididefs.cc.RPN_LSB + ) + self._append_data_entry(pulse, value, fine) + + if null_reset: + self._append_null_reset(pulse) + + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def nrpn_ramp( + self, + parameter: typing.Union[int, str], + start: int, + end: int, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + resolution: int = 4, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + fine: bool = True, + null_reset: bool = True, + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Interpolate an NRPN value over a beat range. + + The parameter is selected once at ``beat_start``; subsequent steps + emit only Data Entry messages. Synths track the most recently + selected NRPN per the spec, so re-selecting per step would just + waste bandwidth. If ``null_reset=True`` the RPN null sentinel is + appended once at ``beat_end``. + + **Mid-ramp parameter persistence:** between ``beat_start`` and + ``beat_end`` the synth still has this NRPN selected. Avoid issuing + ``p.cc(6, …)`` or ``p.cc(38, …)`` on the same channel during the + ramp window — they would land on the ramped parameter rather than + acting as plain data-entry CCs. + + Bandwidth note: with ``fine=True`` (default) every step emits two + CCs. Default ``resolution=4`` is one update every four pulses + (~83 ms at 120 BPM, where one pulse is ~21 ms), which keeps the bus + lightly loaded. Increase + ``resolution`` (e.g. ``8``) on slow DIN-MIDI links if you hear + other messages getting delayed. + + Emitted on the pattern's MIDI channel. + + Parameters: + parameter: 14-bit NRPN parameter number, or a string resolved + via the pattern's ``nrpn_name_map``. + start: Starting value (0–16383 when ``fine=True``, 0–127 when False). + end: Ending value. + beat_start: Beat position to begin the ramp. + beat_end: Beat position to end the ramp. Defaults to pattern length. + resolution: Pulses between Data Entry messages (default 4). + shape: Easing curve — string name or callable [0, 1] → [0, 1]. + fine: If True (default), use full 14-bit Data Entry MSB+LSB. + null_reset: If True (default), append the null sentinel at the + end of the ramp (not per step). + """ + + param = self._resolve_nrpn(parameter) + self._validate_ramp_endpoints(start, end, fine) + + if beat_end is None: + beat_end = self._pattern.length + + pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE) + + self._append_param_select( + int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), + param, + pymididefs.cc.NRPN_MSB, + pymididefs.cc.NRPN_LSB, + ) + + def _event(pulse: int, val: float) -> None: + # Clamp guards against custom easing callables that overshoot [0, 1]. + if fine: + value = max(0, min(16383, int(round(val)))) + else: + value = max(0, min(127, int(round(val)))) + self._append_data_entry(pulse, value, fine) + + self._ramp_pulses( + beat_start, beat_end, float(start), float(end), shape, resolution, _event + ) + + if null_reset: + self._append_null_reset(pulse_end) + + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def rpn_ramp( + self, + parameter: typing.Union[int, str], + start: int, + end: int, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + resolution: int = 4, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + fine: bool = True, + null_reset: bool = True, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Interpolate an RPN value over a beat range. + + Identical to :meth:`nrpn_ramp` but uses CC 101 / 100 for parameter + selection. String names resolve via ``pymididefs.rpn.RPN_MAP``. + The same mid-ramp persistence note applies: avoid plain ``p.cc(6, …)`` + on this channel during the ramp window. + """ + + param = self._resolve_rpn(parameter) + self._validate_ramp_endpoints(start, end, fine) + + if beat_end is None: + beat_end = self._pattern.length + + pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE) + + self._append_param_select( + int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), + param, + pymididefs.cc.RPN_MSB, + pymididefs.cc.RPN_LSB, + ) + + def _event(pulse: int, val: float) -> None: + # Clamp guards against custom easing callables that overshoot [0, 1]. + if fine: + value = max(0, min(16383, int(round(val)))) + else: + value = max(0, min(127, int(round(val)))) + self._append_data_entry(pulse, value, fine) + + self._ramp_pulses( + beat_start, beat_end, float(start), float(end), shape, resolution, _event + ) + + if null_reset: + self._append_null_reset(pulse_end) + + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + # ── Program change and SysEx ───────────────────────────────────────────── + + def program_change( + self, + program: int, + beat: float = 0.0, + bank_msb: typing.Optional[int] = None, + bank_lsb: typing.Optional[int] = None, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Send a Program Change message, optionally preceded by bank select. + + Switches the instrument patch on this pattern's MIDI channel. + Program numbers follow the General MIDI numbering (0–127, where + e.g. 0 = Acoustic Grand Piano, 40 = Violin, 33 = Electric Bass). + + To select a patch in a specific bank, provide ``bank_msb`` and/or + ``bank_lsb``. The bank select CC messages (CC 0 for MSB, CC 32 for + LSB) are sent at the same beat position immediately before the + program change, in the order the synthesizer expects. + + Parameters: + program: Program (patch) number (0–127). + beat: Beat position within the pattern (default 0.0). + bank_msb: Bank select coarse (CC 0), 0–127. ``None`` = omit. + bank_lsb: Bank select fine (CC 32), 0–127. ``None`` = omit. + + Example: + ```python + @composition.pattern(channel=1, beats=4) + def strings (p): + # GM — no bank needed + p.program_change(48) + + # Roland JV-1080 bank 1, patch 48 + p.program_change(48, bank_msb=81, bank_lsb=0) + + # Change patch only at the first bar of each section + if p.section.bar == 0: + p.program_change(48, bank_msb=1) + ``` + """ + + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + + if bank_msb is not None: + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=0, + value=max(0, min(127, int(round(bank_msb)))), + ) + ) + + if bank_lsb is not None: + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="control_change", + control=32, + value=max(0, min(127, int(round(bank_lsb)))), + ) + ) + + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="program_change", + value=max(0, min(127, int(round(program)))), + ) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def sysex( + self, data: typing.Union[bytes, typing.List[int]], beat: float = 0.0 + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Send a System Exclusive (SysEx) message at a beat position. + + SysEx messages allow deep integration with synthesizers and other + hardware: patch dumps, parameter control, and vendor-specific commands. + The ``data`` argument should contain only the inner payload bytes, + without the surrounding ``0xF0`` / ``0xF7`` framing — mido adds those + automatically. + + Parameters: + data: SysEx payload as ``bytes`` or a list of integers (0–127). + beat: Beat position within the pattern (default 0.0). + + Example: + ```python + # GM System On — reset a GM-compatible device to defaults + p.sysex([0x7E, 0x7F, 0x09, 0x01]) + ``` + """ + + # Validate at build time: MIDI sysex payloads are 7-bit. A byte over + # 127 would be rejected by mido at dispatch and the message silently + # dropped every cycle with a misleading "device disconnected" log. + invalid = [b for b in data if not 0 <= b <= 127] + + if invalid: + raise ValueError( + f"sysex data bytes must be 0-127 (7-bit MIDI data) — got {invalid[:4]}. " + "Mask computed values (checksums, packed parameters) with & 0x7F first." + ) + + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, message_type="sysex", data=bytes(data) + ) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + # ── OSC messages ───────────────────────────────────────────────────────── + + def osc( + self, address: str, *args: typing.Any, beat: float = 0.0 + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Send an OSC message at a beat position. + + Requires ``composition.osc()`` to be called before ``composition.play()``. + If no OSC server is configured the event is silently dropped. + + Parameters: + address: OSC address path (e.g. ``"/mixer/fader/1"``). + ``*args``: OSC arguments — float, int, str, or bytes. + beat: Beat position within the pattern (default 0.0). + + Example: + ```python + # Enable a chorus effect at beat 2 + p.osc("/fx/chorus/enable", 1, beat=2.0) + + # Set a mixer pan value immediately + p.osc("/mixer/pan/1", -0.5) + ``` + """ + + pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE) + + self._pattern.osc_events.append( + subsequence.pattern.OscEvent(pulse=pulse, address=address, args=args) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def osc_ramp( + self, + address: str, + start: float, + end: float, + beat_start: float = 0.0, + beat_end: typing.Optional[float] = None, + resolution: int = 4, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + ) -> "subsequence.pattern_builder.PatternBuilder": + """ + Interpolate an OSC float value over a beat range. + + Generates one OSC message per ``resolution`` pulses, sending the + interpolated value to ``address`` at each step. Useful for smoothly + automating mixer faders, effect parameters, and other continuous controls + on a remote machine. + + Requires ``composition.osc()`` to be called before ``composition.play()``. + If no OSC server is configured the events are silently dropped. + + Parameters: + address: OSC address path (e.g. ``"/mixer/fader/1"``). + start: Starting float value. + end: Ending float value. + beat_start: Beat position to begin the ramp (default 0.0). + beat_end: Beat position to end the ramp. Defaults to pattern length. + resolution: Pulses between OSC messages (default 4 — approximately + 6 messages per beat at 120 BPM, which is smooth for fader + automation while keeping UDP traffic light). Use ``resolution=1`` + for pulse-level precision. + shape: Easing curve — a name string (e.g. ``"ease_in"``) or any + callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. + See :mod:`subsequence.easing` for available shapes. + + Example: + ```python + # Fade a mixer fader up over 4 beats + p.osc_ramp("/mixer/fader/1", start=0.0, end=1.0) + + # Ease in a reverb send over the last 2 beats + p.osc_ramp("/fx/reverb/wet", 0.0, 0.8, beat_start=2, beat_end=4, shape="ease_in") + ``` + """ + + if beat_end is None: + beat_end = self._pattern.length + + def _event(pulse: int, val: float) -> None: + self._pattern.osc_events.append( + subsequence.pattern.OscEvent(pulse=pulse, address=address, args=(val,)) + ) + + self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + # ── Note-correlated pitch bend ──────────────────────────────────────────── + + def _generate_bend_events( + self, + start_value: float, + end_value: float, + pulse_start: int, + pulse_end: int, + resolution: int, + shape: typing.Union[str, subsequence.easing.EasingFn], + ) -> None: + """Generate a series of pitchwheel CcEvents between two pulse positions. + + Used by ``bend()``, ``portamento()``, and ``slide()``. Delegates the + span/resolution walk (including the emit-the-endpoint rule) to + :meth:`_ramp_pulse_span` and contributes only the pitchwheel + conversion — normalised value scaled to 14-bit and clamped — appending + events directly to ``self._pattern.cc_events``. + + Parameters: + start_value: Normalised bend at the start of the ramp (-1.0 to 1.0). + end_value: Normalised bend at the end of the ramp (-1.0 to 1.0). + pulse_start: Absolute pulse position to start the ramp. + pulse_end: Absolute pulse position to end the ramp. + resolution: Number of pulses between consecutive events. + shape: Easing curve name or callable. + """ + + def _event(pulse: int, val: float) -> None: + midi_value = max(-8192, min(8191, int(round(val * 8192)))) + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=pulse, + message_type="pitchwheel", + value=midi_value, + ) + ) + + self._ramp_pulse_span( + pulse_start, pulse_end, start_value, end_value, shape, resolution, _event + ) + + def bend( + self, + note: int, + amount: float, + start: float = 0.0, + end: float = 1.0, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + resolution: int = 1, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Bend a specific note by index. + + Generates a pitch bend ramp that covers a fraction of the target note's + duration, then resets to 0.0 at the next note's onset. Call this + *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final. + + Parameters: + note: Note index (0 = first, -1 = last, etc.). + amount: Target bend normalised to -1.0..1.0 (positive = up). + With a standard ±2-semitone pitch wheel range, 0.5 = 1 semitone. + start: Fraction of the note's duration at which the ramp begins + (0.0 = note onset, default). + end: Fraction of the note's duration at which the ramp ends + (1.0 = note end, default). + shape: Easing curve — a name string (e.g. ``"ease_in"``) or any + callable mapping [0, 1] → [0, 1]. Defaults to ``"linear"``. + resolution: Pulses between pitch bend messages. + + Raises: + IndexError: If *note* is out of range for the current pattern. + + Example: + ```python + p.sequence(steps=[0, 4, 8, 12], pitches=midi_notes.E1) + p.legato(0.95) + + # Bend the last note up one semitone (with ±2 st range), easing in + p.bend(note=-1, amount=0.5, shape="ease_in") + + # Bend the second note down, starting halfway through + p.bend(note=1, amount=-0.3, start=0.5) + ``` + """ + + if not self._pattern.steps: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + sorted_positions = sorted(self._pattern.steps.keys()) + + # Resolve note index (supports negative indexing) + position = sorted_positions[note] + note_idx = note if note >= 0 else len(sorted_positions) + note + + # Duration: use the longest note at this step + step = self._pattern.steps[position] + note_duration = max(n.duration for n in step.notes) + + # Clamp start/end fractions and compute pulse range for the ramp + start_clamped = max(0.0, min(1.0, start)) + end_clamped = max(0.0, min(1.0, end)) + bend_start_pulse = position + int(note_duration * start_clamped) + bend_end_pulse = position + int(note_duration * end_clamped) + + self._generate_bend_events( + 0.0, amount, bend_start_pulse, bend_end_pulse, resolution, shape + ) + + # Reset bend at the next note's onset. For the last note that is the + # NEXT cycle's first onset (total + first), not pulse 0 - a bend tail + # spilling past the cycle end was cancelled mid-flight by a pulse-0 + # reset, leaving the next cycle's first note bent. + if note_idx < len(sorted_positions) - 1: + reset_pulse = sorted_positions[note_idx + 1] + else: + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + reset_pulse = total_pulses + sorted_positions[0] + + reset_midi = max(-8192, min(8191, int(round(0.0 * 8192)))) + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=reset_pulse, + message_type="pitchwheel", + value=reset_midi, + ) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def portamento( + self, + time: float = 0.15, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + resolution: int = 1, + bend_range: typing.Optional[float] = 2.0, + wrap: bool = True, + ) -> "subsequence.pattern_builder.PatternBuilder": + """Glide between all consecutive notes using pitch bend. + + Generates a pitch bend ramp in the tail of each note, bending toward + the next note's pitch, then resets at the next note's onset. Call this + *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final. + + Most effective on mono instruments where pitch bend is per-channel. + + Parameters: + time: Fraction of each note's duration used for the glide + (default 0.15 — last 15% of the note). + shape: Easing curve. Defaults to ``"linear"``. + resolution: Pulses between pitch bend messages. + bend_range: Instrument's pitch wheel range in semitones + (default 2.0 — standard ±2 st). Pairs with intervals larger + than this value are skipped. Pass ``None`` to disable range + checking and always generate the bend (large intervals are + clamped to ±1.0). + wrap: If ``True`` (default), glide from the last note toward the + first note of the next cycle. + + Example: + ```python + p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) + p.legato(0.95) + + # Gentle glide across all note transitions + p.portamento(time=0.15, shape="ease_in_out") + + # Wide bend range (synth set to ±12 semitones) + p.portamento(time=0.2, bend_range=12) + + # No range limit — bend as far as MIDI allows + p.portamento(time=0.1, bend_range=None) + ``` + """ + + if bend_range is not None and bend_range <= 0: + raise ValueError( + f"bend_range must be a positive number of semitones (your instrument's " + f"pitch-wheel range) — got {bend_range}. Pass None to disable range checking." + ) + + if not self._pattern.steps: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + sorted_positions = sorted(self._pattern.steps.keys()) + n = len(sorted_positions) + + def _lowest_pitch(pos: int) -> int: + return min(note.pitch for note in self._pattern.steps[pos].notes) + + def _longest_duration(pos: int) -> int: + return max(note.duration for note in self._pattern.steps[pos].notes) + + for i in range(n): + a_pos = sorted_positions[i] + is_last = i == n - 1 + + if is_last: + if not wrap: + continue + b_pos = sorted_positions[0] + else: + b_pos = sorted_positions[i + 1] + + interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos) + + if bend_range is not None and abs(interval) > bend_range: + continue + + normaliser = bend_range if bend_range is not None else 2.0 + amount = max(-1.0, min(1.0, interval / normaliser)) + + a_duration = _longest_duration(a_pos) + glide_start_pulse = a_pos + int(a_duration * (1.0 - time)) + glide_end_pulse = a_pos + a_duration + + self._generate_bend_events( + 0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape + ) + + # Reset at the destination note's onset. For the wrap-around pair + # that is the NEXT cycle's first onset (total + first), not pulse 0 + # - a glide spilling past the cycle end was cancelled mid-flight by + # the pulse-0 reset, leaving the first note fully bent. + if is_last: + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + reset_pulse = total_pulses + sorted_positions[0] + else: + reset_pulse = b_pos + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=reset_pulse, + message_type="pitchwheel", + value=0, + ) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + def slide( + self, + notes: typing.Optional[typing.List[int]] = None, + steps: typing.Optional[typing.List[int]] = None, + time: float = 0.15, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + resolution: int = 1, + bend_range: typing.Optional[float] = 2.0, + wrap: bool = True, + extend: bool = True, + ) -> "subsequence.pattern_builder.PatternBuilder": + """TB-303-style selective slide into specific notes. + + Like ``portamento()`` but only applies to flagged destination notes. + Specify target notes by index (``notes=[1, 3]``) or by step grid + position (``steps=[4, 12]``). If ``extend=True`` (default) the + preceding note's duration is extended to meet the slide target, matching + the 303's behaviour where slide notes do not retrigger. + + Call this *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations + are final. + + Parameters: + notes: List of note indices to slide *into* (0 = first). + Supports negative indexing. Mutually exclusive with *steps*. + steps: List of step grid indices to slide *into*. + Converted to pulse positions using ``self._default_grid``. + Mutually exclusive with *notes*. + time: Fraction of the preceding note's duration used for the glide. + shape: Easing curve. Defaults to ``"linear"``. + resolution: Pulses between pitch bend messages. + bend_range: Instrument's pitch wheel range in semitones + (default 2.0). Pairs with larger intervals are skipped. + Pass ``None`` to disable range checking. + wrap: If ``True`` (default), include a wrap-around slide from the + last note back toward the first. + extend: If ``True`` (default), extend the preceding note's duration + to reach the slide target's onset — 303-style legato through + the glide. + + Raises: + ValueError: If neither or both of *notes* and *steps* are provided, + or a note index falls outside the pattern's notes. + + Example: + ```python + p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) + p.legato(0.95) + + # Slide into the 2nd and 4th notes + p.slide(notes=[1, 3], time=0.2, shape="ease_in") + + # Same using step grid indices + p.slide(steps=[4, 12], time=0.2, shape="ease_in") + + # Slide without extending the preceding note + p.slide(notes=[1, 3], extend=False) + ``` + """ + + if notes is None and steps is None: + raise ValueError("slide() requires either 'notes' or 'steps'") + + if notes is not None and steps is not None: + raise ValueError( + "slide() takes notes= or steps=, not both — they name the same slide targets two different ways" + ) + + if bend_range is not None and bend_range <= 0: + raise ValueError( + f"bend_range must be a positive number of semitones (your instrument's " + f"pitch-wheel range) — got {bend_range}. Pass None to disable range checking." + ) + + if not self._pattern.steps: + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) + + sorted_positions = sorted(self._pattern.steps.keys()) + total_pulses = int( + self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE + ) + n = len(sorted_positions) + + # Resolve flagged pulse positions + if notes is not None: + flagged: typing.Set[int] = set() + for idx in notes: + if not -n <= idx < n: + raise ValueError( + f"slide() note index {idx} is outside this pattern's {n} notes" + ) + + flagged.add(sorted_positions[idx]) + else: + # steps is not None. Resolve each grid step to the SAME pulse the + # placement methods use — int(step * (length / grid) * PPQ) — so the + # flag lands on the note even when the grid doesn't divide the bar + # evenly. Floored uniform spacing (total_pulses // grid) drifts out of + # alignment on non-divisor grids, silently flagging nothing. + step_beats = self._pattern.length / self._default_grid + flagged = set() + for s in steps or []: + flagged.add( + int(s * step_beats * subsequence.constants.MIDI_QUARTER_NOTE) + ) + + def _lowest_pitch(pos: int) -> int: + return min(note.pitch for note in self._pattern.steps[pos].notes) + + def _longest_duration(pos: int) -> int: + return max(note.duration for note in self._pattern.steps[pos].notes) + + for i in range(n): + a_pos = sorted_positions[i] + is_last = i == n - 1 + + if is_last: + if not wrap: + continue + b_pos = sorted_positions[0] + else: + b_pos = sorted_positions[i + 1] + + # Only generate glide if the destination is flagged + if b_pos not in flagged: + continue + + interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos) + + if bend_range is not None and abs(interval) > bend_range: + continue + + normaliser = bend_range if bend_range is not None else 2.0 + amount = max(-1.0, min(1.0, interval / normaliser)) + + # Optionally extend preceding note to meet the target onset (303 style) + if extend: + if is_last: + gap = (total_pulses - a_pos) + sorted_positions[0] + else: + gap = b_pos - a_pos + for note in self._pattern.steps[a_pos].notes: + note.duration = gap + + # Read the duration AFTER any extension so the glide occupies the + # tail of the note as actually played and lands on the target + # onset. (Reading it before the extend block made the bend jump + # near the note's start and then hold flat - the opposite of a + # slide.) + a_duration = _longest_duration(a_pos) + + glide_start_pulse = a_pos + int(a_duration * (1.0 - time)) + glide_end_pulse = a_pos + a_duration + + self._generate_bend_events( + 0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape + ) + + # Reset at the destination note's onset. For the wrap-around pair + # the destination is the NEXT cycle's first onset (total_pulses + + # first onset): resetting at pulse 0 fired while a spilled glide + # was still in flight, so the destination note played fully bent. + reset_pulse = b_pos if not is_last else total_pulses + sorted_positions[0] + self._pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=reset_pulse, + message_type="pitchwheel", + value=0, + ) + ) + return typing.cast("subsequence.pattern_builder.PatternBuilder", self) diff --git a/subsequence/progressions.py b/subsequence/progressions.py index d3004c9..84c82c3 100644 --- a/subsequence/progressions.py +++ b/subsequence/progressions.py @@ -7,11 +7,11 @@ Construction (the standard form — lists, parsed per element): - subsequence.progression([1, 6, 3, 7]) # diatonic degrees - subsequence.progression([1, 6, 3, "bVII7"]) # romans where chromatic - subsequence.progression(["Am", "F", "C", "G"]) # chord names - subsequence.progression([("Am", 4), ("F", 2)]) # per-chord beats - subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) + subsequence.progression([1, 6, 3, 7]) # diatonic degrees + subsequence.progression([1, 6, 3, "bVII7"]) # romans where chromatic + subsequence.progression(["Am", "F", "C", "G"]) # chord names + subsequence.progression([("Am", 4), ("F", 2)]) # per-chord beats + subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) Key-relative content (ints and romans) stays relative inside the value and resolves at query time — change the key once, everything follows. Spice @@ -43,7 +43,9 @@ # A harmonic-rhythm spec is a single length (static), a list of lengths (a shaped # rhythm, cycled per chord), or a between(...) range (bounded, optionally quantised). -HarmonicRhythmSpec = typing.Union[int, float, typing.List[float], subsequence.harmonic_rhythm.HarmonicRhythm] +HarmonicRhythmSpec = typing.Union[ + int, float, typing.List[float], subsequence.harmonic_rhythm.HarmonicRhythm +] # Voicing density: a fixed number of voices, or a (low, high) random range per chord. VoicingSpec = typing.Union[int, typing.Tuple[int, int]] @@ -62,46 +64,102 @@ # numerals (which would read the resolution scale's degree and come out wrong # under the default ionian). _PRESETS: typing.Dict[str, typing.List[typing.Any]] = { - # Pop / rock. - "pop_axis": ["I", "V", "vi", "IV"], # the "Axis of Awesome" four chords - "pop_axis_vi_start": ["vi", "IV", "I", "V"], # the axis rotated to a minor start - "doo_wop": ["I", "vi", "IV", "V"], # the 50s progression - "doo_wop_ii": ["I", "vi", "ii", "V"], # doo-wop with ii for IV - # EDM / trance / cinematic minor. - "trance_epic": ["i", "bVI", "bIII", "bVII"], # uplifting-trance minor loop - "trance_vamp": ["i", "bVII", "bVI", "bVII"], # hypnotic two-direction minor vamp - "minor_pop": ["i", "bVI", "bVII", "i"], # aeolian rise back to the tonic - # Minor / modal. - "andalusian": ["i", "bVII", "bVI", "V"], # the Andalusian cadence (descending tetrachord) - "phrygian_vamp": ["i", "bII", "i", "bII"], # the Spanish/Phrygian half-step - "dorian_vamp": ["i", "IV"], # minor tonic to the major (dorian) IV - "mixolydian_vamp": ["I", "bVII", "IV", "I"], # the classic-rock bVII - # Jazz. - "ii_v_i": ["ii7", "V7", "Imaj7"], # the major ii-V-I - "ii_v_i_minor": ["iiø7", "V7", "i"], # the minor ii-V-i - "rhythm_changes_a": ["I", "vi", "ii", "V", "I", "vi", "ii", "V", "I", "I7", "IV", "iv", "I", "V", "I", "I"], - # Blues. - "twelve_bar_blues": ["I7", "I7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "V7"], - "quick_change_blues": ["I7", "IV7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "V7"], - # Classic / baroque loops. - "pachelbel": ["I", "V", "vi", "iii", "IV", "I", "IV", "V"], # Pachelbel's Canon - "pachelbel_minor": ["i", "v", "bVI", "bIII", "iv", "i", "iv", "V"], # its minor rendering + # Pop / rock. + "pop_axis": ["I", "V", "vi", "IV"], # the "Axis of Awesome" four chords + "pop_axis_vi_start": ["vi", "IV", "I", "V"], # the axis rotated to a minor start + "doo_wop": ["I", "vi", "IV", "V"], # the 50s progression + "doo_wop_ii": ["I", "vi", "ii", "V"], # doo-wop with ii for IV + # EDM / trance / cinematic minor. + "trance_epic": ["i", "bVI", "bIII", "bVII"], # uplifting-trance minor loop + "trance_vamp": ["i", "bVII", "bVI", "bVII"], # hypnotic two-direction minor vamp + "minor_pop": ["i", "bVI", "bVII", "i"], # aeolian rise back to the tonic + # Minor / modal. + "andalusian": [ + "i", + "bVII", + "bVI", + "V", + ], # the Andalusian cadence (descending tetrachord) + "phrygian_vamp": ["i", "bII", "i", "bII"], # the Spanish/Phrygian half-step + "dorian_vamp": ["i", "IV"], # minor tonic to the major (dorian) IV + "mixolydian_vamp": ["I", "bVII", "IV", "I"], # the classic-rock bVII + # Jazz. + "ii_v_i": ["ii7", "V7", "Imaj7"], # the major ii-V-I + "ii_v_i_minor": ["iiø7", "V7", "i"], # the minor ii-V-i + "rhythm_changes_a": [ + "I", + "vi", + "ii", + "V", + "I", + "vi", + "ii", + "V", + "I", + "I7", + "IV", + "iv", + "I", + "V", + "I", + "I", + ], + # Blues. + "twelve_bar_blues": [ + "I7", + "I7", + "I7", + "I7", + "IV7", + "IV7", + "I7", + "I7", + "V7", + "IV7", + "I7", + "V7", + ], + "quick_change_blues": [ + "I7", + "IV7", + "I7", + "I7", + "IV7", + "IV7", + "I7", + "I7", + "V7", + "IV7", + "I7", + "V7", + ], + # Classic / baroque loops. + "pachelbel": ["I", "V", "vi", "iii", "IV", "I", "IV", "V"], # Pachelbel's Canon + "pachelbel_minor": [ + "i", + "v", + "bVI", + "bIII", + "iv", + "i", + "iv", + "V", + ], # its minor rendering } -class ChordEvent (typing.NamedTuple): +class ChordEvent(typing.NamedTuple): + """One chord on a realised timeline: which chord, when it starts, and how long + it lasts (in beats from the start of the part). - """One chord on a realised timeline: which chord, when it starts, and how long - it lasts (in beats from the start of the part). + A ``NamedTuple``, so it unpacks positionally as ``(chord, start, length)`` — the + idiom for looping a progression — while also offering ``.chord`` / ``.start`` / + ``.length`` attribute access. + """ - A ``NamedTuple``, so it unpacks positionally as ``(chord, start, length)`` — the - idiom for looping a progression — while also offering ``.chord`` / ``.start`` / - ``.length`` attribute access. - """ - - chord: typing.Any - start: float - length: float + chord: typing.Any + start: float + length: float # --------------------------------------------------------------------------- @@ -111,241 +169,258 @@ class ChordEvent (typing.NamedTuple): @dataclasses.dataclass(frozen=True) class PitchSet: + """A nameless sonority — a frozen set of absolute MIDI pitches. - """A nameless sonority — a frozen set of absolute MIDI pitches. - - The escape hatch for chords with no root or quality: clusters, spectral - stacks, found objects. It duck-types ``.tones()`` so every placement verb - and the injected ``chord`` accept it unchanged. By design it is excluded - from generation and diatonic spice (there is nothing to transpose - diatonically), and a progression containing one loops on exhaustion - rather than falling through to live graph stepping. - - Pitches are absolute: ``tones()`` ignores its ``root`` argument — you - chose the register when you chose the pitches. - """ - - pitches: typing.Tuple[int, ...] - - def __init__ (self, pitches: typing.Iterable[int]) -> None: + The escape hatch for chords with no root or quality: clusters, spectral + stacks, found objects. It duck-types ``.tones()`` so every placement verb + and the injected ``chord`` accept it unchanged. By design it is excluded + from generation and diatonic spice (there is nothing to transpose + diatonically), and a progression containing one loops on exhaustion + rather than falling through to live graph stepping. - """Normalise any iterable of MIDI pitches into a sorted frozen tuple.""" + Pitches are absolute: ``tones()`` ignores its ``root`` argument — you + chose the register when you chose the pitches. + """ - values = tuple(sorted(int(p) for p in pitches)) + pitches: typing.Tuple[int, ...] - if not values: - raise ValueError("PitchSet needs at least one pitch") + def __init__(self, pitches: typing.Iterable[int]) -> None: + """Normalise any iterable of MIDI pitches into a sorted frozen tuple.""" - object.__setattr__(self, "pitches", values) + values = tuple(sorted(int(p) for p in pitches)) - def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: + if not values: + raise ValueError("PitchSet needs at least one pitch") - """Return the pitches (absolute — *root* is ignored by design). + object.__setattr__(self, "pitches", values) - ``inversion`` rotates pitches up an octave; ``count`` cycles the set - into higher octaves, matching the ``Chord.tones`` contract. - """ + def tones( + self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None + ) -> typing.List[int]: + """Return the pitches (absolute — *root* is ignored by design). - pitches = list(self.pitches) + ``inversion`` rotates pitches up an octave; ``count`` cycles the set + into higher octaves, matching the ``Chord.tones`` contract. + """ - if inversion != 0: - for _ in range(inversion % len(pitches)): - pitches.append(pitches.pop(0) + 12) + pitches = list(self.pitches) - if count is not None: - n = len(pitches) - return [pitches[i % n] + 12 * (i // n) for i in range(count)] + if inversion != 0: + for _ in range(inversion % len(pitches)): + pitches.append(pitches.pop(0) + 12) - return pitches + if count is not None: + n = len(pitches) + return [pitches[i % n] + 12 * (i // n) for i in range(count)] - def intervals (self) -> typing.List[int]: + return pitches - """Semitone offsets from the lowest pitch (the ``Chord`` protocol).""" + def intervals(self) -> typing.List[int]: + """Semitone offsets from the lowest pitch (the ``Chord`` protocol).""" - return [p - self.pitches[0] for p in self.pitches] + return [p - self.pitches[0] for p in self.pitches] - def name (self) -> str: + def name(self) -> str: + """A readable label for describe() output.""" - """A readable label for describe() output.""" - - return "PitchSet(" + ", ".join(str(p) for p in self.pitches) + ")" + return "PitchSet(" + ", ".join(str(p) for p in self.pitches) + ")" @dataclasses.dataclass(frozen=True) class RomanChord: - - """A key-relative chord — a scale degree with optional explicit quality. - - Internal: users only ever meet it as an int or roman string element inside - a progression list. It stays relative inside the value and resolves to a - concrete :class:`~subsequence.chords.Chord` at query time against a key - and scale. - - Attributes: - degree: 1-based scale degree. - accidental: -1 for a ``b`` prefix, +1 for ``#``. An accidental- - prefixed degree reads against the **major** scale, the universal - roman convention — ``bVII`` is always the whole step below the - tonic (Bb in C major, G in A minor); unprefixed degrees read the - current scale (``VII`` in A minor is already G). - quality: Explicit quality name, or ``None`` to infer diatonically - from the key and scale (the bare-int path). - of: Secondary-function target degree (``V/x`` — one level only). - The numeral resolves against the major scale on the target's - root, the common-practice reading. - borrowed: When True, the degree resolves against the parallel scale - (modal interchange) — set by :meth:`Progression.borrow`. - source_text: The element as written, for unbound ``describe()``. - major_relative: When True, the degree always reads the major scale - (with the accidental applied), whatever scale ``resolve()`` is - given — the scale-proof spelling :meth:`Progression.generate` - emits, where quality is always explicit and the resolve scale - must not re-interpret the root. - """ - - degree: int - accidental: int = 0 - quality: typing.Optional[str] = None - of: typing.Optional[int] = None - borrowed: bool = False - source_text: str = "" - major_relative: bool = False - - def __post_init__ (self) -> None: - - """Validate the degree (1-based, like everything a musician counts).""" - - if self.degree < 1: - raise ValueError(f"scale degree must be 1 or higher, got {self.degree}") - - def label (self) -> str: - - """The element as written (for unbound describe() output).""" - - return self.source_text or str(self.degree) - - def resolve (self, key_pc: int, scale: str = "ionian") -> subsequence.chords.Chord: - - """Resolve to a concrete chord against a key and scale. - - Raises: - ValueError: If the scale is unknown, the degree exceeds the - scale, or quality inference is needed but the scale has no - chord qualities registered. - """ - - mode = "minor" if self.borrowed and scale != "minor" else ("ionian" if self.borrowed else scale) - - if mode not in subsequence.intervals.SCALE_MODE_MAP: - available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) - raise ValueError(f"Unknown scale: {mode!r}. Available: {available}") - - if self.of is not None: - # Secondary function: resolve the target degree's root, then read - # this numeral against the major scale on that root. - target = RomanChord(degree=self.of) - target_chord = target.resolve(key_pc, scale) - return dataclasses.replace(self, of=None).resolve(target_chord.root_pc, "ionian") - - pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) - - if self.accidental != 0 or self.major_relative: - # Accidental-prefixed degrees read against the major scale — the - # universal roman convention (bVII is the whole step below tonic - # in every key, major or minor). Generated spans set - # major_relative so their spelling is scale-proof — which is why - # the current scale's degree count must NOT be enforced here - # (bVII is a valid degree even under a five-note scale). - major_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "ionian") - root_pc = (major_pcs[(self.degree - 1) % len(major_pcs)] + self.accidental) % 12 - else: - if self.degree > len(pcs): - raise ValueError( - f"scale degree {self.degree} is out of range for {mode!r} " - f"({len(pcs)} degrees)" - ) - - root_pc = pcs[self.degree - 1] % 12 - - if self.quality is not None: - return subsequence.chords.Chord(root_pc=root_pc, quality=self.quality) - - _, qualities = subsequence.intervals.SCALE_MODE_MAP[mode] - - if qualities is None: - raise ValueError( - f"Scale {mode!r} has no chord qualities defined, so degree " - f"{self.degree} cannot be inferred. Use register_scale(..., " - "qualities=[...]) or write the chord name explicitly." - ) - - if self.degree > len(qualities): - raise ValueError( - f"cannot infer a chord quality for degree {self.degree} under " - f"{mode!r} ({len(qualities)} degrees) — write the quality " - "explicitly (e.g. 'bVII' rather than a bare accidental degree)" - ) - - return subsequence.chords.Chord(root_pc=root_pc, quality=qualities[self.degree - 1]) - - def diatonic_extension_intervals ( - self, - key_pc: int, - scale: str, - extensions: typing.Tuple[typing.Any, ...], - ) -> typing.Tuple[int, ...]: - - """Stack diatonic thirds above the triad for numeric extensions. - - Only meaningful for inferred-quality degrees (the bare-int path): - ``extend(7)`` on V in C major yields F natural (a dominant seventh), - where the colour rule on a concrete G chord would yield F#. - - A 9/11/13 implies every seventh-family tone below it — ``extend(9)`` - stacks the diatonic seventh AND the ninth, so a degree yields the - same chord class as the concrete-chord path (a ninth chord, not an - ``add9``). - """ - - mode = "minor" if self.borrowed and scale != "minor" else ("ionian" if self.borrowed else scale) - pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) - - if self.degree > len(pcs): - raise ValueError( - f"scale degree {self.degree} is out of range for {mode!r} " - f"({len(pcs)} degrees)" - ) - - root_pc = (pcs[self.degree - 1] + self.accidental) % 12 - - intervals: typing.List[int] = [] - - for extension in extensions: - - if not isinstance(extension, int): - continue # sus/add forms are scale-independent — the colour path handles them - - # 7 → six scale steps above the root; 9 → eight; 11 → ten; 13 → twelve. - top = {7: 6, 9: 8, 11: 10, 13: 12}.get(extension) - - if top is None: - continue - - # Stack every odd scale step from the seventh up to the extension. - for steps in range(6, top + 1, 2): - pc = pcs[(self.degree - 1 + steps) % len(pcs)] - octave = 0 if steps == 6 else 12 # 9ths/11ths/13ths live above the octave - intervals.append(((pc - root_pc) % 12) + octave) - - return tuple(sorted(set(intervals))) + """A key-relative chord — a scale degree with optional explicit quality. + + Internal: users only ever meet it as an int or roman string element inside + a progression list. It stays relative inside the value and resolves to a + concrete :class:`~subsequence.chords.Chord` at query time against a key + and scale. + + Attributes: + degree: 1-based scale degree. + accidental: -1 for a ``b`` prefix, +1 for ``#``. An accidental- + prefixed degree reads against the **major** scale, the universal + roman convention — ``bVII`` is always the whole step below the + tonic (Bb in C major, G in A minor); unprefixed degrees read the + current scale (``VII`` in A minor is already G). + quality: Explicit quality name, or ``None`` to infer diatonically + from the key and scale (the bare-int path). + of: Secondary-function target degree (``V/x`` — one level only). + The numeral resolves against the major scale on the target's + root, the common-practice reading. + borrowed: When True, the degree resolves against the parallel scale + (modal interchange) — set by :meth:`Progression.borrow`. + source_text: The element as written, for unbound ``describe()``. + major_relative: When True, the degree always reads the major scale + (with the accidental applied), whatever scale ``resolve()`` is + given — the scale-proof spelling :meth:`Progression.generate` + emits, where quality is always explicit and the resolve scale + must not re-interpret the root. + """ + + degree: int + accidental: int = 0 + quality: typing.Optional[str] = None + of: typing.Optional[int] = None + borrowed: bool = False + source_text: str = "" + major_relative: bool = False + + def __post_init__(self) -> None: + """Validate the degree (1-based, like everything a musician counts).""" + + if self.degree < 1: + raise ValueError(f"scale degree must be 1 or higher, got {self.degree}") + + def label(self) -> str: + """The element as written (for unbound describe() output).""" + + return self.source_text or str(self.degree) + + def resolve(self, key_pc: int, scale: str = "ionian") -> subsequence.chords.Chord: + """Resolve to a concrete chord against a key and scale. + + Raises: + ValueError: If the scale is unknown, the degree exceeds the + scale, or quality inference is needed but the scale has no + chord qualities registered. + """ + + mode = ( + "minor" + if self.borrowed and scale != "minor" + else ("ionian" if self.borrowed else scale) + ) + + if mode not in subsequence.intervals.SCALE_MODE_MAP: + available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) + raise ValueError(f"Unknown scale: {mode!r}. Available: {available}") + + if self.of is not None: + # Secondary function: resolve the target degree's root, then read + # this numeral against the major scale on that root. + target = RomanChord(degree=self.of) + target_chord = target.resolve(key_pc, scale) + return dataclasses.replace(self, of=None).resolve( + target_chord.root_pc, "ionian" + ) + + pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) + + if self.accidental != 0 or self.major_relative: + # Accidental-prefixed degrees read against the major scale — the + # universal roman convention (bVII is the whole step below tonic + # in every key, major or minor). Generated spans set + # major_relative so their spelling is scale-proof — which is why + # the current scale's degree count must NOT be enforced here + # (bVII is a valid degree even under a five-note scale). + major_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "ionian") + root_pc = ( + major_pcs[(self.degree - 1) % len(major_pcs)] + self.accidental + ) % 12 + else: + if self.degree > len(pcs): + raise ValueError( + f"scale degree {self.degree} is out of range for {mode!r} " + f"({len(pcs)} degrees)" + ) + + root_pc = pcs[self.degree - 1] % 12 + + if self.quality is not None: + return subsequence.chords.Chord(root_pc=root_pc, quality=self.quality) + + _, qualities = subsequence.intervals.SCALE_MODE_MAP[mode] + + if qualities is None: + raise ValueError( + f"Scale {mode!r} has no chord qualities defined, so degree " + f"{self.degree} cannot be inferred. Use register_scale(..., " + "qualities=[...]) or write the chord name explicitly." + ) + + if self.degree > len(qualities): + raise ValueError( + f"cannot infer a chord quality for degree {self.degree} under " + f"{mode!r} ({len(qualities)} degrees) — write the quality " + "explicitly (e.g. 'bVII' rather than a bare accidental degree)" + ) + + return subsequence.chords.Chord( + root_pc=root_pc, quality=qualities[self.degree - 1] + ) + + def diatonic_extension_intervals( + self, + key_pc: int, + scale: str, + extensions: typing.Tuple[typing.Any, ...], + ) -> typing.Tuple[int, ...]: + """Stack diatonic thirds above the triad for numeric extensions. + + Only meaningful for inferred-quality degrees (the bare-int path): + ``extend(7)`` on V in C major yields F natural (a dominant seventh), + where the colour rule on a concrete G chord would yield F#. + + A 9/11/13 implies every seventh-family tone below it — ``extend(9)`` + stacks the diatonic seventh AND the ninth, so a degree yields the + same chord class as the concrete-chord path (a ninth chord, not an + ``add9``). + """ + + mode = ( + "minor" + if self.borrowed and scale != "minor" + else ("ionian" if self.borrowed else scale) + ) + pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) + + if self.degree > len(pcs): + raise ValueError( + f"scale degree {self.degree} is out of range for {mode!r} " + f"({len(pcs)} degrees)" + ) + + root_pc = (pcs[self.degree - 1] + self.accidental) % 12 + + intervals: typing.List[int] = [] + + for extension in extensions: + if not isinstance(extension, int): + continue # sus/add forms are scale-independent — the colour path handles them + + # 7 → six scale steps above the root; 9 → eight; 11 → ten; 13 → twelve. + top = {7: 6, 9: 8, 11: 10, 13: 12}.get(extension) + + if top is None: + continue + + # Stack every odd scale step from the seventh up to the extension. + for steps in range(6, top + 1, 2): + pc = pcs[(self.degree - 1 + steps) % len(pcs)] + octave = ( + 0 if steps == 6 else 12 + ) # 9ths/11ths/13ths live above the octave + intervals.append(((pc - root_pc) % 12) + octave) + + return tuple(sorted(set(intervals))) # Major-relative spelling of every pitch-class offset from the tonic — the # pop/rock roman convention (b3, b6, b7; b2 and b5 for the rest). Used by # generation to emit scale-proof RomanChords. _OFFSET_SPELLING: typing.Dict[int, typing.Tuple[int, int]] = { - 0: (1, 0), 1: (2, -1), 2: (2, 0), 3: (3, -1), 4: (3, 0), 5: (4, 0), - 6: (5, -1), 7: (5, 0), 8: (6, -1), 9: (6, 0), 10: (7, -1), 11: (7, 0), + 0: (1, 0), + 1: (2, -1), + 2: (2, 0), + 3: (3, -1), + 4: (3, 0), + 5: (4, 0), + 6: (5, -1), + 7: (5, 0), + 8: (6, -1), + 9: (6, 0), + 10: (7, -1), + 11: (7, 0), } _ROMAN_NUMERALS: typing.Tuple[str, ...] = ("I", "II", "III", "IV", "V", "VI", "VII") @@ -354,119 +429,128 @@ def diatonic_extension_intervals ( # int constraints (end=1) and as generation's default resolve scale. Styles # without diatonic quality rows fall back to ionian. _STYLE_SCALES: typing.Dict[str, str] = { - "functional_major": "ionian", - "diatonic_major": "ionian", - "hooktheory_major": "ionian", - "pop_major": "ionian", - "turnaround": "ionian", - "turnaround_global": "ionian", - "aeolian_minor": "minor", - "phrygian_minor": "phrygian", - "lydian_major": "lydian", - "dorian_minor": "dorian", - "mixolydian": "mixolydian", + "functional_major": "ionian", + "diatonic_major": "ionian", + "hooktheory_major": "ionian", + "pop_major": "ionian", + "turnaround": "ionian", + "turnaround_global": "ionian", + "aeolian_minor": "minor", + "phrygian_minor": "phrygian", + "lydian_major": "lydian", + "dorian_minor": "dorian", + "mixolydian": "mixolydian", } # Quality → (prints lowercase, printable suffix). Qualities outside the # table print as an explicit parenthesised tail. _ROMAN_QUALITY_TEXT: typing.Dict[str, typing.Tuple[bool, str]] = { - "major": (False, ""), - "minor": (True, ""), - "dominant_7th": (False, "7"), - "minor_7th": (True, "7"), - "major_7th": (False, "maj7"), - "diminished": (True, "°"), - "diminished_7th": (True, "°7"), - "half_diminished_7th": (True, "ø7"), - "augmented": (False, "+"), + "major": (False, ""), + "minor": (True, ""), + "dominant_7th": (False, "7"), + "minor_7th": (True, "7"), + "major_7th": (False, "maj7"), + "diminished": (True, "°"), + "diminished_7th": (True, "°7"), + "half_diminished_7th": (True, "ø7"), + "augmented": (False, "+"), } -def resolve_constraint (spec: typing.Any, key_pc: int, scale: str, what: str) -> subsequence.chords.Chord: +def resolve_constraint( + spec: typing.Any, key_pc: int, scale: str, what: str +) -> subsequence.chords.Chord: + """Parse one hybrid-constraint spec (pin/end/avoid) into a concrete chord. - """Parse one hybrid-constraint spec (pin/end/avoid) into a concrete chord. + Specs follow the progression-element grammar: ints are diatonic degrees + (quality inferred from *scale*), strings are chord names or romans, + ``Chord`` objects pass through. ``PitchSet`` objects are rejected — generation + needs rooted chords. + """ - Specs follow the progression-element grammar: ints are diatonic degrees - (quality inferred from *scale*), strings are chord names or romans, - ``Chord`` objects pass through. ``PitchSet`` objects are rejected — generation - needs rooted chords. - """ + parsed = parse_element(spec).chord - parsed = parse_element(spec).chord + if isinstance(parsed, PitchSet): + raise ValueError( + f"{what}: generation needs rooted chords — a PitchSet cannot constrain the walk" + ) + if isinstance(parsed, RomanChord): + return parsed.resolve(key_pc, scale) - if isinstance(parsed, PitchSet): - raise ValueError(f"{what}: generation needs rooted chords — a PitchSet cannot constrain the walk") - if isinstance(parsed, RomanChord): - return parsed.resolve(key_pc, scale) + return typing.cast(subsequence.chords.Chord, parsed) - return typing.cast(subsequence.chords.Chord, parsed) - -def cadence_pins ( - name: str, - bars: int, - pins: typing.Optional[typing.Dict[int, typing.Any]], - end: typing.Optional[typing.Any], +def cadence_pins( + name: str, + bars: int, + pins: typing.Optional[typing.Dict[int, typing.Any]], + end: typing.Optional[typing.Any], ) -> typing.Dict[int, typing.Any]: - - """Compile a cadence name into pins on the final bars of a walk. - - The shared translation for ``Progression.generate(cadence=)`` and - ``Composition.freeze(cadence=)``: the formula occupies the last bars, - merged with the caller's own pins. Conflicts raise loudly — ``end=`` - and a pin on a formula bar both name what the cadence already fixes. - """ - - spec = subsequence.cadences.cadence_formula(name) - count = len(spec.formula) - - if end is not None: - raise ValueError( - f"cadence={name!r} already fixes the final bar — it conflicts with end={end!r} " - "(drop end=, or spell the tail yourself with pins=)" - ) - - if bars < count: - raise ValueError(f"cadence={name!r} needs {count} bars for its formula, but bars={bars}") - - merged = dict(pins or {}) - - for offset, element in enumerate(spec.formula): - position = bars - count + 1 + offset - if position in merged: - raise ValueError( - f"cadence={name!r} fixes bar {position}, which conflicts with " - f"pins[{position}]={merged[position]!r}" - ) - merged[position] = element - - return merged - - -def _roman_from_chord (chord: subsequence.chords.Chord, tonic_pc: int) -> RomanChord: - - """Spell a concrete chord relative to a tonic, scale-proof. - - The inverse of resolution for generated values: the root becomes a - major-relative degree (accidentals for the chromatic offsets), the - quality stays explicit, and ``source_text`` carries a printable roman - for unbound ``describe()``. - """ - - offset = (chord.root_pc - tonic_pc) % 12 - degree, accidental = _OFFSET_SPELLING[offset] - - lowercase, suffix = _ROMAN_QUALITY_TEXT.get(chord.quality, (False, f"({chord.quality})")) - numeral = _ROMAN_NUMERALS[degree - 1] - text = ("b" if accidental < 0 else "#" if accidental > 0 else "") + (numeral.lower() if lowercase else numeral) + suffix - - return RomanChord( - degree = degree, - accidental = accidental, - quality = chord.quality, - source_text = text, - major_relative = True, - ) + """Compile a cadence name into pins on the final bars of a walk. + + The shared translation for ``Progression.generate(cadence=)`` and + ``Composition.freeze(cadence=)``: the formula occupies the last bars, + merged with the caller's own pins. Conflicts raise loudly — ``end=`` + and a pin on a formula bar both name what the cadence already fixes. + """ + + spec = subsequence.cadences.cadence_formula(name) + count = len(spec.formula) + + if end is not None: + raise ValueError( + f"cadence={name!r} already fixes the final bar — it conflicts with end={end!r} " + "(drop end=, or spell the tail yourself with pins=)" + ) + + if bars < count: + raise ValueError( + f"cadence={name!r} needs {count} bars for its formula, but bars={bars}" + ) + + merged = dict(pins or {}) + + for offset, element in enumerate(spec.formula): + position = bars - count + 1 + offset + if position in merged: + raise ValueError( + f"cadence={name!r} fixes bar {position}, which conflicts with " + f"pins[{position}]={merged[position]!r}" + ) + merged[position] = element + + return merged + + +def _roman_from_chord(chord: subsequence.chords.Chord, tonic_pc: int) -> RomanChord: + """Spell a concrete chord relative to a tonic, scale-proof. + + The inverse of resolution for generated values: the root becomes a + major-relative degree (accidentals for the chromatic offsets), the + quality stays explicit, and ``source_text`` carries a printable roman + for unbound ``describe()``. + """ + + offset = (chord.root_pc - tonic_pc) % 12 + degree, accidental = _OFFSET_SPELLING[offset] + + lowercase, suffix = _ROMAN_QUALITY_TEXT.get( + chord.quality, (False, f"({chord.quality})") + ) + numeral = _ROMAN_NUMERALS[degree - 1] + text = ( + ("b" if accidental < 0 else "#" if accidental > 0 else "") + + (numeral.lower() if lowercase else numeral) + + suffix + ) + + return RomanChord( + degree=degree, + accidental=accidental, + quality=chord.quality, + source_text=text, + major_relative=True, + ) # --------------------------------------------------------------------------- @@ -481,339 +565,359 @@ def _roman_from_chord (chord: subsequence.chords.Chord, tonic_pc: int) -> RomanC @dataclasses.dataclass(frozen=True) class ChordSpan: - - """One chord with a duration and its decoration — the unit of harmonic time. - - Decoration (extensions, slash bass, inversion, spread) lives HERE, never - on :class:`~subsequence.chords.Chord`: the engine's graph identity stays - the bare triad, and the decorated voicing is what patterns hear. - - Attributes: - chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a - :class:`PitchSet`. - beats: Span length in beats. - extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``) - or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``). - bass: Slash/pedal bass — a pitch class int, a note name, or - ``"tonic"`` (resolved against the key at query time). - inversion: Chord inversion for the voicing (0 = root position). - spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2), - or ``"wide"`` (drop-2-and-4). - extension_intervals: Pre-computed semitone offsets for the - extensions, set by :meth:`Progression.resolve` for diatonic - degrees. ``None`` means "derive from the chord's own colour". - """ - - chord: typing.Any - beats: float - extensions: typing.Tuple[typing.Any, ...] = () - bass: typing.Optional[typing.Union[int, str]] = None - inversion: int = 0 - spread: typing.Optional[str] = None - extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None - - def __post_init__ (self) -> None: - - """Validate beats, extensions, and spread.""" - - if self.beats <= 0: - raise ValueError(f"a chord span must last at least one beat-fraction, got {self.beats:g}") - - for extension in self.extensions: - if isinstance(extension, bool) or not ( - (isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS) - or (isinstance(extension, str) and extension in _EXTENSION_NAMES) - ): - known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES)) - raise ValueError(f"unknown extension {extension!r} — expected one of: {known}") - - if self.spread is not None and self.spread not in _SPREAD_STYLES: - raise ValueError(f"unknown spread {self.spread!r} — expected one of: " + ", ".join(sorted(_SPREAD_STYLES))) - - @property - def is_concrete (self) -> bool: - - """True when the chord (and any pedal bass) needs no key context to sound. - - A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not - concrete until :meth:`resolve` pins it to a key. Note-name basses are - resolved to a pitch class eagerly in :meth:`Progression.over`, so they - never linger here as strings. - """ - - return not isinstance(self.chord, RomanChord) and not isinstance(self.bass, str) - - @property - def is_decorated (self) -> bool: - - """True when the span carries any decoration beyond the bare chord.""" - - return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None - - def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": - - """Return a concrete span: romans resolved, bass resolved to a pitch class.""" - - chord = self.chord - extension_intervals = self.extension_intervals - - if isinstance(chord, RomanChord): - if chord.quality is None and any(isinstance(e, int) for e in self.extensions): - extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) - chord = chord.resolve(key_pc, scale) - - bass: typing.Optional[typing.Union[int, str]] = self.bass - - if isinstance(bass, str): - if bass == "tonic": - bass = key_pc - else: - bass = subsequence.chords.key_name_to_pc(bass) - - return dataclasses.replace( - self, - chord = chord, - bass = bass, - extension_intervals = extension_intervals, - ) - - def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: - - """A printable chord label: roman text when relative, decorated name when concrete.""" - - if isinstance(self.chord, RomanChord): - if key_pc is None: - text = self.chord.label() - return text + self._decoration_suffix(resolved=False) - return self.resolve(key_pc, scale).label() - - base = str(self.chord.name()) - return base + self._decoration_suffix(resolved=True) - - def _decoration_suffix (self, resolved: bool) -> str: - - """The printable decoration tail (extensions and slash bass).""" - - parts = "" - numeric = sorted(e for e in self.extensions if isinstance(e, int)) - - # 9 implies 7 (and so on up): print only the highest stacked extension. - stacked = [e for e in numeric if e in (7, 9, 11, 13)] - if stacked: - parts += str(stacked[-1]) - - for name in (e for e in self.extensions if isinstance(e, str)): - parts += name - - if self.bass is not None: - if isinstance(self.bass, int): - parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12] - else: - parts += "/" + str(self.bass) - - return parts - - def decorated_intervals (self) -> typing.List[int]: - - """Semitone offsets of the decorated voicing (before inversion/spread/bass). - - Numeric extensions deepen the chord in its own colour — a minor third - gets a minor seventh, a major third a major seventh, a diminished - triad a diminished seventh. Diatonic degrees extended with - ``extend(...)`` carry pre-computed scale-true intervals instead (so V - gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the - dominant colour on a concrete major chord. - """ - - if isinstance(self.chord, RomanChord): - raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") - - intervals = list(self.chord.intervals()) - - sus = [e for e in self.extensions if e in ("sus2", "sus4")] - if sus and len(intervals) >= 2: - intervals[1] = 2 if sus[0] == "sus2" else 5 - - numeric = sorted(e for e in self.extensions if isinstance(e, int)) - - if self.extension_intervals is not None: - added: typing.List[int] = list(self.extension_intervals) - else: - added = [] - third = intervals[1] if len(intervals) >= 2 else None - has_seventh = any(i in (9, 10, 11) for i in intervals) - stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] - - if stacked and not has_seventh: - if third == 3 and len(intervals) >= 3 and intervals[2] == 6: - added.append(9) # diminished colour - elif third == 3: - added.append(10) # minor colour - elif third == 4: - added.append(11) # major colour - else: - added.append(10) # sus / no third: the dominant-leaning seventh - - for extension in stacked: - if extension == 9: - added.append(14) - elif extension == 11: - added.append(17) - elif extension == 13: - added.append(21) - - if "add9" in self.extensions: - added.append(14) - if "6" in self.extensions: - added.append(9) - - return sorted(set(intervals) | set(added)) - - def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: - - """MIDI notes of the decorated voicing nearest *root* (concrete spans only). - - Applies, in order: extensions, inversion, spread, then the slash/pedal - bass below the voicing. ``PitchSet`` spans return their absolute - pitches (decoration other than ``count`` does not apply). - """ - - if isinstance(self.chord, RomanChord): - raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") - - if isinstance(self.chord, PitchSet): - return self.chord.tones(root, inversion=self.inversion, count=count) - - intervals = self.decorated_intervals() - - if self.inversion != 0: - intervals = subsequence.voicings.invert_chord(intervals, self.inversion) - - if self.spread == "open" and len(intervals) >= 3: - intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) - elif self.spread == "wide" and len(intervals) >= 3: - dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] - intervals = sorted(dropped) - - offset = (self.chord.root_pc - root) % 12 - if offset > 6: - offset -= 12 - effective_root = root + offset - - if count is not None: - n = len(intervals) - span_octave = max(12, ((max(intervals) // 12) + 1) * 12) - pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] - else: - pitches = [effective_root + interval for interval in intervals] - - if self.bass is not None and isinstance(self.bass, int): - lowest = min(pitches) - bass_note = lowest - ((lowest - self.bass) % 12) - if bass_note == lowest: - bass_note -= 12 - pitches = [bass_note] + pitches - - return pitches + """One chord with a duration and its decoration — the unit of harmonic time. + + Decoration (extensions, slash bass, inversion, spread) lives HERE, never + on :class:`~subsequence.chords.Chord`: the engine's graph identity stays + the bare triad, and the decorated voicing is what patterns hear. + + Attributes: + chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a + :class:`PitchSet`. + beats: Span length in beats. + extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``) + or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``). + bass: Slash/pedal bass — a pitch class int, a note name, or + ``"tonic"`` (resolved against the key at query time). + inversion: Chord inversion for the voicing (0 = root position). + spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2), + or ``"wide"`` (drop-2-and-4). + extension_intervals: Pre-computed semitone offsets for the + extensions, set by :meth:`Progression.resolve` for diatonic + degrees. ``None`` means "derive from the chord's own colour". + """ + + chord: typing.Any + beats: float + extensions: typing.Tuple[typing.Any, ...] = () + bass: typing.Optional[typing.Union[int, str]] = None + inversion: int = 0 + spread: typing.Optional[str] = None + extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None + + def __post_init__(self) -> None: + """Validate beats, extensions, and spread.""" + + if self.beats <= 0: + raise ValueError( + f"a chord span must last at least one beat-fraction, got {self.beats:g}" + ) + + for extension in self.extensions: + if isinstance(extension, bool) or not ( + (isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS) + or (isinstance(extension, str) and extension in _EXTENSION_NAMES) + ): + known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES)) + raise ValueError( + f"unknown extension {extension!r} — expected one of: {known}" + ) + + if self.spread is not None and self.spread not in _SPREAD_STYLES: + raise ValueError( + f"unknown spread {self.spread!r} — expected one of: " + + ", ".join(sorted(_SPREAD_STYLES)) + ) + + @property + def is_concrete(self) -> bool: + """True when the chord (and any pedal bass) needs no key context to sound. + + A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not + concrete until :meth:`resolve` pins it to a key. Note-name basses are + resolved to a pitch class eagerly in :meth:`Progression.over`, so they + never linger here as strings. + """ + + return not isinstance(self.chord, RomanChord) and not isinstance(self.bass, str) + + @property + def is_decorated(self) -> bool: + """True when the span carries any decoration beyond the bare chord.""" + + return ( + bool(self.extensions) + or self.bass is not None + or self.inversion != 0 + or self.spread is not None + ) + + def resolve(self, key_pc: int, scale: str = "ionian") -> "ChordSpan": + """Return a concrete span: romans resolved, bass resolved to a pitch class.""" + + chord = self.chord + extension_intervals = self.extension_intervals + + if isinstance(chord, RomanChord): + if chord.quality is None and any( + isinstance(e, int) for e in self.extensions + ): + extension_intervals = chord.diatonic_extension_intervals( + key_pc, scale, self.extensions + ) + chord = chord.resolve(key_pc, scale) + + bass: typing.Optional[typing.Union[int, str]] = self.bass + + if isinstance(bass, str): + if bass == "tonic": + bass = key_pc + else: + bass = subsequence.chords.key_name_to_pc(bass) + + return dataclasses.replace( + self, + chord=chord, + bass=bass, + extension_intervals=extension_intervals, + ) + + def label(self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: + """A printable chord label: roman text when relative, decorated name when concrete.""" + + if isinstance(self.chord, RomanChord): + if key_pc is None: + text = self.chord.label() + return text + self._decoration_suffix(resolved=False) + return self.resolve(key_pc, scale).label() + + base = str(self.chord.name()) + return base + self._decoration_suffix(resolved=True) + + def _decoration_suffix(self, resolved: bool) -> str: + """The printable decoration tail (extensions and slash bass).""" + + parts = "" + numeric = sorted(e for e in self.extensions if isinstance(e, int)) + + # 9 implies 7 (and so on up): print only the highest stacked extension. + stacked = [e for e in numeric if e in (7, 9, 11, 13)] + if stacked: + parts += str(stacked[-1]) + + for name in (e for e in self.extensions if isinstance(e, str)): + parts += name + + if self.bass is not None: + if isinstance(self.bass, int): + parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12] + else: + parts += "/" + str(self.bass) + + return parts + + def decorated_intervals(self) -> typing.List[int]: + """Semitone offsets of the decorated voicing (before inversion/spread/bass). + + Numeric extensions deepen the chord in its own colour — a minor third + gets a minor seventh, a major third a major seventh, a diminished + triad a diminished seventh. Diatonic degrees extended with + ``extend(...)`` carry pre-computed scale-true intervals instead (so V + gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the + dominant colour on a concrete major chord. + """ + + if isinstance(self.chord, RomanChord): + raise ValueError( + "cannot voice a key-relative span — resolve(key=...) it first" + ) + + intervals = list(self.chord.intervals()) + + sus = [e for e in self.extensions if e in ("sus2", "sus4")] + if sus and len(intervals) >= 2: + intervals[1] = 2 if sus[0] == "sus2" else 5 + + numeric = sorted(e for e in self.extensions if isinstance(e, int)) + + if self.extension_intervals is not None: + added: typing.List[int] = list(self.extension_intervals) + else: + added = [] + third = intervals[1] if len(intervals) >= 2 else None + has_seventh = any(i in (9, 10, 11) for i in intervals) + stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] + + if stacked and not has_seventh: + if third == 3 and len(intervals) >= 3 and intervals[2] == 6: + added.append(9) # diminished colour + elif third == 3: + added.append(10) # minor colour + elif third == 4: + added.append(11) # major colour + else: + added.append(10) # sus / no third: the dominant-leaning seventh + + for extension in stacked: + if extension == 9: + added.append(14) + elif extension == 11: + added.append(17) + elif extension == 13: + added.append(21) + + if "add9" in self.extensions: + added.append(14) + if "6" in self.extensions: + added.append(9) + + return sorted(set(intervals) | set(added)) + + def tones( + self, root: int = 60, count: typing.Optional[int] = None + ) -> typing.List[int]: + """MIDI notes of the decorated voicing nearest *root* (concrete spans only). + + Applies, in order: extensions, inversion, spread, then the slash/pedal + bass below the voicing. ``PitchSet`` spans return their absolute + pitches (decoration other than ``count`` does not apply). + """ + + if isinstance(self.chord, RomanChord): + raise ValueError( + "cannot voice a key-relative span — resolve(key=...) it first" + ) + + if isinstance(self.chord, PitchSet): + return self.chord.tones(root, inversion=self.inversion, count=count) + + intervals = self.decorated_intervals() + + if self.inversion != 0: + intervals = subsequence.voicings.invert_chord(intervals, self.inversion) + + if self.spread == "open" and len(intervals) >= 3: + intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) + elif self.spread == "wide" and len(intervals) >= 3: + dropped = [ + i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i + for position, i in enumerate(intervals) + ] + intervals = sorted(dropped) + + offset = (self.chord.root_pc - root) % 12 + if offset > 6: + offset -= 12 + effective_root = root + offset + + if count is not None: + n = len(intervals) + span_octave = max(12, ((max(intervals) // 12) + 1) * 12) + pitches = [ + effective_root + intervals[i % n] + span_octave * (i // n) + for i in range(count) + ] + else: + pitches = [effective_root + interval for interval in intervals] + + if self.bass is not None and isinstance(self.bass, int): + lowest = min(pitches) + bass_note = lowest - ((lowest - self.bass) % 12) + if bass_note == lowest: + bass_note -= 12 + pitches = [bass_note] + pitches + + return pitches class DecoratedChord: + """Duck-types the ``Chord`` voicing protocol over a decorated span. - """Duck-types the ``Chord`` voicing protocol over a decorated span. - - What patterns and the injected ``chord`` receive when a span carries - decoration: ``tones()`` voices the extensions/inversion/spread/bass, - ``intervals()`` reports the decorated intervals (so per-pattern voice - leading works over them), and ``name()`` prints the decorated name - (``Am9``, ``C/G``). The engine itself never sees this — graph identity - stays the bare triad underneath (:attr:`base`). - """ - - def __init__ (self, span: ChordSpan) -> None: - - """Wrap a concrete, decorated span.""" - - if not span.is_concrete: - raise ValueError("DecoratedChord needs a concrete span — resolve(key=...) first") - - self._span = span - - @property - def base (self) -> typing.Any: - - """The undecorated chord (the engine's currency).""" + What patterns and the injected ``chord`` receive when a span carries + decoration: ``tones()`` voices the extensions/inversion/spread/bass, + ``intervals()`` reports the decorated intervals (so per-pattern voice + leading works over them), and ``name()`` prints the decorated name + (``Am9``, ``C/G``). The engine itself never sees this — graph identity + stays the bare triad underneath (:attr:`base`). + """ - return self._span.chord + def __init__(self, span: ChordSpan) -> None: + """Wrap a concrete, decorated span.""" - @property - def span (self) -> ChordSpan: + if not span.is_concrete: + raise ValueError( + "DecoratedChord needs a concrete span — resolve(key=...) first" + ) - """The wrapped span (decoration and all).""" + self._span = span - return self._span + @property + def base(self) -> typing.Any: + """The undecorated chord (the engine's currency).""" - @property - def root_pc (self) -> int: + return self._span.chord - """The root pitch class of the underlying chord.""" + @property + def span(self) -> ChordSpan: + """The wrapped span (decoration and all).""" - return int(self._span.chord.root_pc) if hasattr(self._span.chord, "root_pc") else self._span.chord.pitches[0] % 12 + return self._span - @property - def quality (self) -> str: + @property + def root_pc(self) -> int: + """The root pitch class of the underlying chord.""" - """The quality of the underlying chord.""" + return ( + int(self._span.chord.root_pc) + if hasattr(self._span.chord, "root_pc") + else self._span.chord.pitches[0] % 12 + ) - return str(getattr(self._span.chord, "quality", "pitch_set")) + @property + def quality(self) -> str: + """The quality of the underlying chord.""" - def intervals (self) -> typing.List[int]: + return str(getattr(self._span.chord, "quality", "pitch_set")) - """Decorated semitone offsets from the root.""" + def intervals(self) -> typing.List[int]: + """Decorated semitone offsets from the root.""" - if isinstance(self._span.chord, PitchSet): - return self._span.chord.intervals() + if isinstance(self._span.chord, PitchSet): + return self._span.chord.intervals() - return self._span.decorated_intervals() + return self._span.decorated_intervals() - def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: + def tones( + self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None + ) -> typing.List[int]: + """Decorated voicing nearest *root*; an explicit *inversion* overrides the span's.""" - """Decorated voicing nearest *root*; an explicit *inversion* overrides the span's.""" + span = ( + self._span + if inversion == 0 + else dataclasses.replace(self._span, inversion=inversion) + ) - span = self._span if inversion == 0 else dataclasses.replace(self._span, inversion=inversion) + return span.tones(root, count=count) - return span.tones(root, count=count) + def root_note(self, root_midi: int) -> int: + """The MIDI note of the (undecorated) chord root nearest *root_midi*.""" - def root_note (self, root_midi: int) -> int: + if isinstance(self._span.chord, PitchSet): + return self._span.chord.pitches[0] - """The MIDI note of the (undecorated) chord root nearest *root_midi*.""" + return int(self._span.chord.root_note(root_midi)) - if isinstance(self._span.chord, PitchSet): - return self._span.chord.pitches[0] + def bass_note(self, root_midi: int, octave_offset: int = -1) -> int: + """The chord root shifted by octaves (the slash bass pc when one is set). - return int(self._span.chord.root_note(root_midi)) + The slash bass uses the same register as the plain root bass — an octave + below the chord at the default ``octave_offset=-1`` — so a bass line over + a mix of plain and slash chords doesn't jump up an octave on the slash + ones. + """ - def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: + if isinstance(self._span.bass, int): + lowest = self.root_note(root_midi) + bass = lowest - ((lowest - self._span.bass) % 12) + return bass + 12 * octave_offset - """The chord root shifted by octaves (the slash bass pc when one is set). + return self.root_note(root_midi) + (12 * octave_offset) - The slash bass uses the same register as the plain root bass — an octave - below the chord at the default ``octave_offset=-1`` — so a bass line over - a mix of plain and slash chords doesn't jump up an octave on the slash - ones. - """ + def name(self) -> str: + """The decorated chord name (``Am9``, ``C/G``).""" - if isinstance(self._span.bass, int): - lowest = self.root_note(root_midi) - bass = lowest - ((lowest - self._span.bass) % 12) - return bass + 12 * octave_offset - - return self.root_note(root_midi) + (12 * octave_offset) - - def name (self) -> str: - - """The decorated chord name (``Am9``, ``C/G``).""" - - return self._span.label() + return self._span.label() # --------------------------------------------------------------------------- @@ -821,145 +925,163 @@ def name (self) -> str: # --------------------------------------------------------------------------- _ROMAN_VALUES: typing.Dict[str, int] = { - "i": 1, "ii": 2, "iii": 3, "iv": 4, "v": 5, "vi": 6, "vii": 7, + "i": 1, + "ii": 2, + "iii": 3, + "iv": 4, + "v": 5, + "vi": 6, + "vii": 7, } _ROMAN_RE = re.compile( - r"^(?P[b#])?" - r"(?P[ivIV]+)" - r"(?P°|o|ø|\+|aug|dim)?" - r"(?Pmaj7|M7)?" - r"(?P
65|64|43|42|7|6|2)?" - r"(?:/(?P.+))?$" + r"^(?P[b#])?" + r"(?P[ivIV]+)" + r"(?P°|o|ø|\+|aug|dim)?" + r"(?Pmaj7|M7)?" + r"(?P
65|64|43|42|7|6|2)?" + r"(?:/(?P.+))?$" ) # Figure → (adds a seventh, inversion). _FIGURES: typing.Dict[typing.Optional[str], typing.Tuple[bool, int]] = { - None: (False, 0), - "6": (False, 1), - "64": (False, 2), - "7": (True, 0), - "65": (True, 1), - "43": (True, 2), - "42": (True, 3), - "2": (True, 3), + None: (False, 0), + "6": (False, 1), + "64": (False, 2), + "7": (True, 0), + "65": (True, 1), + "43": (True, 2), + "42": (True, 3), + "2": (True, 3), } -def parse_roman (text: str) -> typing.Tuple[RomanChord, int]: - - """Parse a roman numeral element into a (RomanChord, inversion) pair. - - The ~music21 semantics grammar: case is quality (``IV`` major, ``iv`` - minor), ``°``/``o``/``dim`` diminished, ``ø`` half-diminished, ``+``/ - ``aug`` augmented; ``maj7`` forces the major seventh; figured-bass - suffixes give sevenths and inversions (``7``/``65``/``43``/``42``; - ``6``/``64`` for triads); ``b``/``#`` prefixes shift the degree; one - level of ``/x`` secondary function (``V7/IV``). - - Raises ``ValueError`` for anything it can't read. - """ - - stripped = text.strip() - match = _ROMAN_RE.match(stripped) - - if not match: - raise ValueError(f"Cannot parse roman numeral {text!r} — expected e.g. 'V7', 'bVII', 'ii65', 'V/V'") - - numeral = match.group("numeral") - lowered = numeral.lower() - - if lowered not in _ROMAN_VALUES or numeral not in (lowered, numeral.upper()): - raise ValueError(f"Cannot parse roman numeral {text!r} — {numeral!r} is not a degree numeral (I–VII)") - - degree = _ROMAN_VALUES[lowered] - is_upper = numeral == numeral.upper() - accidental = {"b": -1, "#": 1}.get(match.group("accidental") or "", 0) - - modifier = match.group("modifier") - has_maj7 = match.group("maj7") is not None - has_seventh, inversion = _FIGURES[match.group("figure")] - - if modifier in ("°", "o", "dim"): - quality = "diminished_7th" if has_seventh else "diminished" - elif modifier == "ø": - quality = "half_diminished_7th" - elif modifier in ("+", "aug"): - quality = "augmented" - elif has_maj7: - if not is_upper: - raise ValueError(f"Cannot parse roman numeral {text!r} — maj7 needs an uppercase numeral") - quality = "major_7th" - elif has_seventh: - quality = "dominant_7th" if is_upper else "minor_7th" - else: - quality = "major" if is_upper else "minor" - - of: typing.Optional[int] = None - of_text = match.group("of") - - if of_text is not None: - if "/" in of_text: - raise ValueError(f"Cannot parse roman numeral {text!r} — only one level of secondary function (/x) is supported") - if of_text.lower() in _ROMAN_VALUES: - of = _ROMAN_VALUES[of_text.lower()] - elif of_text.isdigit(): - of = int(of_text) - else: - raise ValueError(f"Cannot parse roman numeral {text!r} — secondary target {of_text!r} is not a degree") - - roman = RomanChord( - degree = degree, - accidental = accidental, - quality = quality, - of = of, - source_text = stripped, - ) - - return roman, inversion - - -def parse_element (element: typing.Any, beats: float = DEFAULT_SPAN_BEATS) -> ChordSpan: - - """Parse one progression-list element into a :class:`ChordSpan`. - - Elements mix freely and are parsed per element (decision 16): ints are - diatonic degrees (1-based, quality inferred from key+scale at query - time); strings are chord names where they start with a note letter - (``"Am"``) and romans otherwise (``"VI"``, ``"bVII7"``); ``Chord``, - ``PitchSet``, and ``ChordSpan`` values pass through; an - ``(element, beats)`` tuple sets the span length. - """ - - if isinstance(element, ChordSpan): - return element - - if isinstance(element, tuple): - if len(element) != 2: - raise ValueError(f"a progression tuple element must be (chord, beats), got {element!r}") - inner, span_beats = element - return parse_element(inner, beats=float(span_beats)) - - if isinstance(element, bool): - raise TypeError(f"cannot parse progression element {element!r} (bool)") - - if isinstance(element, int): - return ChordSpan(chord=RomanChord(degree=element, source_text=str(element)), beats=beats) - - if isinstance(element, (subsequence.chords.Chord, PitchSet, RomanChord)): - return ChordSpan(chord=element, beats=beats) - - if isinstance(element, str): - stripped = element.strip() - if stripped and stripped[0] in "ABCDEFG": - return _parse_chord_name(stripped, beats) - roman, inversion = parse_roman(stripped) - return ChordSpan(chord=roman, beats=beats, inversion=inversion) - - raise TypeError( - f"cannot parse progression element {element!r} — expected an int degree, " - "a chord name or roman string, a Chord, a PitchSet, or an (element, beats) tuple" - ) +def parse_roman(text: str) -> typing.Tuple[RomanChord, int]: + """Parse a roman numeral element into a (RomanChord, inversion) pair. + + The ~music21 semantics grammar: case is quality (``IV`` major, ``iv`` + minor), ``°``/``o``/``dim`` diminished, ``ø`` half-diminished, ``+``/ + ``aug`` augmented; ``maj7`` forces the major seventh; figured-bass + suffixes give sevenths and inversions (``7``/``65``/``43``/``42``; + ``6``/``64`` for triads); ``b``/``#`` prefixes shift the degree; one + level of ``/x`` secondary function (``V7/IV``). + + Raises ``ValueError`` for anything it can't read. + """ + + stripped = text.strip() + match = _ROMAN_RE.match(stripped) + + if not match: + raise ValueError( + f"Cannot parse roman numeral {text!r} — expected e.g. 'V7', 'bVII', 'ii65', 'V/V'" + ) + + numeral = match.group("numeral") + lowered = numeral.lower() + + if lowered not in _ROMAN_VALUES or numeral not in (lowered, numeral.upper()): + raise ValueError( + f"Cannot parse roman numeral {text!r} — {numeral!r} is not a degree numeral (I–VII)" + ) + + degree = _ROMAN_VALUES[lowered] + is_upper = numeral == numeral.upper() + accidental = {"b": -1, "#": 1}.get(match.group("accidental") or "", 0) + + modifier = match.group("modifier") + has_maj7 = match.group("maj7") is not None + has_seventh, inversion = _FIGURES[match.group("figure")] + + if modifier in ("°", "o", "dim"): + quality = "diminished_7th" if has_seventh else "diminished" + elif modifier == "ø": + quality = "half_diminished_7th" + elif modifier in ("+", "aug"): + quality = "augmented" + elif has_maj7: + if not is_upper: + raise ValueError( + f"Cannot parse roman numeral {text!r} — maj7 needs an uppercase numeral" + ) + quality = "major_7th" + elif has_seventh: + quality = "dominant_7th" if is_upper else "minor_7th" + else: + quality = "major" if is_upper else "minor" + + of: typing.Optional[int] = None + of_text = match.group("of") + + if of_text is not None: + if "/" in of_text: + raise ValueError( + f"Cannot parse roman numeral {text!r} — only one level of secondary function (/x) is supported" + ) + if of_text.lower() in _ROMAN_VALUES: + of = _ROMAN_VALUES[of_text.lower()] + elif of_text.isdigit(): + of = int(of_text) + else: + raise ValueError( + f"Cannot parse roman numeral {text!r} — secondary target {of_text!r} is not a degree" + ) + + roman = RomanChord( + degree=degree, + accidental=accidental, + quality=quality, + of=of, + source_text=stripped, + ) + + return roman, inversion + + +def parse_element(element: typing.Any, beats: float = DEFAULT_SPAN_BEATS) -> ChordSpan: + """Parse one progression-list element into a :class:`ChordSpan`. + + Elements mix freely and are parsed per element (decision 16): ints are + diatonic degrees (1-based, quality inferred from key+scale at query + time); strings are chord names where they start with a note letter + (``"Am"``) and romans otherwise (``"VI"``, ``"bVII7"``); ``Chord``, + ``PitchSet``, and ``ChordSpan`` values pass through; an + ``(element, beats)`` tuple sets the span length. + """ + + if isinstance(element, ChordSpan): + return element + + if isinstance(element, tuple): + if len(element) != 2: + raise ValueError( + f"a progression tuple element must be (chord, beats), got {element!r}" + ) + inner, span_beats = element + return parse_element(inner, beats=float(span_beats)) + + if isinstance(element, bool): + raise TypeError(f"cannot parse progression element {element!r} (bool)") + + if isinstance(element, int): + return ChordSpan( + chord=RomanChord(degree=element, source_text=str(element)), beats=beats + ) + + if isinstance(element, (subsequence.chords.Chord, PitchSet, RomanChord)): + return ChordSpan(chord=element, beats=beats) + + if isinstance(element, str): + stripped = element.strip() + if stripped and stripped[0] in "ABCDEFG": + return _parse_chord_name(stripped, beats) + roman, inversion = parse_roman(stripped) + return ChordSpan(chord=roman, beats=beats, inversion=inversion) + + raise TypeError( + f"cannot parse progression element {element!r} — expected an int degree, " + "a chord name or roman string, a Chord, a PitchSet, or an (element, beats) tuple" + ) # --------------------------------------------------------------------------- @@ -967,651 +1089,698 @@ def parse_element (element: typing.Any, beats: float = DEFAULT_SPAN_BEATS) -> Ch # --------------------------------------------------------------------------- -def _parse_chord_name (name: str, beats: float) -> ChordSpan: - - """Parse a chord-name element, splitting a trailing extension onto the span. - - ``"Dm9"`` is D minor decorated with a 9 — the quality table holds bare - qualities, and the 9/11/13 ride the span as extensions (decoration lives - on spans, never chords). ``"Dm7"`` stays a plain quality (m7 is in the - table); the split only happens when the full name does not parse. - """ +def _parse_chord_name(name: str, beats: float) -> ChordSpan: + """Parse a chord-name element, splitting a trailing extension onto the span. - try: - return ChordSpan(chord = subsequence.chords.parse_chord(name), beats = beats) - except ValueError as original: - for extension in ("13", "11", "9"): - if name.endswith(extension) and len(name) > len(extension): - base = name[:-len(extension)] - try: - chord = subsequence.chords.parse_chord(base) - except ValueError: - continue - return ChordSpan(chord = chord, beats = beats, extensions = (int(extension),)) - raise original + ``"Dm9"`` is D minor decorated with a 9 — the quality table holds bare + qualities, and the 9/11/13 ride the span as extensions (decoration lives + on spans, never chords). ``"Dm7"`` stays a plain quality (m7 is in the + table); the split only happens when the full name does not parse. + """ + try: + return ChordSpan(chord=subsequence.chords.parse_chord(name), beats=beats) + except ValueError as original: + for extension in ("13", "11", "9"): + if name.endswith(extension) and len(name) > len(extension): + base = name[: -len(extension)] + try: + chord = subsequence.chords.parse_chord(base) + except ValueError: + continue + return ChordSpan(chord=chord, beats=beats, extensions=(int(extension),)) + raise original -def _check_slot (slot: int, count: int) -> int: - """Validate a 1-based chord slot and return its 0-based index.""" +def _check_slot(slot: int, count: int) -> int: + """Validate a 1-based chord slot and return its 0-based index.""" - if not isinstance(slot, int) or isinstance(slot, bool): - raise TypeError(f"chord slots are 1-based ints, got {slot!r}") - if slot < 1 or slot > count: - raise ValueError(f"chord slot {slot} is out of range (1–{count})") + if not isinstance(slot, int) or isinstance(slot, bool): + raise TypeError(f"chord slots are 1-based ints, got {slot!r}") + if slot < 1 or slot > count: + raise ValueError(f"chord slot {slot} is out of range (1–{count})") - return slot - 1 + return slot - 1 @dataclasses.dataclass(frozen=True) class Progression: + """A frozen sequence of :class:`ChordSpan` — the governing harmony value. - """A frozen sequence of :class:`ChordSpan` — the governing harmony value. - - Always a realised value: binding it to the clock freezes one realisation; - ``p.progression()`` keeps its breathing behaviour by re-realising a fresh - one each rebuild. Iterating yields ``(chord, start, length)`` - :class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so - placement loops keep working unchanged. - - The governing family supports ``+`` (concatenate) and ``*`` (tile) but - never ``&`` — there is one current chord (P1, the type law). - - Attributes: - spans: The chord spans, in order. - trailing_history: Engine continuity metadata set by - :meth:`Composition.freeze` — the NIR history at capture time, - restored on each frozen replay. Empty for hand-built values. - """ - - spans: typing.Tuple[ChordSpan, ...] - trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = () - - def __post_init__ (self) -> None: - - """Normalise span containers to tuples.""" - - object.__setattr__(self, "spans", tuple(self.spans)) - object.__setattr__(self, "trailing_history", tuple(self.trailing_history)) - - if not self.spans: - raise ValueError("a Progression needs at least one chord span") + Always a realised value: binding it to the clock freezes one realisation; + ``p.progression()`` keeps its breathing behaviour by re-realising a fresh + one each rebuild. Iterating yields ``(chord, start, length)`` + :class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so + placement loops keep working unchanged. - # -- queries ------------------------------------------------------------ + The governing family supports ``+`` (concatenate) and ``*`` (tile) but + never ``&`` — there is one current chord (P1, the type law). - @property - def length (self) -> float: + Attributes: + spans: The chord spans, in order. + trailing_history: Engine continuity metadata set by + :meth:`Composition.freeze` — the NIR history at capture time, + restored on each frozen replay. Empty for hand-built values. + """ - """Total length in beats (the sum of span lengths).""" + spans: typing.Tuple[ChordSpan, ...] + trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = () - return float(sum(span.beats for span in self.spans)) + def __post_init__(self) -> None: + """Normalise span containers to tuples.""" - @property - def is_concrete (self) -> bool: + object.__setattr__(self, "spans", tuple(self.spans)) + object.__setattr__(self, "trailing_history", tuple(self.trailing_history)) - """True when every span is key-independent (no romans/degrees).""" + if not self.spans: + raise ValueError("a Progression needs at least one chord span") - return all(span.is_concrete for span in self.spans) + # -- queries ------------------------------------------------------------ - @property - def chords (self) -> typing.Tuple[typing.Any, ...]: + @property + def length(self) -> float: + """Total length in beats (the sum of span lengths).""" - """The bare chords, one per span (concrete progressions only).""" + return float(sum(span.beats for span in self.spans)) - self._require_concrete("read .chords") + @property + def is_concrete(self) -> bool: + """True when every span is key-independent (no romans/degrees).""" - return tuple(span.chord for span in self.spans) + return all(span.is_concrete for span in self.spans) - @property - def loops_on_exhaustion (self) -> bool: + @property + def chords(self) -> typing.Tuple[typing.Any, ...]: + """The bare chords, one per span (concrete progressions only).""" - """True when the clock must loop rather than fall through to live stepping.""" + self._require_concrete("read .chords") - return any(isinstance(span.chord, PitchSet) for span in self.spans) + return tuple(span.chord for span in self.spans) + + @property + def loops_on_exhaustion(self) -> bool: + """True when the clock must loop rather than fall through to live stepping.""" + + return any(isinstance(span.chord, PitchSet) for span in self.spans) + + def _require_concrete(self, action: str) -> None: + """Raise with a resolution hint when key-relative spans remain.""" + + if not self.is_concrete: + relative = ", ".join( + span.label() for span in self.spans if not span.is_concrete + ) + raise ValueError( + f"cannot {action} on a key-relative progression (contains {relative}) — " + "call .resolve(key=...) first, or bind it where a key is known" + ) + + def __iter__(self) -> typing.Iterator[ChordEvent]: + """Yield ``(chord, start, length)`` events — decorated chords where spiced.""" + + self._require_concrete("iterate") + + cursor = 0.0 + + for span in self.spans: + chord = DecoratedChord(span) if span.is_decorated else span.chord + yield ChordEvent(chord=chord, start=cursor, length=span.beats) + cursor += span.beats + + def __len__(self) -> int: + """The number of chord spans.""" + + return len(self.spans) + + def events(self) -> typing.Tuple[ChordEvent, ...]: + """The realised timeline as a tuple (iteration, materialised).""" + + return tuple(self) + + def span_at(self, beat: float) -> typing.Tuple[ChordSpan, float, float]: + """Return ``(span, start, end)`` for the span sounding at *beat*. + + *beat* wraps modulo the progression length, so the lookup also + serves looped playback. + """ + + position = beat % self.length + cursor = 0.0 + + for span in self.spans: + if cursor <= position < cursor + span.beats: + return span, cursor, cursor + span.beats + cursor += span.beats + + final = self.spans[-1] + return final, self.length - final.beats, self.length + + def resolve( + self, key: typing.Union[str, int], scale: str = "ionian" + ) -> "Progression": + """Resolve every key-relative span against a key (name or pitch class).""" + + key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) + + return dataclasses.replace( + self, + spans=tuple(span.resolve(key_pc, scale) for span in self.spans), + ) + + @classmethod + def generate( + cls, + style: typing.Union[str, typing.Any] = "functional_major", + bars: int = 8, + beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, + *, + key: typing.Optional[str] = None, + scale: typing.Optional[str] = None, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + pins: typing.Optional[typing.Dict[int, typing.Any]] = None, + end: typing.Optional[typing.Any] = None, + avoid: typing.Optional[typing.Sequence[typing.Any]] = None, + cadence: typing.Optional[str] = None, + dominant_7th: bool = True, + gravity: float = 1.0, + nir_strength: float = 0.5, + minor_turnaround_weight: float = 0.0, + root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, + ) -> "Progression": + """Generate a progression from a chord-graph walk — the hybrid generator. + + Full parameter pass-through to the engine (no more throwaway default + engines), plus the hybrid constraints: ``pins`` fix chords at 1-based + bars, ``end`` fixes the last bar, ``avoid`` excludes chords + everywhere. Constraints compile into the walk — a backward + feasibility pass guarantees satisfiability before any chord is + drawn (unsatisfiable constraints raise immediately), then a forward + walk samples through the engine's real history-dependent weights + (NIR, gravity, diversity keep their character). + + **Without** ``key=`` the result is key-relative — the walk runs + against a reference tonic and the spans store scale-proof + major-relative romans, so the value prints meaningfully unbound and + resolves wherever it is bound (the walk itself is key-invariant). + **With** ``key=`` the result is concrete. + + Parameters: + style: A chord-graph style name (or ``ChordGraph`` instance). + bars: How many chords to generate. + beats: Span length per chord — a scalar, or a list cycled. + key: Key for a concrete result; omit for a key-relative value. + scale: Scale for int constraints' quality inference (e.g. + ``end=1``). Defaults from the style (aeolian_minor → + minor); explicit strings (``"V"``, ``"bVII7"``) never + need it. + seed: Seed for the walk. A standalone generated value without + a seed warns — module-level nondeterminism breaks live + reload. + rng: An explicit random stream (overrides ``seed``). + pins: ``{bar: chord}`` — 1-based; values parse like progression + elements (ints, romans, names, ``Chord``). + end: The chord at the final bar — ``end="V"`` is the cadential + major dominant in minor (a string because it is chromatic; + no int can ask for it). + avoid: Chords excluded from the walk. Naming a chord outside + the style's vocabulary is allowed (trivially satisfied). + cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ + ``"fakeout"``, theory aliases accepted) — its formula + becomes pins on the final bars, so the walk *approaches* + the close. Conflicts with ``end=`` or pins on those bars. + dominant_7th / gravity / nir_strength / minor_turnaround_weight / + root_diversity: The engine parameters, exactly as + :meth:`Composition.harmony` takes them. + + Example: + ```python + chorus = subsequence.Progression.generate( + style="aeolian_minor", bars=4, end="V", seed=7, + ) + print(chorus) # romans until bound + ``` + """ + + if bars < 1: + raise ValueError("bars must be at least 1") + + if cadence is not None: + pins = cadence_pins(cadence, bars, pins, end) + end = None + + if rng is None: + if seed is None: + warnings.warn( + "Progression.generate without seed= is nondeterministic — " + "pass seed= so the value survives live reload", + stacklevel=2, + ) + rng = random.Random() + else: + rng = random.Random(seed) + + resolved_scale = ( + scale + if scale is not None + else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") + ) + relative = key is None + reference = key if key is not None else "C" + + state = subsequence.harmonic_state.HarmonicState( + key_name=reference, + graph_style=style, + include_dominant_7th=dominant_7th, + key_gravity_blend=gravity, + nir_strength=nir_strength, + minor_turnaround_weight=minor_turnaround_weight, + root_diversity=root_diversity, + rng=rng, + ) + + resolved_pins = { + position: resolve_constraint( + spec, state.key_root_pc, resolved_scale, f"pins[{position}]" + ) + for position, spec in (pins or {}).items() + } + resolved_end = ( + resolve_constraint(end, state.key_root_pc, resolved_scale, "end") + if end is not None + else None + ) + resolved_avoid = [ + resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") + for spec in (avoid or []) + ] + + if 1 in resolved_pins: + if resolved_pins[1] not in state.graph.nodes(): + raise ValueError( + f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" + ) + state.current_chord = resolved_pins[1] + + def commit(chosen: subsequence.chords.Chord) -> None: + state.current_chord = chosen + + walked = subsequence.sequence_utils.constrained_walk( + state.graph, + state.current_chord, + bars, + rng=state.rng, + pins=resolved_pins, + end=resolved_end, + avoid=resolved_avoid, + weight_modifier=state._transition_weight, + before_choice=state._record_transition_source, + after_choice=commit, + ) + + lengths = _span_lengths(beats, bars) + + if relative: + return cls( + spans=tuple( + ChordSpan( + chord=_roman_from_chord(chord, state.key_root_pc), + beats=lengths[index], + ) + for index, chord in enumerate(walked) + ) + ) + + return cls( + spans=tuple( + ChordSpan(chord=chord, beats=lengths[index]) + for index, chord in enumerate(walked) + ) + ) + + # -- algebra ------------------------------------------------------------ + + def __add__(self, other: "Progression") -> "Progression": + """Concatenate two progressions (the governing ``+``).""" + + if not isinstance(other, Progression): + return NotImplemented + + return Progression(spans=self.spans + other.spans) + + def __mul__(self, count: int) -> "Progression": + """Tile the spans *count* times.""" + + if not isinstance(count, int) or isinstance(count, bool): + return NotImplemented + if count < 1: + raise ValueError("a progression must repeat at least once (n >= 1)") + + return Progression(spans=self.spans * count) + + def __and__(self, other: typing.Any) -> "Progression": + """Parallel merge is a type error for governing values — by design.""" + + raise TypeError( + "Progressions cannot be merged with & — there is one current chord. " + "Sequence them with +, or give a pattern its own part-level progression." + ) + + # -- spice (the five operators) and editing ------------------------------ + + def extend( + self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None + ) -> "Progression": + """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. + + ``only=`` restricts the spice to the given 1-based chord slots. + """ + + slots = ( + set(range(len(self.spans))) + if only is None + else {_check_slot(s, len(self.spans)) for s in only} + ) + + spans = tuple( + dataclasses.replace( + span, extensions=tuple(dict.fromkeys(span.extensions + extensions)) + ) + if index in slots + else span + for index, span in enumerate(self.spans) + ) + + return dataclasses.replace(self, spans=spans) + + def inversions(self, spec: typing.Union[int, typing.List[int]]) -> "Progression": + """Set chord inversions — a single int for all spans, or a list cycled per span.""" + + values = [spec] if isinstance(spec, int) else list(spec) + + if not values: + raise ValueError("inversions list is empty — pass at least one inversion") + + spans = tuple( + dataclasses.replace(span, inversion=int(values[index % len(values)])) + for index, span in enumerate(self.spans) + ) + + return dataclasses.replace(self, spans=spans) + + def spread(self, style: str) -> "Progression": + """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" + + spans = tuple( + dataclasses.replace(span, spread=None if style == "close" else style) + for span in self.spans + ) + + return dataclasses.replace(self, spans=spans) + + def over( + self, + bass: typing.Union[int, str], + only: typing.Optional[typing.List[int]] = None, + ) -> "Progression": + """Put the progression over a slash/pedal bass — *the* trance/techno move. + + *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A + note name is key-independent, so it resolves to its pitch class right + here; ``"tonic"`` follows the key and stays relative until the + progression is resolved. ``only=`` restricts it to the given 1-based + slots (slash chords rather than a full pedal). + """ + + if isinstance(bass, str) and bass != "tonic": + bass = subsequence.chords.key_name_to_pc( + bass + ) # note names are key-independent — resolve now + elif isinstance(bass, int) and not 0 <= bass <= 11: + raise ValueError(f"a bass pitch class must be 0–11, got {bass}") - def _require_concrete (self, action: str) -> None: + slots = ( + set(range(len(self.spans))) + if only is None + else {_check_slot(s, len(self.spans)) for s in only} + ) + + spans = tuple( + dataclasses.replace(span, bass=bass) if index in slots else span + for index, span in enumerate(self.spans) + ) + + return dataclasses.replace(self, spans=spans) + + def borrow(self, slot: typing.Union[int, typing.List[int]]) -> "Progression": + """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. + + Modal interchange for key-relative content: the degree re-resolves + against the parallel mode (minor under a major scale and vice + versa). Concrete chords raise — there is nothing relative to borrow. + """ + + slots = { + _check_slot(s, len(self.spans)) + for s in ([slot] if isinstance(slot, int) else slot) + } + + spans = list(self.spans) + + for index in slots: + chord = spans[index].chord + if not isinstance(chord, RomanChord): + raise ValueError( + f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " + "borrow() needs key-relative content (an int degree or roman)" + ) + spans[index] = dataclasses.replace( + spans[index], + chord=dataclasses.replace(chord, borrowed=not chord.borrowed), + ) + + return dataclasses.replace(self, spans=tuple(spans)) + + def replace(self, slot: int, chord: typing.Any) -> "Progression": + """Replace the chord at a 1-based slot (the span keeps its beats).""" + + index = _check_slot(slot, len(self.spans)) + parsed = parse_element(chord, beats=self.spans[index].beats) + + spans = self.spans[:index] + (parsed,) + self.spans[index + 1 :] + + return dataclasses.replace(self, spans=spans) + + def cadence(self, name: str = "strong") -> "Progression": + """Substitute a cadence formula into the tail — the close, named. + + The final spans take the formula's chords (``"strong"`` is V→I, + ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names — + authentic, plagal, half, deceptive — work as aliases). Each replaced + span keeps its beats; its old chord and decorations go. Formula + chords are key-relative (ints follow the bound scale's qualities, + ``"V"`` is the major dominant by convention), so the tail resolves + wherever the progression is bound — a concrete progression becomes + mixed and resolves its tail at bind time, like any roman content. + + Example:: + + verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") + # Bound in A minor: Am F Dm E — the half close, hanging on the dominant + + Raises: + ValueError: If the cadence name is unknown, or the progression + has fewer spans than the formula. + """ + + spec = subsequence.cadences.cadence_formula(name) + count = len(spec.formula) + + if len(self.spans) < count: + raise ValueError( + f"cadence({name!r}) substitutes the last {count} chords, but this " + f"progression has only {len(self.spans)}" + ) + + tail = tuple( + parse_element(element, beats=span.beats) + for element, span in zip(spec.formula, self.spans[-count:]) + ) + + return dataclasses.replace(self, spans=self.spans[:-count] + tail) + + def with_rhythm( + self, beats: typing.Union[float, typing.List[float]] + ) -> "Progression": + """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" + + if isinstance(beats, bool): + raise TypeError( + f"with_rhythm takes beats or a list of beats, got bool: {beats!r}" + ) + + values = ( + [float(beats)] + if isinstance(beats, (int, float)) + else [float(b) for b in beats] + ) + + if not values: + raise ValueError("with_rhythm list is empty — pass at least one length") + + spans = tuple( + dataclasses.replace(span, beats=float(values[index % len(values)])) + for index, span in enumerate(self.spans) + ) + + return dataclasses.replace(self, spans=spans) + + def elaborate( + self, depth: int = 1, seed: typing.Optional[int] = None + ) -> "Progression": + """Steedman-inspired chord elaboration — approach each chord by fifths. + + Implements the heart of Mark Steedman's generative grammar for + jazz/blues chord sequences: every chord is **approached** by a chain + of secondary dominants propagated backward around the cycle of fifths + (Rule 3, "the perfect cadence propagated backward"), carved out of that + chord's own span (Rule 1, metric subdivision). ``depth`` is literally + how many fifth-steps back the chain extends: + + - ``depth=0`` — identity (the bare progression). + - ``depth=1`` — a secondary dominant before each chord: ``[X]`` → + ``[V7/X, X]`` (e.g. a bar of C becomes G7 C). + - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C). + - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord + is made minor — the ``ii`` of *its own local dominant* (the next + link in the chain), forming a ii–V into that link, not the + target's own ii — and dominants are recoloured by **tritone + substitution** with even odds (Rule 4) for chromatic descents. + This tritone choice is the only nondeterministic part, so ``seed`` + is taken (or warned) at depth ≥ 3. + + Its flagship is the 12-bar blues with depth-per-chorus — elaborate a + ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and + tritone subs accumulate. + + The progression must be **concrete** (resolved to rooted chords); + the inserted dominants are computed by pitch-class arithmetic. Each + chord keeps its decorations on the final (resolved) sub-span; the + inserted approach chords are bare dominant/minor sevenths. Note that + each span is divided into ``depth + 1`` equal sub-spans, so deep + elaboration of a short harmonic rhythm can drop sub-spans below the + harmony clock's lookahead floor — which raises at ``play()``/ + ``render()`` if the result is bound to the global clock (it is free + of that floor at the part level, ``p.progression()``). + + Parameters: + depth: Elaboration depth (≥ 0). + seed: Seed for the depth-≥3 tritone-substitution choices. + + Returns: + A new :class:`Progression` with the approach chords inserted. + + Raises: + ValueError: If *depth* is negative, the progression is + key-relative, or any span is a rootless + :class:`PitchSet`. + + Example: + ```python + blues = subsequence.progression("twelve_bar_blues").resolve("C") + chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout + ``` + """ + + if depth < 0: + raise ValueError("elaborate depth must be at least 0") + + self._require_concrete("elaborate") + + if depth == 0: + return self + + for span in self.spans: + if isinstance(span.chord, PitchSet): + raise ValueError( + "elaborate needs rooted chords — a PitchSet has no root to approach by fifths" + ) + + if depth >= 3 and seed is None: + warnings.warn( + "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the " + "result survives live reload", + stacklevel=2, + ) + + rng = random.Random(seed) + new_spans: typing.List[ChordSpan] = [] + + for span in self.spans: + target_root = span.chord.root_pc + sub_beats = span.beats / (depth + 1) + + # The backward cycle-of-fifths chain, furthest-back first: chord j + # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The + # furthest-back (j == depth) is made minor — the ii of its OWN + # local dominant (the next link), forming a ii–V into that link — + # once the chain is long enough (depth >= 2) to spell one. + for j in range(depth, 0, -1): + root = (target_root + 7 * j) % 12 + quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th" + + # Tritone substitution recolours a dominant to the dom7 a + # tritone away (same guide tones, chromatic resolution). + if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5: + root = (root + 6) % 12 + + new_spans.append( + ChordSpan( + chord=subsequence.chords.Chord(root_pc=root, quality=quality), + beats=sub_beats, + ) + ) + + # The target keeps its own chord and decorations, on its sub-span. + new_spans.append(dataclasses.replace(span, beats=sub_beats)) + + return dataclasses.replace(self, spans=tuple(new_spans)) + + # -- description ---------------------------------------------------------- + + def describe( + self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian" + ) -> str: + """A readable, one-chord-per-line summary. + + Key-relative spans print as written (romans/degrees) when unbound, + and as concrete chord names under a *key*. + """ + + key_pc = ( + None + if key is None + else ( + key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) + ) + ) + + lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] + cursor = 0.0 + + for span in self.spans: + lines.append( + f" {cursor:6.2f} … {cursor + span.beats:6.2f} " + f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" + ) + cursor += span.beats + + return "\n".join(lines) - """Raise with a resolution hint when key-relative spans remain.""" - - if not self.is_concrete: - relative = ", ".join(span.label() for span in self.spans if not span.is_concrete) - raise ValueError( - f"cannot {action} on a key-relative progression (contains {relative}) — " - "call .resolve(key=...) first, or bind it where a key is known" - ) - - def __iter__ (self) -> typing.Iterator[ChordEvent]: - - """Yield ``(chord, start, length)`` events — decorated chords where spiced.""" - - self._require_concrete("iterate") - - cursor = 0.0 - - for span in self.spans: - chord = DecoratedChord(span) if span.is_decorated else span.chord - yield ChordEvent(chord=chord, start=cursor, length=span.beats) - cursor += span.beats - - def __len__ (self) -> int: - - """The number of chord spans.""" - - return len(self.spans) - - def events (self) -> typing.Tuple[ChordEvent, ...]: - - """The realised timeline as a tuple (iteration, materialised).""" - - return tuple(self) - - def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: - - """Return ``(span, start, end)`` for the span sounding at *beat*. - - *beat* wraps modulo the progression length, so the lookup also - serves looped playback. - """ - - position = beat % self.length - cursor = 0.0 - - for span in self.spans: - if cursor <= position < cursor + span.beats: - return span, cursor, cursor + span.beats - cursor += span.beats - - final = self.spans[-1] - return final, self.length - final.beats, self.length - - def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": - - """Resolve every key-relative span against a key (name or pitch class).""" - - key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) - - return dataclasses.replace( - self, - spans = tuple(span.resolve(key_pc, scale) for span in self.spans), - ) - - @classmethod - def generate ( - cls, - style: typing.Union[str, typing.Any] = "functional_major", - bars: int = 8, - beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, - *, - key: typing.Optional[str] = None, - scale: typing.Optional[str] = None, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - pins: typing.Optional[typing.Dict[int, typing.Any]] = None, - end: typing.Optional[typing.Any] = None, - avoid: typing.Optional[typing.Sequence[typing.Any]] = None, - cadence: typing.Optional[str] = None, - dominant_7th: bool = True, - gravity: float = 1.0, - nir_strength: float = 0.5, - minor_turnaround_weight: float = 0.0, - root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, - ) -> "Progression": - - """Generate a progression from a chord-graph walk — the hybrid generator. - - Full parameter pass-through to the engine (no more throwaway default - engines), plus the hybrid constraints: ``pins`` fix chords at 1-based - bars, ``end`` fixes the last bar, ``avoid`` excludes chords - everywhere. Constraints compile into the walk — a backward - feasibility pass guarantees satisfiability before any chord is - drawn (unsatisfiable constraints raise immediately), then a forward - walk samples through the engine's real history-dependent weights - (NIR, gravity, diversity keep their character). - - **Without** ``key=`` the result is key-relative — the walk runs - against a reference tonic and the spans store scale-proof - major-relative romans, so the value prints meaningfully unbound and - resolves wherever it is bound (the walk itself is key-invariant). - **With** ``key=`` the result is concrete. - - Parameters: - style: A chord-graph style name (or ``ChordGraph`` instance). - bars: How many chords to generate. - beats: Span length per chord — a scalar, or a list cycled. - key: Key for a concrete result; omit for a key-relative value. - scale: Scale for int constraints' quality inference (e.g. - ``end=1``). Defaults from the style (aeolian_minor → - minor); explicit strings (``"V"``, ``"bVII7"``) never - need it. - seed: Seed for the walk. A standalone generated value without - a seed warns — module-level nondeterminism breaks live - reload. - rng: An explicit random stream (overrides ``seed``). - pins: ``{bar: chord}`` — 1-based; values parse like progression - elements (ints, romans, names, ``Chord``). - end: The chord at the final bar — ``end="V"`` is the cadential - major dominant in minor (a string because it is chromatic; - no int can ask for it). - avoid: Chords excluded from the walk. Naming a chord outside - the style's vocabulary is allowed (trivially satisfied). - cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ - ``"fakeout"``, theory aliases accepted) — its formula - becomes pins on the final bars, so the walk *approaches* - the close. Conflicts with ``end=`` or pins on those bars. - dominant_7th / gravity / nir_strength / minor_turnaround_weight / - root_diversity: The engine parameters, exactly as - :meth:`Composition.harmony` takes them. - - Example: - ```python - chorus = subsequence.Progression.generate( - style="aeolian_minor", bars=4, end="V", seed=7, - ) - print(chorus) # romans until bound - ``` - """ - - if bars < 1: - raise ValueError("bars must be at least 1") - - if cadence is not None: - pins = cadence_pins(cadence, bars, pins, end) - end = None - - if rng is None: - if seed is None: - warnings.warn( - "Progression.generate without seed= is nondeterministic — " - "pass seed= so the value survives live reload", - stacklevel = 2, - ) - rng = random.Random() - else: - rng = random.Random(seed) - - resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") - relative = key is None - reference = key if key is not None else "C" - - state = subsequence.harmonic_state.HarmonicState( - key_name = reference, - graph_style = style, - include_dominant_7th = dominant_7th, - key_gravity_blend = gravity, - nir_strength = nir_strength, - minor_turnaround_weight = minor_turnaround_weight, - root_diversity = root_diversity, - rng = rng, - ) - - resolved_pins = { - position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") - for position, spec in (pins or {}).items() - } - resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None - resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] - - if 1 in resolved_pins: - if resolved_pins[1] not in state.graph.nodes(): - raise ValueError( - f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" - ) - state.current_chord = resolved_pins[1] - - def commit (chosen: subsequence.chords.Chord) -> None: - state.current_chord = chosen - - walked = subsequence.sequence_utils.constrained_walk( - state.graph, - state.current_chord, - bars, - rng = state.rng, - pins = resolved_pins, - end = resolved_end, - avoid = resolved_avoid, - weight_modifier = state._transition_weight, - before_choice = state._record_transition_source, - after_choice = commit, - ) - - lengths = _span_lengths(beats, bars) - - if relative: - return cls(spans = tuple( - ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) - for index, chord in enumerate(walked) - )) - - return cls(spans = tuple( - ChordSpan(chord = chord, beats = lengths[index]) - for index, chord in enumerate(walked) - )) - - # -- algebra ------------------------------------------------------------ - - def __add__ (self, other: "Progression") -> "Progression": - - """Concatenate two progressions (the governing ``+``).""" - - if not isinstance(other, Progression): - return NotImplemented - - return Progression(spans = self.spans + other.spans) - - def __mul__ (self, count: int) -> "Progression": - - """Tile the spans *count* times.""" - - if not isinstance(count, int) or isinstance(count, bool): - return NotImplemented - if count < 1: - raise ValueError("a progression must repeat at least once (n >= 1)") - - return Progression(spans = self.spans * count) - - def __and__ (self, other: typing.Any) -> "Progression": - - """Parallel merge is a type error for governing values — by design.""" - - raise TypeError( - "Progressions cannot be merged with & — there is one current chord. " - "Sequence them with +, or give a pattern its own part-level progression." - ) - - # -- spice (the five operators) and editing ------------------------------ - - def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": - - """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. - - ``only=`` restricts the spice to the given 1-based chord slots. - """ - - slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} - - spans = tuple( - dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) - if index in slots else span - for index, span in enumerate(self.spans) - ) - - return dataclasses.replace(self, spans=spans) - - def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": - - """Set chord inversions — a single int for all spans, or a list cycled per span.""" - - values = [spec] if isinstance(spec, int) else list(spec) - - if not values: - raise ValueError("inversions list is empty — pass at least one inversion") - - spans = tuple( - dataclasses.replace(span, inversion = int(values[index % len(values)])) - for index, span in enumerate(self.spans) - ) - - return dataclasses.replace(self, spans=spans) - - def spread (self, style: str) -> "Progression": - - """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" - - spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) - - return dataclasses.replace(self, spans=spans) - - def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": - - """Put the progression over a slash/pedal bass — *the* trance/techno move. - - *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A - note name is key-independent, so it resolves to its pitch class right - here; ``"tonic"`` follows the key and stays relative until the - progression is resolved. ``only=`` restricts it to the given 1-based - slots (slash chords rather than a full pedal). - """ - - if isinstance(bass, str) and bass != "tonic": - bass = subsequence.chords.key_name_to_pc(bass) # note names are key-independent — resolve now - elif isinstance(bass, int) and not 0 <= bass <= 11: - raise ValueError(f"a bass pitch class must be 0–11, got {bass}") - - slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} - - spans = tuple( - dataclasses.replace(span, bass=bass) if index in slots else span - for index, span in enumerate(self.spans) - ) - - return dataclasses.replace(self, spans=spans) - - def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": - - """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. - - Modal interchange for key-relative content: the degree re-resolves - against the parallel mode (minor under a major scale and vice - versa). Concrete chords raise — there is nothing relative to borrow. - """ - - slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} - - spans = list(self.spans) - - for index in slots: - chord = spans[index].chord - if not isinstance(chord, RomanChord): - raise ValueError( - f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " - "borrow() needs key-relative content (an int degree or roman)" - ) - spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) - - return dataclasses.replace(self, spans=tuple(spans)) - - def replace (self, slot: int, chord: typing.Any) -> "Progression": - - """Replace the chord at a 1-based slot (the span keeps its beats).""" - - index = _check_slot(slot, len(self.spans)) - parsed = parse_element(chord, beats = self.spans[index].beats) - - spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] - - return dataclasses.replace(self, spans=spans) - - def cadence (self, name: str = "strong") -> "Progression": - - """Substitute a cadence formula into the tail — the close, named. - - The final spans take the formula's chords (``"strong"`` is V→I, - ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names — - authentic, plagal, half, deceptive — work as aliases). Each replaced - span keeps its beats; its old chord and decorations go. Formula - chords are key-relative (ints follow the bound scale's qualities, - ``"V"`` is the major dominant by convention), so the tail resolves - wherever the progression is bound — a concrete progression becomes - mixed and resolves its tail at bind time, like any roman content. - - Example:: - - verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") - # Bound in A minor: Am F Dm E — the half close, hanging on the dominant - - Raises: - ValueError: If the cadence name is unknown, or the progression - has fewer spans than the formula. - """ - - spec = subsequence.cadences.cadence_formula(name) - count = len(spec.formula) - - if len(self.spans) < count: - raise ValueError( - f"cadence({name!r}) substitutes the last {count} chords, but this " - f"progression has only {len(self.spans)}" - ) - - tail = tuple( - parse_element(element, beats = span.beats) - for element, span in zip(spec.formula, self.spans[-count:]) - ) - - return dataclasses.replace(self, spans = self.spans[:-count] + tail) - - def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": - - """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" - - if isinstance(beats, bool): - raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") - - values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] - - if not values: - raise ValueError("with_rhythm list is empty — pass at least one length") - - spans = tuple( - dataclasses.replace(span, beats = float(values[index % len(values)])) - for index, span in enumerate(self.spans) - ) - - return dataclasses.replace(self, spans=spans) - - def elaborate (self, depth: int = 1, seed: typing.Optional[int] = None) -> "Progression": - - """Steedman-inspired chord elaboration — approach each chord by fifths. - - Implements the heart of Mark Steedman's generative grammar for - jazz/blues chord sequences: every chord is **approached** by a chain - of secondary dominants propagated backward around the cycle of fifths - (Rule 3, "the perfect cadence propagated backward"), carved out of that - chord's own span (Rule 1, metric subdivision). ``depth`` is literally - how many fifth-steps back the chain extends: - - - ``depth=0`` — identity (the bare progression). - - ``depth=1`` — a secondary dominant before each chord: ``[X]`` → - ``[V7/X, X]`` (e.g. a bar of C becomes G7 C). - - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C). - - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord - is made minor — the ``ii`` of *its own local dominant* (the next - link in the chain), forming a ii–V into that link, not the - target's own ii — and dominants are recoloured by **tritone - substitution** with even odds (Rule 4) for chromatic descents. - This tritone choice is the only nondeterministic part, so ``seed`` - is taken (or warned) at depth ≥ 3. - - Its flagship is the 12-bar blues with depth-per-chorus — elaborate a - ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and - tritone subs accumulate. - - The progression must be **concrete** (resolved to rooted chords); - the inserted dominants are computed by pitch-class arithmetic. Each - chord keeps its decorations on the final (resolved) sub-span; the - inserted approach chords are bare dominant/minor sevenths. Note that - each span is divided into ``depth + 1`` equal sub-spans, so deep - elaboration of a short harmonic rhythm can drop sub-spans below the - harmony clock's lookahead floor — which raises at ``play()``/ - ``render()`` if the result is bound to the global clock (it is free - of that floor at the part level, ``p.progression()``). - - Parameters: - depth: Elaboration depth (≥ 0). - seed: Seed for the depth-≥3 tritone-substitution choices. - - Returns: - A new :class:`Progression` with the approach chords inserted. - - Raises: - ValueError: If *depth* is negative, the progression is - key-relative, or any span is a rootless - :class:`PitchSet`. - - Example: - ```python - blues = subsequence.progression("twelve_bar_blues").resolve("C") - chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout - ``` - """ - - if depth < 0: - raise ValueError("elaborate depth must be at least 0") - - self._require_concrete("elaborate") - - if depth == 0: - return self - - for span in self.spans: - if isinstance(span.chord, PitchSet): - raise ValueError("elaborate needs rooted chords — a PitchSet has no root to approach by fifths") - - if depth >= 3 and seed is None: - warnings.warn( - "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the " - "result survives live reload", - stacklevel = 2, - ) - - rng = random.Random(seed) - new_spans: typing.List[ChordSpan] = [] - - for span in self.spans: - - target_root = span.chord.root_pc - sub_beats = span.beats / (depth + 1) - - # The backward cycle-of-fifths chain, furthest-back first: chord j - # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The - # furthest-back (j == depth) is made minor — the ii of its OWN - # local dominant (the next link), forming a ii–V into that link — - # once the chain is long enough (depth >= 2) to spell one. - for j in range(depth, 0, -1): - root = (target_root + 7 * j) % 12 - quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th" - - # Tritone substitution recolours a dominant to the dom7 a - # tritone away (same guide tones, chromatic resolution). - if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5: - root = (root + 6) % 12 - - new_spans.append(ChordSpan(chord = subsequence.chords.Chord(root_pc = root, quality = quality), beats = sub_beats)) - - # The target keeps its own chord and decorations, on its sub-span. - new_spans.append(dataclasses.replace(span, beats = sub_beats)) - - return dataclasses.replace(self, spans = tuple(new_spans)) - - # -- description ---------------------------------------------------------- - - def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: - - """A readable, one-chord-per-line summary. - - Key-relative spans print as written (romans/degrees) when unbound, - and as concrete chord names under a *key*. - """ - - key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) - - lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] - cursor = 0.0 - - for span in self.spans: - lines.append( - f" {cursor:6.2f} … {cursor + span.beats:6.2f} " - f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" - ) - cursor += span.beats - - return "\n".join(lines) - - def __str__ (self) -> str: - - """Same as :meth:`describe` with no key bound.""" - - return self.describe() + def __str__(self) -> str: + """Same as :meth:`describe` with no key bound.""" + + return self.describe() # --------------------------------------------------------------------------- @@ -1619,295 +1788,322 @@ def __str__ (self) -> str: # --------------------------------------------------------------------------- -def _span_lengths (beats: typing.Union[float, typing.List[float]], count: int) -> typing.List[float]: - - """Resolve a beats= spec into per-span lengths — a scalar for all, or a list cycled.""" - - if isinstance(beats, bool): - raise TypeError(f"beats takes a number or a list of lengths, got bool: {beats!r}") - - if isinstance(beats, (int, float)): - return [float(beats)] * count - - values = [float(b) for b in beats] - - if not values: - raise ValueError("beats list is empty — pass at least one length") - - return [values[index % len(values)] for index in range(count)] - - -def progression ( - source: typing.Optional[typing.Any] = None, - beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, - *, - style: typing.Optional[str] = None, - bars: int = 8, - key: typing.Optional[str] = None, - scale: typing.Optional[str] = None, - seed: typing.Optional[int] = None, - rng: typing.Optional[random.Random] = None, - pins: typing.Optional[typing.Dict[int, typing.Any]] = None, - end: typing.Optional[typing.Any] = None, - avoid: typing.Optional[typing.Sequence[typing.Any]] = None, - cadence: typing.Optional[str] = None, - dominant_7th: bool = True, - gravity: float = 1.0, - nir_strength: float = 0.5, - minor_turnaround_weight: float = 0.0, - root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, +def _span_lengths( + beats: typing.Union[float, typing.List[float]], count: int +) -> typing.List[float]: + """Resolve a beats= spec into per-span lengths — a scalar for all, or a list cycled.""" + + if isinstance(beats, bool): + raise TypeError( + f"beats takes a number or a list of lengths, got bool: {beats!r}" + ) + + if isinstance(beats, (int, float)): + return [float(beats)] * count + + values = [float(b) for b in beats] + + if not values: + raise ValueError("beats list is empty — pass at least one length") + + return [values[index % len(values)] for index in range(count)] + + +def progression( + source: typing.Optional[typing.Any] = None, + beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, + *, + style: typing.Optional[str] = None, + bars: int = 8, + key: typing.Optional[str] = None, + scale: typing.Optional[str] = None, + seed: typing.Optional[int] = None, + rng: typing.Optional[random.Random] = None, + pins: typing.Optional[typing.Dict[int, typing.Any]] = None, + end: typing.Optional[typing.Any] = None, + avoid: typing.Optional[typing.Sequence[typing.Any]] = None, + cadence: typing.Optional[str] = None, + dominant_7th: bool = True, + gravity: float = 1.0, + nir_strength: float = 0.5, + minor_turnaround_weight: float = 0.0, + root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, ) -> Progression: - - """Build a :class:`Progression` — the lowercase factory. - - Dispatch by argument type: a **list** parses per element (ints where - diatonic, name/roman strings where nominal/chromatic, - ``(element, beats)`` tuples for per-chord durations); a bare **string** names a - preset from the curated table; ``style=`` generates *bars* chords from a - chord-graph walk (requires ``key=``). - - Parameters: - source: The element list, preset name, or an existing Progression - (returned unchanged). - beats: Span length per chord — a scalar, or a list cycled per chord - (``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm). - style: A chord-graph style name to generate from (e.g. - ``"aeolian_minor"``). - bars: How many chords to generate (style mode only). - key: Key for style generation. - seed: Seed for style generation. A standalone generated value - without a seed warns — module-level nondeterminism breaks live - reload. - rng: An explicit random stream (overrides ``seed``; used by - engine-mediated calls). - dominant_7th / gravity / nir_strength: Graph-walk parameters, - matching :meth:`Composition.harmony` (style mode only; full - pass-through arrives with ``Progression.generate``). - - Example: - ```python - verse = subsequence.progression([1, 6, 3, 7]) # i–VI–III–VII in A minor - blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"]) - walk = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) - ``` - """ - - if style is not None: - if source is not None: - raise ValueError("pass either source or style=, not both") - return Progression.generate( - style = style, - bars = bars, - beats = beats, - key = key, - scale = scale, - seed = seed, - rng = rng, - pins = pins, - end = end, - avoid = avoid, - cadence = cadence, - dominant_7th = dominant_7th, - gravity = gravity, - nir_strength = nir_strength, - minor_turnaround_weight = minor_turnaround_weight, - root_diversity = root_diversity, - ) - - # Generation-only knobs are meaningless for a concrete source — reject - # them so a musician asking for cadence= or key= on a list gets a usable - # error instead of a silent no-op. - generation_only = { - "bars": bars != 8, - "key": key is not None, - "scale": scale is not None, - "seed": seed is not None, - "rng": rng is not None, - "pins": pins is not None, - "end": end is not None, - "avoid": avoid is not None, - "cadence": cadence is not None, - "dominant_7th": dominant_7th is not True, - "gravity": gravity != 1.0, - "nir_strength": nir_strength != 0.5, - "minor_turnaround_weight": minor_turnaround_weight != 0.0, - "root_diversity": root_diversity != subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, - } - passed = [name for name, was_set in generation_only.items() if was_set] - - if passed: - raise ValueError( - f"{', '.join(sorted(passed))} only apply when generating with style=. " - "A concrete progression takes these as methods instead — e.g. " - ".cadence('strong') for the close, and the key binds at " - "composition.harmony() / resolve() time." - ) - - if isinstance(source, Progression): - return source - - if isinstance(source, str): - if source in _PRESETS: - return progression(_PRESETS[source], beats=beats) - known = ", ".join(sorted(_PRESETS)) - raise ValueError( - f"Unknown progression preset {source!r}. Known presets: {known}. " - "Or pass a list — progression([1, 6, 3, 7]) / progression(['Am', 'F', 'C', 'G'])." - ) - - if source is None: - raise ValueError("progression() needs a source list (or style=...)") - - elements = list(source) - - if not elements: - raise ValueError("progression list is empty — pass at least one chord") - - lengths = _span_lengths(beats, len(elements)) - - return Progression(spans = tuple( - parse_element(element, beats=lengths[index]) - for index, element in enumerate(elements) - )) - - -def _chord_source (source: ProgressionSource, key: typing.Optional[str], rng: random.Random, scale: str = "ionian") -> typing.Iterator[typing.Any]: - - """Yield chords indefinitely — generated from a graph style, or cycled from a list.""" - - if isinstance(source, str): - if not key: - raise ValueError(f"progression style {source!r} needs a key — pass key= or set the Composition key") - state = subsequence.harmonic_state.HarmonicState(key_name=key, graph_style=source, rng=rng) - yield state.current_chord - while True: - yield state.step() - - elif isinstance(source, Progression): - resolved = source if source.is_concrete else source.resolve( - subsequence.chords.key_name_to_pc(_require_key(source, key)), scale - ) - index = 0 - while True: - span = resolved.spans[index % len(resolved.spans)] - yield DecoratedChord(span) if span.is_decorated else span.chord - index += 1 - - else: - spans = [parse_element(item) for item in source] - if not spans: - raise ValueError("progression list is empty — pass at least one chord") - chords = [] - for span in spans: - if not span.is_concrete: - span = span.resolve(subsequence.chords.key_name_to_pc(_require_key(span, key)), scale) - chords.append(DecoratedChord(span) if span.is_decorated else span.chord) - index = 0 - while True: - yield chords[index % len(chords)] - index += 1 - - -def _require_key (what: typing.Any, key: typing.Optional[str]) -> str: - - """Return the key or raise the standard relative-content error.""" - - if not key: - raise ValueError( - "this progression contains key-relative content (degrees, romans, or a " - "'tonic' pedal bass) — pass key= or set the Composition key" - ) - - return key - - -def _resolve_length (spec: HarmonicRhythmSpec, index: int, rng: random.Random) -> float: - - """Resolve one chord length in beats from a harmonic-rhythm spec. - - Accepts a scalar (static), a list/tuple of lengths (a shaped rhythm, cycled by - ``index``), or a :class:`~subsequence.harmonic_rhythm.HarmonicRhythm` from - ``between(...)``. Mirrors the ``(low, high)``-tuple house idiom used for - velocity — except a range here is spelled ``between(...)`` so a bare list can - mean a *sequence* of lengths. - """ - - if isinstance(spec, subsequence.harmonic_rhythm.HarmonicRhythm): - return spec.resolve(rng) - if isinstance(spec, tuple): # type: ignore # the hint says list, but users pass a tuple anyway — guard for it - # A (low, high) tuple means a random range everywhere else in the API (e.g. - # velocity); here it would silently cycle. Reject it so the intent is explicit. - raise ValueError( - f"harmonic_rhythm tuple {spec!r} is ambiguous — use between{spec!r} for a random " - f"range, or a list {list(spec)!r} for a repeating sequence of lengths" - ) - if isinstance(spec, list): - if not spec: - raise ValueError("harmonic_rhythm sequence is empty — pass at least one length") - return float(spec[index % len(spec)]) - if isinstance(spec, bool): - raise TypeError(f"harmonic_rhythm must be a number, a list of lengths, or between(...), got bool: {spec!r}") - if isinstance(spec, (int, float)): - return float(spec) - raise TypeError(f"harmonic_rhythm must be a number, a list of lengths, or between(...), got {type(spec).__name__}") - - -def resolve_voices (voicing: VoicingSpec, rng: random.Random) -> int: - - """Resolve the voice count for one chord — a fixed int, or a ``(low, high)`` draw.""" - - if isinstance(voicing, bool): - raise TypeError(f"voicing must be an int or a (low, high) tuple, got bool: {voicing!r}") - if isinstance(voicing, int): - count = voicing - elif isinstance(voicing, tuple): - if len(voicing) != 2: - raise ValueError(f"voicing tuple must be (low, high), got {voicing!r}") - count = rng.randint(int(voicing[0]), int(voicing[1])) - else: - raise TypeError(f"voicing must be an int or a (low, high) tuple, got {type(voicing).__name__}") - if count < 1: - raise ValueError(f"voicing must be at least 1 voice, got {count}") - return count - - -def realize ( - source: ProgressionSource, - harmonic_rhythm: HarmonicRhythmSpec, - key: typing.Optional[str], - length: float, - rng: random.Random, - scale: str = "ionian", + """Build a :class:`Progression` — the lowercase factory. + + Dispatch by argument type: a **list** parses per element (ints where + diatonic, name/roman strings where nominal/chromatic, + ``(element, beats)`` tuples for per-chord durations); a bare **string** names a + preset from the curated table; ``style=`` generates *bars* chords from a + chord-graph walk (requires ``key=``). + + Parameters: + source: The element list, preset name, or an existing Progression + (returned unchanged). + beats: Span length per chord — a scalar, or a list cycled per chord + (``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm). + style: A chord-graph style name to generate from (e.g. + ``"aeolian_minor"``). + bars: How many chords to generate (style mode only). + key: Key for style generation. + seed: Seed for style generation. A standalone generated value + without a seed warns — module-level nondeterminism breaks live + reload. + rng: An explicit random stream (overrides ``seed``; used by + engine-mediated calls). + dominant_7th / gravity / nir_strength: Graph-walk parameters, + matching :meth:`Composition.harmony` (style mode only; full + pass-through arrives with ``Progression.generate``). + + Example: + ```python + verse = subsequence.progression([1, 6, 3, 7]) # i–VI–III–VII in A minor + blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"]) + walk = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) + ``` + """ + + if style is not None: + if source is not None: + raise ValueError("pass either source or style=, not both") + return Progression.generate( + style=style, + bars=bars, + beats=beats, + key=key, + scale=scale, + seed=seed, + rng=rng, + pins=pins, + end=end, + avoid=avoid, + cadence=cadence, + dominant_7th=dominant_7th, + gravity=gravity, + nir_strength=nir_strength, + minor_turnaround_weight=minor_turnaround_weight, + root_diversity=root_diversity, + ) + + # Generation-only knobs are meaningless for a concrete source — reject + # them so a musician asking for cadence= or key= on a list gets a usable + # error instead of a silent no-op. + generation_only = { + "bars": bars != 8, + "key": key is not None, + "scale": scale is not None, + "seed": seed is not None, + "rng": rng is not None, + "pins": pins is not None, + "end": end is not None, + "avoid": avoid is not None, + "cadence": cadence is not None, + "dominant_7th": dominant_7th is not True, + "gravity": gravity != 1.0, + "nir_strength": nir_strength != 0.5, + "minor_turnaround_weight": minor_turnaround_weight != 0.0, + "root_diversity": root_diversity + != subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, + } + passed = [name for name, was_set in generation_only.items() if was_set] + + if passed: + raise ValueError( + f"{', '.join(sorted(passed))} only apply when generating with style=. " + "A concrete progression takes these as methods instead — e.g. " + ".cadence('strong') for the close, and the key binds at " + "composition.harmony() / resolve() time." + ) + + if isinstance(source, Progression): + return source + + if isinstance(source, str): + if source in _PRESETS: + return progression(_PRESETS[source], beats=beats) + known = ", ".join(sorted(_PRESETS)) + raise ValueError( + f"Unknown progression preset {source!r}. Known presets: {known}. " + "Or pass a list — progression([1, 6, 3, 7]) / progression(['Am', 'F', 'C', 'G'])." + ) + + if source is None: + raise ValueError("progression() needs a source list (or style=...)") + + elements = list(source) + + if not elements: + raise ValueError("progression list is empty — pass at least one chord") + + lengths = _span_lengths(beats, len(elements)) + + return Progression( + spans=tuple( + parse_element(element, beats=lengths[index]) + for index, element in enumerate(elements) + ) + ) + + +def _chord_source( + source: ProgressionSource, + key: typing.Optional[str], + rng: random.Random, + scale: str = "ionian", +) -> typing.Iterator[typing.Any]: + """Yield chords indefinitely — generated from a graph style, or cycled from a list.""" + + if isinstance(source, str): + if not key: + raise ValueError( + f"progression style {source!r} needs a key — pass key= or set the Composition key" + ) + state = subsequence.harmonic_state.HarmonicState( + key_name=key, graph_style=source, rng=rng + ) + yield state.current_chord + while True: + yield state.step() + + elif isinstance(source, Progression): + resolved = ( + source + if source.is_concrete + else source.resolve( + subsequence.chords.key_name_to_pc(_require_key(source, key)), scale + ) + ) + index = 0 + while True: + span = resolved.spans[index % len(resolved.spans)] + yield DecoratedChord(span) if span.is_decorated else span.chord + index += 1 + + else: + spans = [parse_element(item) for item in source] + if not spans: + raise ValueError("progression list is empty — pass at least one chord") + chords = [] + for span in spans: + if not span.is_concrete: + span = span.resolve( + subsequence.chords.key_name_to_pc(_require_key(span, key)), scale + ) + chords.append(DecoratedChord(span) if span.is_decorated else span.chord) + index = 0 + while True: + yield chords[index % len(chords)] + index += 1 + + +def _require_key(what: typing.Any, key: typing.Optional[str]) -> str: + """Return the key or raise the standard relative-content error.""" + + if not key: + raise ValueError( + "this progression contains key-relative content (degrees, romans, or a " + "'tonic' pedal bass) — pass key= or set the Composition key" + ) + + return key + + +def _resolve_length(spec: HarmonicRhythmSpec, index: int, rng: random.Random) -> float: + """Resolve one chord length in beats from a harmonic-rhythm spec. + + Accepts a scalar (static), a list/tuple of lengths (a shaped rhythm, cycled by + ``index``), or a :class:`~subsequence.harmonic_rhythm.HarmonicRhythm` from + ``between(...)``. Mirrors the ``(low, high)``-tuple house idiom used for + velocity — except a range here is spelled ``between(...)`` so a bare list can + mean a *sequence* of lengths. + """ + + if isinstance(spec, subsequence.harmonic_rhythm.HarmonicRhythm): + return spec.resolve(rng) + if isinstance(spec, tuple): # type: ignore # the hint says list, but users pass a tuple anyway — guard for it + # A (low, high) tuple means a random range everywhere else in the API (e.g. + # velocity); here it would silently cycle. Reject it so the intent is explicit. + raise ValueError( + f"harmonic_rhythm tuple {spec!r} is ambiguous — use between{spec!r} for a random " + f"range, or a list {list(spec)!r} for a repeating sequence of lengths" + ) + if isinstance(spec, list): + if not spec: + raise ValueError( + "harmonic_rhythm sequence is empty — pass at least one length" + ) + return float(spec[index % len(spec)]) + if isinstance(spec, bool): + raise TypeError( + f"harmonic_rhythm must be a number, a list of lengths, or between(...), got bool: {spec!r}" + ) + if isinstance(spec, (int, float)): + return float(spec) + raise TypeError( + f"harmonic_rhythm must be a number, a list of lengths, or between(...), got {type(spec).__name__}" + ) + + +def resolve_voices(voicing: VoicingSpec, rng: random.Random) -> int: + """Resolve the voice count for one chord — a fixed int, or a ``(low, high)`` draw.""" + + if isinstance(voicing, bool): + raise TypeError( + f"voicing must be an int or a (low, high) tuple, got bool: {voicing!r}" + ) + if isinstance(voicing, int): + count = voicing + elif isinstance(voicing, tuple): + if len(voicing) != 2: + raise ValueError(f"voicing tuple must be (low, high), got {voicing!r}") + count = rng.randint(int(voicing[0]), int(voicing[1])) + else: + raise TypeError( + f"voicing must be an int or a (low, high) tuple, got {type(voicing).__name__}" + ) + if count < 1: + raise ValueError(f"voicing must be at least 1 voice, got {count}") + return count + + +def realize( + source: ProgressionSource, + harmonic_rhythm: HarmonicRhythmSpec, + key: typing.Optional[str], + length: float, + rng: random.Random, + scale: str = "ionian", ) -> Progression: - - """Lay a progression out across *length* beats and return a frozen value. - - Walks the chord source, giving each chord a harmonic-rhythm length, until the - part is full. The final chord is trimmed so the timeline ends exactly on - *length* and therefore loops cleanly. Voicing and articulation are not decided - here — they belong to whatever places the chords (the verb you call in the loop, - or :meth:`Composition.chords`). - """ - - if length <= 0: - raise ValueError(f"progression length ({length:g}) must be positive") - - stream = _chord_source(source, key, rng, scale) - spans: typing.List[ChordSpan] = [] - cursor = 0.0 - index = 0 - - while cursor < length - 1e-9: - chord = next(stream) - duration = _resolve_length(harmonic_rhythm, index, rng) - if duration <= 0: - raise ValueError(f"harmonic_rhythm produced a non-positive length ({duration:g}) — lengths are in beats and must be > 0") - duration = min(duration, length - cursor) - if isinstance(chord, DecoratedChord): - spans.append(dataclasses.replace(chord.span, beats=duration)) - else: - spans.append(ChordSpan(chord=chord, beats=duration)) - cursor += duration - index += 1 - - return Progression(spans = tuple(spans)) + """Lay a progression out across *length* beats and return a frozen value. + + Walks the chord source, giving each chord a harmonic-rhythm length, until the + part is full. The final chord is trimmed so the timeline ends exactly on + *length* and therefore loops cleanly. Voicing and articulation are not decided + here — they belong to whatever places the chords (the verb you call in the loop, + or :meth:`Composition.chords`). + """ + + if length <= 0: + raise ValueError(f"progression length ({length:g}) must be positive") + + stream = _chord_source(source, key, rng, scale) + spans: typing.List[ChordSpan] = [] + cursor = 0.0 + index = 0 + + while cursor < length - 1e-9: + chord = next(stream) + duration = _resolve_length(harmonic_rhythm, index, rng) + if duration <= 0: + raise ValueError( + f"harmonic_rhythm produced a non-positive length ({duration:g}) — lengths are in beats and must be > 0" + ) + duration = min(duration, length - cursor) + if isinstance(chord, DecoratedChord): + spans.append(dataclasses.replace(chord.span, beats=duration)) + else: + spans.append(ChordSpan(chord=chord, beats=duration)) + cursor += duration + index += 1 + + return Progression(spans=tuple(spans)) diff --git a/subsequence/roles.py b/subsequence/roles.py index 9688877..1354c16 100644 --- a/subsequence/roles.py +++ b/subsequence/roles.py @@ -15,7 +15,7 @@ @comp.pattern(channel=3, bars=2) def pad (p): - p.motif(chords, **subsequence.roles.PAD) + p.motif(chords, **subsequence.roles.PAD) ``` These are taste defaults, not rules — a bass usually sits low and locks to @@ -28,36 +28,36 @@ def pad (p): # A bass: low register, strong, locked hard to the chord tones. BASS: typing.Dict[str, typing.Any] = { - "root": 36, # C2 - "velocity": 105, - "fit": 0.9, + "root": 36, # C2 + "velocity": 105, + "fit": 0.9, } # A pad: mid register, soft, floating loosely over the changes. PAD: typing.Dict[str, typing.Any] = { - "root": 60, # C4 - "velocity": 70, - "fit": 0.6, + "root": 60, # C4 + "velocity": 70, + "fit": 0.6, } # A lead: upper register, present, playing against the changes. LEAD: typing.Dict[str, typing.Any] = { - "root": 72, # C5 - "velocity": 95, - "fit": 0.7, + "root": 72, # C5 + "velocity": 95, + "fit": 0.7, } # An arp: mid register, even and bright, free to wander between chord tones. ARP: typing.Dict[str, typing.Any] = { - "root": 60, # C4 - "velocity": 85, - "fit": 0.5, + "root": 60, # C4 + "velocity": 85, + "fit": 0.5, } # The bundles by name, for programmatic lookup. ROLES: typing.Dict[str, typing.Dict[str, typing.Any]] = { - "bass": BASS, - "pad": PAD, - "lead": LEAD, - "arp": ARP, + "bass": BASS, + "pad": PAD, + "lead": LEAD, + "arp": ARP, } diff --git a/subsequence/sequence_utils.py b/subsequence/sequence_utils.py index 8a7eb00..f2f9a72 100644 --- a/subsequence/sequence_utils.py +++ b/subsequence/sequence_utils.py @@ -16,2832 +16,2913 @@ T = typing.TypeVar("T") -def generate_euclidean_sequence (steps: int, pulses: int) -> typing.List[int]: - - """ - Generate a Euclidean rhythm using Bjorklund's algorithm. - """ - - if pulses < 0: - raise ValueError(f"Pulses must be zero or positive — got {pulses}") - - if pulses == 0: - return [0] * steps - - if pulses > steps: - raise ValueError(f"Pulses ({pulses}) cannot be greater than steps ({steps})") - - sequence = [] - counts = [] - remainders = [] - divisor = steps - pulses - - remainders.append(pulses) - level = 0 - - while True: - counts.append(divisor // remainders[level]) - remainders.append(divisor % remainders[level]) - divisor = remainders[level] - level += 1 - if remainders[level] <= 1: - break - - counts.append(divisor) - - def build (level: int) -> None: - if level == -1: - sequence.append(0) - elif level == -2: - sequence.append(1) - else: - for i in range(counts[level]): - build(level - 1) - if remainders[level] != 0: - build(level - 2) - - build(level) - i = sequence.index(1) - return sequence[i:] + sequence[:i] - - -def generate_bresenham_sequence (steps: int, pulses: int) -> typing.List[int]: - - """ - Generate a rhythm using Bresenham's line algorithm. - """ - - if pulses < 0: - raise ValueError(f"Pulses must be zero or positive — got {pulses}") - - sequence = [0] * steps - error = 0 - - # The accumulator starts at 0, so each group of steps fills up before it - # overflows — placing hits LATE in each group BY DESIGN. This is the - # documented difference from generate_euclidean_sequence, which rotates - # its result to start on a hit. - for i in range(steps): - error += pulses - if error >= steps: - sequence[i] = 1 - error -= steps - - return sequence - - -def generate_bresenham_sequence_weighted (steps: int, weights: typing.List[float]) -> typing.List[int]: - - """ - Generate a sequence that distributes weighted indices across steps. - """ - - if steps <= 0: - raise ValueError("Steps must be positive") - - if not weights: - raise ValueError("Weights cannot be empty") - - acc = [0.0] * len(weights) - sequence: typing.List[int] = [] - - for _ in range(steps): - - for i, weight in enumerate(weights): - acc[i] += weight +def generate_euclidean_sequence(steps: int, pulses: int) -> typing.List[int]: + """ + Generate a Euclidean rhythm using Bjorklund's algorithm. + """ + + if pulses < 0: + raise ValueError(f"Pulses must be zero or positive — got {pulses}") + + if pulses == 0: + return [0] * steps + + if pulses > steps: + raise ValueError(f"Pulses ({pulses}) cannot be greater than steps ({steps})") + + sequence = [] + counts = [] + remainders = [] + divisor = steps - pulses + + remainders.append(pulses) + level = 0 + + while True: + counts.append(divisor // remainders[level]) + remainders.append(divisor % remainders[level]) + divisor = remainders[level] + level += 1 + if remainders[level] <= 1: + break + + counts.append(divisor) + + def build(level: int) -> None: + if level == -1: + sequence.append(0) + elif level == -2: + sequence.append(1) + else: + for i in range(counts[level]): + build(level - 1) + if remainders[level] != 0: + build(level - 2) + + build(level) + i = sequence.index(1) + return sequence[i:] + sequence[:i] + + +def generate_bresenham_sequence(steps: int, pulses: int) -> typing.List[int]: + """ + Generate a rhythm using Bresenham's line algorithm. + """ + + if pulses < 0: + raise ValueError(f"Pulses must be zero or positive — got {pulses}") + + sequence = [0] * steps + error = 0 + + # The accumulator starts at 0, so each group of steps fills up before it + # overflows — placing hits LATE in each group BY DESIGN. This is the + # documented difference from generate_euclidean_sequence, which rotates + # its result to start on a hit. + for i in range(steps): + error += pulses + if error >= steps: + sequence[i] = 1 + error -= steps + + return sequence + + +def generate_bresenham_sequence_weighted( + steps: int, weights: typing.List[float] +) -> typing.List[int]: + """ + Generate a sequence that distributes weighted indices across steps. + """ - chosen = max(range(len(weights)), key=lambda i: acc[i]) - sequence.append(chosen) - acc[chosen] -= 1.0 + if steps <= 0: + raise ValueError("Steps must be positive") - return sequence + if not weights: + raise ValueError("Weights cannot be empty") + acc = [0.0] * len(weights) + sequence: typing.List[int] = [] -def generate_van_der_corput_sequence (n: int, base: int = 2) -> typing.List[float]: + for _ in range(steps): + for i, weight in enumerate(weights): + acc[i] += weight - """ - Generate a sequence of n numbers using the van der Corput sequence. - """ + chosen = max(range(len(weights)), key=lambda i: acc[i]) + sequence.append(chosen) + acc[chosen] -= 1.0 - # base 1 never shrinks k (infinite loop) and base 0 divides by zero. - if base < 2: - raise ValueError(f"van der Corput base must be at least 2 — got {base}") + return sequence - sequence = [] - - for i in range(n): - value = 0.0 - f = 1.0 / base - k = i - while k > 0: - value += (k % base) * f - k //= base - f /= base - sequence.append(value) - - return sequence +def generate_van_der_corput_sequence(n: int, base: int = 2) -> typing.List[float]: + """ + Generate a sequence of n numbers using the van der Corput sequence. + """ -def sequence_to_indices (sequence: typing.List[int]) -> typing.List[int]: + # base 1 never shrinks k (infinite loop) and base 0 divides by zero. + if base < 2: + raise ValueError(f"van der Corput base must be at least 2 — got {base}") - """Extract step indices where hits occur in a binary sequence.""" + sequence = [] - return [i for i, v in enumerate(sequence) if v] + for i in range(n): + value = 0.0 + f = 1.0 / base + k = i + while k > 0: + value += (k % base) * f + k //= base + f /= base + sequence.append(value) + return sequence -def rotate (indices: typing.List[int], shift: int, length: int) -> typing.List[int]: - """Circularly rotate step indices by the specified amount, wrapping at *length*.""" +def sequence_to_indices(sequence: typing.List[int]) -> typing.List[int]: + """Extract step indices where hits occur in a binary sequence.""" - return [(i + shift) % length for i in indices] + return [i for i, v in enumerate(sequence) if v] -def displace (sequence: typing.List[T], amount: int) -> typing.List[T]: +def rotate(indices: typing.List[int], shift: int, length: int) -> typing.List[int]: + """Circularly rotate step indices by the specified amount, wrapping at *length*.""" - """Phase-shift a per-step pattern by a whole number of steps, wrapping. + return [(i + shift) % length for i in indices] - Moves every element of ``sequence`` along by ``amount`` positions and wraps - the steps that fall off one end back round to the other — the classic rhythm - *necklace* rotation (metric displacement). A **positive** ``amount`` pushes - the pattern **later** (to the right): the hit at step 0 in ``[1, 0, 0, 0]`` - lands on step 1. A negative ``amount`` pulls it earlier. ``amount`` is taken - modulo the length, so a whole revolution (or zero) returns the pattern - unchanged and an over-length shift simply wraps. - Works on any per-step data — a 0/1 rhythm, a density profile, a velocity or - note list — since it only reorders the existing values. This is a different - operation from :func:`rotate`, which adds ``shift`` to the integer *values* - of a step-index list; ``displace`` moves the *positions* within a value list. +def displace(sequence: typing.List[T], amount: int) -> typing.List[T]: + """Phase-shift a per-step pattern by a whole number of steps, wrapping. - Parameters: - sequence: The per-step pattern to phase-shift. - amount: Steps to move later (positive) or earlier (negative); reduced - modulo ``len(sequence)``. + Moves every element of ``sequence`` along by ``amount`` positions and wraps + the steps that fall off one end back round to the other — the classic rhythm + *necklace* rotation (metric displacement). A **positive** ``amount`` pushes + the pattern **later** (to the right): the hit at step 0 in ``[1, 0, 0, 0]`` + lands on step 1. A negative ``amount`` pulls it earlier. ``amount`` is taken + modulo the length, so a whole revolution (or zero) returns the pattern + unchanged and an over-length shift simply wraps. - Returns: - A new list the same length as ``sequence`` (the input is never mutated), - or ``[]`` when ``sequence`` is empty. + Works on any per-step data — a 0/1 rhythm, a density profile, a velocity or + note list — since it only reorders the existing values. This is a different + operation from :func:`rotate`, which adds ``shift`` to the integer *values* + of a step-index list; ``displace`` moves the *positions* within a value list. - Example: - ```python - # Push a Euclidean kick one step later in the bar. - kick = subsequence.sequence_utils.generate_euclidean_sequence(8, 3) - delayed = subsequence.sequence_utils.displace(kick, 1) - steps = subsequence.sequence_utils.sequence_to_indices(delayed) - ``` - """ + Parameters: + sequence: The per-step pattern to phase-shift. + amount: Steps to move later (positive) or earlier (negative); reduced + modulo ``len(sequence)``. - if not sequence: - return [] + Returns: + A new list the same length as ``sequence`` (the input is never mutated), + or ``[]`` when ``sequence`` is empty. - k = amount % len(sequence) - return sequence[-k:] + sequence[:-k] + Example: + ```python + # Push a Euclidean kick one step later in the bar. + kick = subsequence.sequence_utils.generate_euclidean_sequence(8, 3) + delayed = subsequence.sequence_utils.displace(kick, 1) + steps = subsequence.sequence_utils.sequence_to_indices(delayed) + ``` + """ + if not sequence: + return [] -def generate_legato_durations (hits: typing.List[int]) -> typing.List[int]: + k = amount % len(sequence) + return sequence[-k:] + sequence[:-k] - """ - Convert a hit list into per-step legato durations. - """ - if not hits: - return [] +def generate_legato_durations(hits: typing.List[int]) -> typing.List[int]: + """ + Convert a hit list into per-step legato durations. + """ - note_on_indices = [idx for idx, hit in enumerate(hits) if hit] - note_on_indices.append(len(hits)) + if not hits: + return [] - durations = [0] * len(hits) + note_on_indices = [idx for idx, hit in enumerate(hits) if hit] + note_on_indices.append(len(hits)) - for idx, next_idx in zip(note_on_indices[:-1], note_on_indices[1:]): - durations[idx] = max(1, next_idx - idx) + durations = [0] * len(hits) - return durations + for idx, next_idx in zip(note_on_indices[:-1], note_on_indices[1:]): + durations[idx] = max(1, next_idx - idx) + return durations -def tile (sequence: typing.List[T], length: int) -> typing.List[T]: - """Cycle a sequence to an exact length. +def tile(sequence: typing.List[T], length: int) -> typing.List[T]: + """Cycle a sequence to an exact length. - Repeats ``sequence`` end-to-end and truncates so the result is exactly - ``length`` items long — ``tile([1, 0, 0], 8)`` gives ``[1, 0, 0, 1, 0, 0, 1, - 0]``. Reach for it when the target length is not a whole multiple of the - pattern; for an exact multiple, plain ``[1, 0, 0] * 3`` reads more clearly. + Repeats ``sequence`` end-to-end and truncates so the result is exactly + ``length`` items long — ``tile([1, 0, 0], 8)`` gives ``[1, 0, 0, 1, 0, 0, 1, + 0]``. Reach for it when the target length is not a whole multiple of the + pattern; for an exact multiple, plain ``[1, 0, 0] * 3`` reads more clearly. - Works on any per-step data — a rhythm, a density profile, notes, velocities. + Works on any per-step data — a rhythm, a density profile, notes, velocities. - Parameters: - sequence: The pattern to repeat. Must be non-empty when ``length > 0``. - length: The exact length of the result. + Parameters: + sequence: The pattern to repeat. Must be non-empty when ``length > 0``. + length: The exact length of the result. - Returns: - A new list of exactly ``length`` items, or ``[]`` when ``length <= 0``. + Returns: + A new list of exactly ``length`` items, or ``[]`` when ``length <= 0``. - Raises: - ValueError: If ``sequence`` is empty and ``length > 0`` — there is - nothing to cycle. + Raises: + ValueError: If ``sequence`` is empty and ``length > 0`` — there is + nothing to cycle. - Example: - ```python - # Stretch a three-step kick cell across a 16-step bar. - bar = subsequence.sequence_utils.tile([1, 0, 0], 16) - ``` - """ + Example: + ```python + # Stretch a three-step kick cell across a 16-step bar. + bar = subsequence.sequence_utils.tile([1, 0, 0], 16) + ``` + """ - if length <= 0: - return [] + if length <= 0: + return [] - if not sequence: - raise ValueError("cannot tile an empty sequence") + if not sequence: + raise ValueError("cannot tile an empty sequence") - return [sequence[i % len(sequence)] for i in range(length)] + return [sequence[i % len(sequence)] for i in range(length)] -def _select_mask ( - sequence: typing.List[T], - against: typing.Optional[typing.Sequence[typing.Any]], - steps: typing.Optional[typing.Iterable[int]], - off: typing.Any, - keep_active: bool, +def _select_mask( + sequence: typing.List[T], + against: typing.Optional[typing.Sequence[typing.Any]], + steps: typing.Optional[typing.Iterable[int]], + off: typing.Any, + keep_active: bool, ) -> typing.List[T]: - - """Gate ``sequence`` against an active selector (the shared mask/choke core). - - Exactly one of ``against`` (a parallel part, active where ``against[i]`` is - truthy) or ``steps`` (a collection of step indices, active at those positions) - must be given. With ``keep_active`` True each step is kept where the selector - is active and set to ``off`` elsewhere; with False the sense is inverted. An - ``against`` part shorter than ``sequence`` repeats its last value; an empty one - is inactive everywhere; indices in ``steps`` outside the sequence are ignored. - """ - - if (against is None) == (steps is None): - raise ValueError("pass exactly one of against= or steps=") - - if against is not None: - active = ( - [bool(against[i]) if i < len(against) else bool(against[-1]) for i in range(len(sequence))] - if against else [False] * len(sequence) - ) - else: - index_set = set(steps if steps is not None else []) - active = [i in index_set for i in range(len(sequence))] - - return [value if (flag == keep_active) else off for value, flag in zip(sequence, active)] - - -def mask ( - sequence: typing.List[T], - against: typing.Optional[typing.Sequence[typing.Any]] = None, - steps: typing.Optional[typing.Iterable[int]] = None, - zero: typing.Any = 0, + """Gate ``sequence`` against an active selector (the shared mask/choke core). + + Exactly one of ``against`` (a parallel part, active where ``against[i]`` is + truthy) or ``steps`` (a collection of step indices, active at those positions) + must be given. With ``keep_active`` True each step is kept where the selector + is active and set to ``off`` elsewhere; with False the sense is inverted. An + ``against`` part shorter than ``sequence`` repeats its last value; an empty one + is inactive everywhere; indices in ``steps`` outside the sequence are ignored. + """ + + if (against is None) == (steps is None): + raise ValueError("pass exactly one of against= or steps=") + + if against is not None: + active = ( + [ + bool(against[i]) if i < len(against) else bool(against[-1]) + for i in range(len(sequence)) + ] + if against + else [False] * len(sequence) + ) + else: + index_set = set(steps if steps is not None else []) + active = [i in index_set for i in range(len(sequence))] + + return [ + value if (flag == keep_active) else off for value, flag in zip(sequence, active) + ] + + +def mask( + sequence: typing.List[T], + against: typing.Optional[typing.Sequence[typing.Any]] = None, + steps: typing.Optional[typing.Iterable[int]] = None, + zero: typing.Any = 0, ) -> typing.List[T]: - - """Keep the steps where a selector is active, zeroing the rest. - - Gates ``sequence`` against a selector, keeping ``sequence[i]`` where the - selector is **active** and replacing every other step with ``zero``. Give the - selector as **exactly one** of: - - - ``against`` — a parallel part, active where ``against[i]`` is truthy - (non-zero). Shorter than ``sequence`` it repeats its last value; an - empty one is inactive everywhere. - - ``steps`` — a collection of step indices, active at exactly those - positions. Indices outside the sequence are ignored. - - Works on any per-step data — densities, 0/1 gates, notes, velocities — since - the kept steps keep their value. Off steps take ``zero`` (default ``0``); pass - ``zero=0.0`` to keep a float density profile float. - - Parameters: - sequence: The per-step data to gate. - against: A parallel part, active where truthy. Mutually exclusive with - ``steps``. - steps: Step indices at which the selector is active. Mutually exclusive - with ``against``. - zero: The value written to inactive steps (default ``0``). - - Returns: - A new list the length of ``sequence``. - - Raises: - ValueError: If neither or both of ``against`` and ``steps`` are given. - - Example: - ```python - # Keep the open hat only on the backbeat accents. - accent = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0] - open_hat = subsequence.sequence_utils.mask(open_hat_density, against=accent) - - # Or keep only the downbeats, by step index. - downs = subsequence.sequence_utils.mask(open_hat_density, steps=[0, 4, 8, 12]) - ``` - """ - - return _select_mask(sequence, against, steps, zero, keep_active=True) - - -def choke ( - sequence: typing.List[T], - against: typing.Optional[typing.Sequence[typing.Any]] = None, - steps: typing.Optional[typing.Iterable[int]] = None, - floor: typing.Any = 0, + """Keep the steps where a selector is active, zeroing the rest. + + Gates ``sequence`` against a selector, keeping ``sequence[i]`` where the + selector is **active** and replacing every other step with ``zero``. Give the + selector as **exactly one** of: + + - ``against`` — a parallel part, active where ``against[i]`` is truthy + (non-zero). Shorter than ``sequence`` it repeats its last value; an + empty one is inactive everywhere. + - ``steps`` — a collection of step indices, active at exactly those + positions. Indices outside the sequence are ignored. + + Works on any per-step data — densities, 0/1 gates, notes, velocities — since + the kept steps keep their value. Off steps take ``zero`` (default ``0``); pass + ``zero=0.0`` to keep a float density profile float. + + Parameters: + sequence: The per-step data to gate. + against: A parallel part, active where truthy. Mutually exclusive with + ``steps``. + steps: Step indices at which the selector is active. Mutually exclusive + with ``against``. + zero: The value written to inactive steps (default ``0``). + + Returns: + A new list the length of ``sequence``. + + Raises: + ValueError: If neither or both of ``against`` and ``steps`` are given. + + Example: + ```python + # Keep the open hat only on the backbeat accents. + accent = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0] + open_hat = subsequence.sequence_utils.mask(open_hat_density, against=accent) + + # Or keep only the downbeats, by step index. + downs = subsequence.sequence_utils.mask(open_hat_density, steps=[0, 4, 8, 12]) + ``` + """ + + return _select_mask(sequence, against, steps, zero, keep_active=True) + + +def choke( + sequence: typing.List[T], + against: typing.Optional[typing.Sequence[typing.Any]] = None, + steps: typing.Optional[typing.Iterable[int]] = None, + floor: typing.Any = 0, ) -> typing.List[T]: + """Suppress the steps where a selector is active, keeping the rest. - """Suppress the steps where a selector is active, keeping the rest. - - The complement of :func:`mask`: replaces ``sequence[i]`` with ``floor`` where - the selector is **active**, and keeps it everywhere else. This is the classic - drum *choke* — one voice silences another on the steps it sounds. Give the - selector as **exactly one** of ``against`` (a parallel part, active where - truthy) or ``steps`` (a collection of active step indices), with the same - repeat-last / ignore-out-of-range / empty rules as :func:`mask`. - - ``choke(seq, against=other)`` is the same as masking by the complement of - ``other``. + The complement of :func:`mask`: replaces ``sequence[i]`` with ``floor`` where + the selector is **active**, and keeps it everywhere else. This is the classic + drum *choke* — one voice silences another on the steps it sounds. Give the + selector as **exactly one** of ``against`` (a parallel part, active where + truthy) or ``steps`` (a collection of active step indices), with the same + repeat-last / ignore-out-of-range / empty rules as :func:`mask`. - Parameters: - sequence: The per-step data to gate. - against: A parallel part; steps are suppressed where it is truthy. - Mutually exclusive with ``steps``. - steps: Step indices to suppress. Mutually exclusive with ``against``. - floor: The value written to suppressed steps (default ``0``). + ``choke(seq, against=other)`` is the same as masking by the complement of + ``other``. - Returns: - A new list the length of ``sequence``. + Parameters: + sequence: The per-step data to gate. + against: A parallel part; steps are suppressed where it is truthy. + Mutually exclusive with ``steps``. + steps: Step indices to suppress. Mutually exclusive with ``against``. + floor: The value written to suppressed steps (default ``0``). - Raises: - ValueError: If neither or both of ``against`` and ``steps`` are given. + Returns: + A new list the length of ``sequence``. - Example: - ```python - # Closed hat chokes the open hat wherever the closed hat sounds. - open_hat = subsequence.sequence_utils.choke(open_hat_density, against=closed_hat_density) + Raises: + ValueError: If neither or both of ``against`` and ``steps`` are given. - # Drum 2 ducks out on the steps drum 1 already fired. - drum_2 = subsequence.sequence_utils.choke(drum_2_density, steps=fired_steps) - ``` - """ + Example: + ```python + # Closed hat chokes the open hat wherever the closed hat sounds. + open_hat = subsequence.sequence_utils.choke(open_hat_density, against=closed_hat_density) - return _select_mask(sequence, against, steps, floor, keep_active=False) + # Drum 2 ducks out on the steps drum 1 already fired. + drum_2 = subsequence.sequence_utils.choke(drum_2_density, steps=fired_steps) + ``` + """ + return _select_mask(sequence, against, steps, floor, keep_active=False) -def weighted_choice (options: typing.List[typing.Tuple[T, float]], rng: random.Random) -> T: - """Pick one item from a list of (value, weight) pairs. +def weighted_choice( + options: typing.List[typing.Tuple[T, float]], rng: random.Random +) -> T: + """Pick one item from a list of (value, weight) pairs. - Weights are relative - they don't need to sum to 1.0. Higher weight means - higher probability of selection. + Weights are relative - they don't need to sum to 1.0. Higher weight means + higher probability of selection. - Parameters: - options: List of ``(value, weight)`` tuples - rng: Random number generator instance + Parameters: + options: List of ``(value, weight)`` tuples + rng: Random number generator instance - Example: - ```python - density = subsequence.sequence_utils.weighted_choice([ - (3, 0.5), # 3 hits: 50% - (5, 0.3), # 5 hits: 30% - (7, 0.2), # 7 hits: 20% - ], p.rng) - p.euclidean("snare", pulses=density) - ``` - """ + Example: + ```python + density = subsequence.sequence_utils.weighted_choice([ + (3, 0.5), # 3 hits: 50% + (5, 0.3), # 5 hits: 30% + (7, 0.2), # 7 hits: 20% + ], p.rng) + p.euclidean("snare", pulses=density) + ``` + """ - if not options: - raise ValueError("Options list cannot be empty") + if not options: + raise ValueError("Options list cannot be empty") - values, weights = zip(*options) - total = sum(weights) + values, weights = zip(*options) + total = sum(weights) - if total <= 0: - raise ValueError("Total weight must be positive") + if total <= 0: + raise ValueError("Total weight must be positive") - threshold = rng.random() * total - cumulative = 0.0 + threshold = rng.random() * total + cumulative = 0.0 - for value, weight in options: - cumulative += weight - if cumulative >= threshold: - return value + for value, weight in options: + cumulative += weight + if cumulative >= threshold: + return value - return options[-1][0] + return options[-1][0] -def shuffled_choices (pool: typing.List[T], n: int, rng: random.Random) -> typing.List[T]: - - """Choose N items from a pool with no immediate repetition. - - Within each pass through the pool, every item appears before any repeats. - Across reshuffles, the last item of one pass is never the first of the next. - Similar to Max/MSP's ``urn`` object. - - The no-immediate-repeat guarantee assumes the pool values are distinct — - a pool containing duplicates (e.g. ``[100, 100, 80]``) can still place - equal values back to back. - - Parameters: - pool: Items to choose from - n: Number of items to return - rng: Random number generator instance - - Example: - ```python - # 16 velocity values with no adjacent repeats - vels = subsequence.sequence_utils.shuffled_choices([70, 85, 100, 110], 16, p.rng) - ``` - """ - - if not pool: - raise ValueError("Pool cannot be empty") +def shuffled_choices( + pool: typing.List[T], n: int, rng: random.Random +) -> typing.List[T]: + """Choose N items from a pool with no immediate repetition. - if n <= 0: - return [] + Within each pass through the pool, every item appears before any repeats. + Across reshuffles, the last item of one pass is never the first of the next. + Similar to Max/MSP's ``urn`` object. - result: typing.List[T] = [] - last: typing.Optional[T] = None + The no-immediate-repeat guarantee assumes the pool values are distinct — + a pool containing duplicates (e.g. ``[100, 100, 80]``) can still place + equal values back to back. - while len(result) < n: + Parameters: + pool: Items to choose from + n: Number of items to return + rng: Random number generator instance - deck = list(pool) - rng.shuffle(deck) + Example: + ```python + # 16 velocity values with no adjacent repeats + vels = subsequence.sequence_utils.shuffled_choices([70, 85, 100, 110], 16, p.rng) + ``` + """ - # Prevent adjacent repeat across reshuffles. - if last is not None and len(deck) > 1 and deck[0] == last: - # Swap with a random later position. - swap_idx = rng.randint(1, len(deck) - 1) - deck[0], deck[swap_idx] = deck[swap_idx], deck[0] + if not pool: + raise ValueError("Pool cannot be empty") - for item in deck: + if n <= 0: + return [] - if len(result) >= n: - break + result: typing.List[T] = [] + last: typing.Optional[T] = None - result.append(item) - last = item + while len(result) < n: + deck = list(pool) + rng.shuffle(deck) - return result + # Prevent adjacent repeat across reshuffles. + if last is not None and len(deck) > 1 and deck[0] == last: + # Swap with a random later position. + swap_idx = rng.randint(1, len(deck) - 1) + deck[0], deck[swap_idx] = deck[swap_idx], deck[0] + for item in deck: + if len(result) >= n: + break -def random_walk (n: int, low: int, high: int, step: int, rng: random.Random, start: typing.Optional[int] = None) -> typing.List[int]: + result.append(item) + last = item - """Generate values that drift by small steps within a range. + return result - Each value moves up or down by at most ``step`` from the previous, - clamped to ``[low, high]``. Similar to Max/MSP's ``drunk`` object. - Parameters: - n: Number of values to generate - low: Minimum value (inclusive) - high: Maximum value (inclusive) - step: Maximum change per step - rng: Random number generator instance - start: Starting value (default: midpoint of range) +def random_walk( + n: int, + low: int, + high: int, + step: int, + rng: random.Random, + start: typing.Optional[int] = None, +) -> typing.List[int]: + """Generate values that drift by small steps within a range. - Example: - ```python - # Wandering velocity for 16 hi-hat steps - walk = subsequence.sequence_utils.random_walk(16, low=50, high=110, step=15, rng=p.rng) - ``` - """ + Each value moves up or down by at most ``step`` from the previous, + clamped to ``[low, high]``. Similar to Max/MSP's ``drunk`` object. - if n <= 0: - return [] + Parameters: + n: Number of values to generate + low: Minimum value (inclusive) + high: Maximum value (inclusive) + step: Maximum change per step + rng: Random number generator instance + start: Starting value (default: midpoint of range) - if low > high: - raise ValueError(f"low ({low}) must be <= high ({high})") + Example: + ```python + # Wandering velocity for 16 hi-hat steps + walk = subsequence.sequence_utils.random_walk(16, low=50, high=110, step=15, rng=p.rng) + ``` + """ - if start is not None: - current = max(low, min(high, start)) - else: - current = (low + high) // 2 + if n <= 0: + return [] - result = [current] + if low > high: + raise ValueError(f"low ({low}) must be <= high ({high})") - for _ in range(n - 1): - delta = rng.randint(-step, step) - current = max(low, min(high, current + delta)) - result.append(current) + if start is not None: + current = max(low, min(high, start)) + else: + current = (low + high) // 2 - return result + result = [current] + for _ in range(n - 1): + delta = rng.randint(-step, step) + current = max(low, min(high, current + delta)) + result.append(current) -def probability_gate (sequence: typing.List[int], probability: typing.Union[float, typing.List[float]], rng: random.Random) -> typing.List[int]: + return result - """Filter a binary sequence by probability. - Each active step (value > 0) is kept with the given probability. - Inactive steps (value == 0) are never promoted. +def probability_gate( + sequence: typing.List[int], + probability: typing.Union[float, typing.List[float]], + rng: random.Random, +) -> typing.List[int]: + """Filter a binary sequence by probability. - Parameters: - sequence: Binary sequence (0s and 1s) - probability: Chance of keeping each hit (0.0–1.0). A single float applies - uniformly; a list assigns per-step probability. Steps beyond the - end of a short list are always kept (probability 1.0). - rng: Random number generator instance + Each active step (value > 0) is kept with the given probability. + Inactive steps (value == 0) are never promoted. - Example: - ```python - seq = subsequence.sequence_utils.generate_euclidean_sequence(16, 7) - gated = subsequence.sequence_utils.probability_gate(seq, 0.7, p.rng) - p.hit_steps("hh", subsequence.sequence_utils.sequence_to_indices(gated)) - ``` - """ + Parameters: + sequence: Binary sequence (0s and 1s) + probability: Chance of keeping each hit (0.0–1.0). A single float applies + uniformly; a list assigns per-step probability. Steps beyond the + end of a short list are always kept (probability 1.0). + rng: Random number generator instance - result: typing.List[int] = [] + Example: + ```python + seq = subsequence.sequence_utils.generate_euclidean_sequence(16, 7) + gated = subsequence.sequence_utils.probability_gate(seq, 0.7, p.rng) + p.hit_steps("hh", subsequence.sequence_utils.sequence_to_indices(gated)) + ``` + """ - for i, value in enumerate(sequence): + result: typing.List[int] = [] - if value == 0: - result.append(0) - continue + for i, value in enumerate(sequence): + if value == 0: + result.append(0) + continue - if isinstance(probability, list): - prob = probability[i] if i < len(probability) else 1.0 - else: - prob = probability + if isinstance(probability, list): + prob = probability[i] if i < len(probability) else 1.0 + else: + prob = probability - result.append(value if rng.random() < prob else 0) + result.append(value if rng.random() < prob else 0) - return result + return result @typing.overload -def density_to_steps (density: float, rng: random.Random, length: int) -> typing.List[int]: ... +def density_to_steps( + density: float, rng: random.Random, length: int +) -> typing.List[int]: ... @typing.overload -def density_to_steps (density: typing.List[float], rng: random.Random, length: typing.Optional[int] = None) -> typing.List[int]: ... +def density_to_steps( + density: typing.List[float], rng: random.Random, length: typing.Optional[int] = None +) -> typing.List[int]: ... -def density_to_steps ( - density: typing.Union[float, typing.List[float]], - rng: random.Random, - length: typing.Optional[int] = None, +def density_to_steps( + density: typing.Union[float, typing.List[float]], + rng: random.Random, + length: typing.Optional[int] = None, ) -> typing.List[int]: + """Roll each step against its density and return the fired step indices. - """Roll each step against its density and return the fired step indices. - - Walks the grid and, for each step, draws a fresh random number and keeps - that step when the draw falls below the step's density — an independent - weighted coin per step. Returns the **list of fired step indices**, ready - to feed ``p.sequence(steps=...)`` or ``hit_steps`` and per-step - comprehensions over those indices. This is the named form of the - hand-written ``[i for i in range(n) if rng.random() < density[i]]``. + Walks the grid and, for each step, draws a fresh random number and keeps + that step when the draw falls below the step's density — an independent + weighted coin per step. Returns the **list of fired step indices**, ready + to feed ``p.sequence(steps=...)`` or ``hit_steps`` and per-step + comprehensions over those indices. This is the named form of the + hand-written ``[i for i in range(n) if rng.random() < density[i]]``. - It is the *stochastic* member of the density-gate family: :func:`threshold` - is the deterministic gate, :func:`probability_gate` thins an already-binary - sequence and returns a parallel 0/1 list, and ``density_to_steps`` takes a - pure density profile (floats in ``[0, 1]``) and emits the sparse set of - survivors as indices — no ``[1] * n`` base and no :func:`sequence_to_indices` - bridge needed. + It is the *stochastic* member of the density-gate family: :func:`threshold` + is the deterministic gate, :func:`probability_gate` thins an already-binary + sequence and returns a parallel 0/1 list, and ``density_to_steps`` takes a + pure density profile (floats in ``[0, 1]``) and emits the sparse set of + survivors as indices — no ``[1] * n`` base and no :func:`sequence_to_indices` + bridge needed. - ``density`` may be a per-step list (its length sets the grid) or a single - float applied uniformly, in which case ``length`` is **required** — a bare - probability has no grid of its own. A density at or above ``1.0`` always - fires; at or below ``0.0`` never fires. Pass a seeded ``random.Random`` (or - the pattern's ``p.rng``) for reproducible output. + ``density`` may be a per-step list (its length sets the grid) or a single + float applied uniformly, in which case ``length`` is **required** — a bare + probability has no grid of its own. A density at or above ``1.0`` always + fires; at or below ``0.0`` never fires. Pass a seeded ``random.Random`` (or + the pattern's ``p.rng``) for reproducible output. - Parameters: - density: A per-step density list (floats in ``[0, 1]``), or a single - float applied to every step (then ``length`` is required). - rng: The seeded random generator — pass the pattern's ``p.rng``. - length: The grid size when ``density`` is a scalar. Ignored for a list - density (the list's own length is used). + Parameters: + density: A per-step density list (floats in ``[0, 1]``), or a single + float applied to every step (then ``length`` is required). + rng: The seeded random generator — pass the pattern's ``p.rng``. + length: The grid size when ``density`` is a scalar. Ignored for a list + density (the list's own length is used). - Returns: - The fired step indices in ascending order — possibly empty. An empty - result is normal: a sparse profile may fire nothing on a given cycle. + Returns: + The fired step indices in ascending order — possibly empty. An empty + result is normal: a sparse profile may fire nothing on a given cycle. - Raises: - ValueError: If ``density`` is a scalar and ``length`` is not given — - there is no grid to roll against. + Raises: + ValueError: If ``density`` is a scalar and ``length`` is not given — + there is no grid to roll against. - Example: - ```python - # A per-step kick profile rolled into concrete hits. - profile = [0.9, 0.1, 0.5, 0.1, 0.8, 0.1, 0.4, 0.1] - steps = subsequence.sequence_utils.density_to_steps(profile, p.rng) - velocities = [40 + int(profile[i] * 30) for i in steps] - p.sequence(steps=steps, pitches="kick", velocities=velocities) + Example: + ```python + # A per-step kick profile rolled into concrete hits. + profile = [0.9, 0.1, 0.5, 0.1, 0.8, 0.1, 0.4, 0.1] + steps = subsequence.sequence_utils.density_to_steps(profile, p.rng) + velocities = [40 + int(profile[i] * 30) for i in steps] + p.sequence(steps=steps, pitches="kick", velocities=velocities) - # A uniform 30% across a 16-step bar needs an explicit length. - ghosts = subsequence.sequence_utils.density_to_steps(0.3, p.rng, length=16) - ``` - """ + # A uniform 30% across a 16-step bar needs an explicit length. + ghosts = subsequence.sequence_utils.density_to_steps(0.3, p.rng, length=16) + ``` + """ - if isinstance(density, list): - return [i for i in range(len(density)) if rng.random() < density[i]] + if isinstance(density, list): + return [i for i in range(len(density)) if rng.random() < density[i]] - if length is None: - raise ValueError("density_to_steps needs a length when density is a scalar") + if length is None: + raise ValueError("density_to_steps needs a length when density is a scalar") - return [i for i in range(length) if rng.random() < density] + return [i for i in range(length) if rng.random() < density] -def _density_warp_scalar (p: float, d: float) -> float: +def _density_warp_scalar(p: float, d: float) -> float: + """Warp a single probability ``p`` by knob ``d`` (the scalar core). - """Warp a single probability ``p`` by knob ``d`` (the scalar core). + Returns ``(p*d) / (p*d + (1-p)*(1-d))`` with hard guards at the edges so the + warp reaches exact ``0.0`` / ``1.0`` and never divides by zero. + """ - Returns ``(p*d) / (p*d + (1-p)*(1-d))`` with hard guards at the edges so the - warp reaches exact ``0.0`` / ``1.0`` and never divides by zero. - """ + if p <= 0.0: + return 0.0 - if p <= 0.0: - return 0.0 + if p >= 1.0: + return 1.0 - if p >= 1.0: - return 1.0 + if d <= 0.0: + return 0.0 - if d <= 0.0: - return 0.0 + if d >= 1.0: + return 1.0 - if d >= 1.0: - return 1.0 - - pd = p * d - return pd / (pd + (1.0 - p) * (1.0 - d)) + pd = p * d + return pd / (pd + (1.0 - p) * (1.0 - d)) @typing.overload -def density_warp (value: float, amount: float) -> float: ... +def density_warp(value: float, amount: float) -> float: ... @typing.overload -def density_warp (value: typing.List[float], amount: typing.Union[float, typing.List[float]]) -> typing.List[float]: ... +def density_warp( + value: typing.List[float], amount: typing.Union[float, typing.List[float]] +) -> typing.List[float]: ... @typing.overload -def density_warp (value: float, amount: typing.List[float]) -> typing.List[float]: ... +def density_warp(value: float, amount: typing.List[float]) -> typing.List[float]: ... -def density_warp ( - value: typing.Union[float, typing.List[float]], - amount: typing.Union[float, typing.List[float]], +def density_warp( + value: typing.Union[float, typing.List[float]], + amount: typing.Union[float, typing.List[float]], ) -> typing.Union[float, typing.List[float]]: + """Warp a probability/density by a single denser/sparser knob. + + Pushes a probability ``value`` in ``[0, 1]`` toward 1.0 (denser) or toward + 0.0 (sparser) with one knob ``amount`` in ``[0, 1]``. ``amount = 0.5`` is + the identity and returns ``value`` unchanged; above 0.5 thickens, below 0.5 + thins. The output is always in ``[0, 1]``. + + The map is ``W = (value*amount) / (value*amount + (1-value)*(1-amount))`` — + the logistic of ``logit(value) + logit(amount)``. Because the log-odds add, + warps **stack**: two warps equal one whose knobs combine by summing their + log-odds. ``amount = 1`` forces a full ``1.0`` and ``amount = 0`` a full + ``0.0`` for any ``value`` strictly between 0 and 1. + + ``value`` may be a single float or a list; the return matches that shape. + ``amount`` may likewise be a single float (applied to every element) or a + per-step list (e.g. a Perlin density field). The result is a list whenever + either argument is a list; when both are lists of unequal length the result + has the length of the longer, the shorter extended by repeating its last + value. An empty list yields an empty list. + + Parameters: + value: A probability/density in ``[0, 1]``, or a list of them. + amount: The warp knob in ``[0, 1]`` — ``0.5`` is identity, ``>0.5`` + denser, ``<0.5`` sparser. A float warps every element uniformly; + a list warps per step. + + Returns: + A float when both arguments are floats, otherwise a list of floats. + + Example: + ```python + # A per-step kick profile, thickened by one density knob. + profile = [0.9, 0.1, 0.5, 0.1, 0.8, 0.1, 0.4, 0.1] + dense = subsequence.sequence_utils.density_warp(profile, 0.7) + # dense is a list in [0, 1] — use as per-step probabilities or velocities. + ``` + """ + + if isinstance(value, list): + if isinstance(amount, list): + # Both lists: an empty operand yields []; otherwise pad the shorter + # by repeating its last element (the _expand_sequence_param rule). + if not value or not amount: + return [] + + n = max(len(value), len(amount)) + vs = value if len(value) == n else value + [value[-1]] * (n - len(value)) + amt = ( + amount + if len(amount) == n + else amount + [amount[-1]] * (n - len(amount)) + ) + return [_density_warp_scalar(v, a) for v, a in zip(vs, amt)] + + return [_density_warp_scalar(v, amount) for v in value] + + if isinstance(amount, list): + return [_density_warp_scalar(value, a) for a in amount] + + return _density_warp_scalar(value, amount) + + +def _sigmoid(x: float) -> float: + """Logistic sigmoid, evaluated so ``exp`` only ever sees a non-positive argument. + + Splitting on the sign of ``x`` keeps the exponent ``<= 0`` in both branches, + so the result saturates gracefully to ``1.0`` / ``0.0`` at large magnitude + instead of raising ``OverflowError`` or producing ``nan``. + """ + + if x >= 0.0: + z = math.exp(-x) + return 1.0 / (1.0 + z) + + z = math.exp(x) + return z / (1.0 + z) + + +def _density_spread_scalar(p: float, amount: float, m: float) -> float: + """Contrast a single probability ``p`` about anchor ``m`` by knob ``amount`` (the scalar core). + + Maps ``out = sigmoid(logit(m) + k*(logit(p) - logit(m)))`` with + ``k = amount/(1-amount)``, so ``odds(out) = odds(p)**k * odds(m)**(1-k)``. + Hard guards keep the result in ``[0, 1]`` and avoid log/exp blow-ups; + ``amount = 0.5`` returns ``p`` exactly (the general path round-trips through + ``log``/``exp`` and would otherwise drift by up to one ULP). + """ + + if p <= 0.0: + return 0.0 + + if p >= 1.0: + return 1.0 + + if amount <= 0.0: # k = 0 -> collapse onto the anchor + return m + + if amount >= 1.0: # k -> inf -> push to the nearest rail across m + if p < m: + return 0.0 + if p > m: + return 1.0 + return m + + if amount == 0.5: # k = 1 -> exact identity, no log/exp round-trip + return p - """Warp a probability/density by a single denser/sparser knob. - - Pushes a probability ``value`` in ``[0, 1]`` toward 1.0 (denser) or toward - 0.0 (sparser) with one knob ``amount`` in ``[0, 1]``. ``amount = 0.5`` is - the identity and returns ``value`` unchanged; above 0.5 thickens, below 0.5 - thins. The output is always in ``[0, 1]``. - - The map is ``W = (value*amount) / (value*amount + (1-value)*(1-amount))`` — - the logistic of ``logit(value) + logit(amount)``. Because the log-odds add, - warps **stack**: two warps equal one whose knobs combine by summing their - log-odds. ``amount = 1`` forces a full ``1.0`` and ``amount = 0`` a full - ``0.0`` for any ``value`` strictly between 0 and 1. - - ``value`` may be a single float or a list; the return matches that shape. - ``amount`` may likewise be a single float (applied to every element) or a - per-step list (e.g. a Perlin density field). The result is a list whenever - either argument is a list; when both are lists of unequal length the result - has the length of the longer, the shorter extended by repeating its last - value. An empty list yields an empty list. - - Parameters: - value: A probability/density in ``[0, 1]``, or a list of them. - amount: The warp knob in ``[0, 1]`` — ``0.5`` is identity, ``>0.5`` - denser, ``<0.5`` sparser. A float warps every element uniformly; - a list warps per step. - - Returns: - A float when both arguments are floats, otherwise a list of floats. - - Example: - ```python - # A per-step kick profile, thickened by one density knob. - profile = [0.9, 0.1, 0.5, 0.1, 0.8, 0.1, 0.4, 0.1] - dense = subsequence.sequence_utils.density_warp(profile, 0.7) - # dense is a list in [0, 1] — use as per-step probabilities or velocities. - ``` - """ - - if isinstance(value, list): - - if isinstance(amount, list): - - # Both lists: an empty operand yields []; otherwise pad the shorter - # by repeating its last element (the _expand_sequence_param rule). - if not value or not amount: - return [] - - n = max(len(value), len(amount)) - vs = value if len(value) == n else value + [value[-1]] * (n - len(value)) - amt = amount if len(amount) == n else amount + [amount[-1]] * (n - len(amount)) - return [_density_warp_scalar(v, a) for v, a in zip(vs, amt)] - - return [_density_warp_scalar(v, amount) for v in value] - - if isinstance(amount, list): - return [_density_warp_scalar(value, a) for a in amount] - - return _density_warp_scalar(value, amount) - - -def _sigmoid (x: float) -> float: - - """Logistic sigmoid, evaluated so ``exp`` only ever sees a non-positive argument. - - Splitting on the sign of ``x`` keeps the exponent ``<= 0`` in both branches, - so the result saturates gracefully to ``1.0`` / ``0.0`` at large magnitude - instead of raising ``OverflowError`` or producing ``nan``. - """ - - if x >= 0.0: - z = math.exp(-x) - return 1.0 / (1.0 + z) - - z = math.exp(x) - return z / (1.0 + z) - - -def _density_spread_scalar (p: float, amount: float, m: float) -> float: - - """Contrast a single probability ``p`` about anchor ``m`` by knob ``amount`` (the scalar core). - - Maps ``out = sigmoid(logit(m) + k*(logit(p) - logit(m)))`` with - ``k = amount/(1-amount)``, so ``odds(out) = odds(p)**k * odds(m)**(1-k)``. - Hard guards keep the result in ``[0, 1]`` and avoid log/exp blow-ups; - ``amount = 0.5`` returns ``p`` exactly (the general path round-trips through - ``log``/``exp`` and would otherwise drift by up to one ULP). - """ - - if p <= 0.0: - return 0.0 - - if p >= 1.0: - return 1.0 - - if amount <= 0.0: # k = 0 -> collapse onto the anchor - return m - - if amount >= 1.0: # k -> inf -> push to the nearest rail across m - if p < m: - return 0.0 - if p > m: - return 1.0 - return m - - if amount == 0.5: # k = 1 -> exact identity, no log/exp round-trip - return p - - k = amount / (1.0 - amount) - lm = math.log(m / (1.0 - m)) - lp = math.log(p / (1.0 - p)) - return _sigmoid(lm + k * (lp - lm)) + k = amount / (1.0 - amount) + lm = math.log(m / (1.0 - m)) + lp = math.log(p / (1.0 - p)) + return _sigmoid(lm + k * (lp - lm)) @typing.overload -def density_spread (value: float, amount: float, midpoint: float = 0.5) -> float: ... +def density_spread(value: float, amount: float, midpoint: float = 0.5) -> float: ... @typing.overload -def density_spread (value: typing.List[float], amount: typing.Union[float, typing.List[float]], midpoint: float = 0.5) -> typing.List[float]: ... +def density_spread( + value: typing.List[float], + amount: typing.Union[float, typing.List[float]], + midpoint: float = 0.5, +) -> typing.List[float]: ... @typing.overload -def density_spread (value: float, amount: typing.List[float], midpoint: float = 0.5) -> typing.List[float]: ... +def density_spread( + value: float, amount: typing.List[float], midpoint: float = 0.5 +) -> typing.List[float]: ... -def density_spread ( - value: typing.Union[float, typing.List[float]], - amount: typing.Union[float, typing.List[float]], - midpoint: float = 0.5, +def density_spread( + value: typing.Union[float, typing.List[float]], + amount: typing.Union[float, typing.List[float]], + midpoint: float = 0.5, ) -> typing.Union[float, typing.List[float]]: - - """Expand or contract a probability/density about a fixed anchor. - - Where :func:`density_warp` *shifts* a value denser or sparser, this *scales* - the spread of values around an anchor ``midpoint`` — the contrast twin of the - warp. ``amount = 0.5`` is the identity; above 0.5 **expands** the spread - (pushes values away from the anchor, toward 0 and 1); below 0.5 **contracts** - it (pulls values toward the anchor). A ``value`` equal to ``midpoint`` never - moves, and the output is always in ``[0, 1]``. - - The map scales each value's log-odds deviation from the anchor by a gain - ``k = amount/(1-amount)``, so the odds compound as - ``odds(out) = odds(value)**k * odds(midpoint)**(1-k)``. Handy points: - ``amount = 0.75`` triples the spread (``k = 3``), ``0.25`` thirds it - (``k = 1/3``); ``amount`` and ``1 - amount`` are exact inverses (contract - then expand-by-complement returns the input). - - The spread is even in log-odds, not in linear distance: it stretches the - mid-range most and barely moves values already near 0 or 1, and a strong - expand pins near-rail values onto the rails (the same saturation - :func:`density_warp` has at ``amount`` near 0 or 1). - - ``value`` may be a single float or a list; the return matches that shape. - ``amount`` may likewise be a single float (applied to every element) or a - per-step list. The result is a list whenever either is a list; on unequal - lengths the shorter repeats its last value, and an empty list yields an empty - list. ``midpoint`` is a single anchor in the open interval ``(0, 1)``. - - Parameters: - value: A probability/density in ``[0, 1]``, or a list of them. - amount: The spread knob in ``[0, 1]`` — ``0.5`` is identity, ``>0.5`` - expands, ``<0.5`` contracts. A float spreads every element - uniformly; a list spreads per step. - midpoint: The fixed anchor in ``(0, 1)`` that the spread pivots around - (default ``0.5``). Values stay on their own side of it. - - Returns: - A float when ``value`` and ``amount`` are both floats, otherwise a list - of floats. - - Raises: - ValueError: If ``midpoint`` is not strictly between 0 and 1 (an anchor on - a rail has no defined log-odds). - - Example: - ```python - # Sharpen the contrast of a per-step accent profile around 0.5. - accents = [0.55, 0.3, 0.7, 0.45, 0.8, 0.25, 0.6, 0.4] - sharp = subsequence.sequence_utils.density_spread(accents, 0.75) - # values above 0.5 pushed up, below pushed down — same centre, wider spread. - ``` - """ - - if not 0.0 < midpoint < 1.0: - raise ValueError(f"midpoint must be strictly between 0 and 1, got {midpoint}") - - if isinstance(value, list): - - if isinstance(amount, list): - - # Both lists: an empty operand yields []; otherwise pad the shorter - # by repeating its last element (the _expand_sequence_param rule). - if not value or not amount: - return [] - - n = max(len(value), len(amount)) - vs = value if len(value) == n else value + [value[-1]] * (n - len(value)) - amt = amount if len(amount) == n else amount + [amount[-1]] * (n - len(amount)) - return [_density_spread_scalar(v, a, midpoint) for v, a in zip(vs, amt)] - - return [_density_spread_scalar(v, amount, midpoint) for v in value] - - if isinstance(amount, list): - return [_density_spread_scalar(value, a, midpoint) for a in amount] - - return _density_spread_scalar(value, amount, midpoint) - - -def _combine_geomean (values: typing.List[float]) -> float: - - """Geometric mean of ``values`` — the Nth root of their product.""" - - return math.prod(values) ** (1.0 / len(values)) + """Expand or contract a probability/density about a fixed anchor. + + Where :func:`density_warp` *shifts* a value denser or sparser, this *scales* + the spread of values around an anchor ``midpoint`` — the contrast twin of the + warp. ``amount = 0.5`` is the identity; above 0.5 **expands** the spread + (pushes values away from the anchor, toward 0 and 1); below 0.5 **contracts** + it (pulls values toward the anchor). A ``value`` equal to ``midpoint`` never + moves, and the output is always in ``[0, 1]``. + + The map scales each value's log-odds deviation from the anchor by a gain + ``k = amount/(1-amount)``, so the odds compound as + ``odds(out) = odds(value)**k * odds(midpoint)**(1-k)``. Handy points: + ``amount = 0.75`` triples the spread (``k = 3``), ``0.25`` thirds it + (``k = 1/3``); ``amount`` and ``1 - amount`` are exact inverses (contract + then expand-by-complement returns the input). + + The spread is even in log-odds, not in linear distance: it stretches the + mid-range most and barely moves values already near 0 or 1, and a strong + expand pins near-rail values onto the rails (the same saturation + :func:`density_warp` has at ``amount`` near 0 or 1). + + ``value`` may be a single float or a list; the return matches that shape. + ``amount`` may likewise be a single float (applied to every element) or a + per-step list. The result is a list whenever either is a list; on unequal + lengths the shorter repeats its last value, and an empty list yields an empty + list. ``midpoint`` is a single anchor in the open interval ``(0, 1)``. + + Parameters: + value: A probability/density in ``[0, 1]``, or a list of them. + amount: The spread knob in ``[0, 1]`` — ``0.5`` is identity, ``>0.5`` + expands, ``<0.5`` contracts. A float spreads every element + uniformly; a list spreads per step. + midpoint: The fixed anchor in ``(0, 1)`` that the spread pivots around + (default ``0.5``). Values stay on their own side of it. + + Returns: + A float when ``value`` and ``amount`` are both floats, otherwise a list + of floats. + + Raises: + ValueError: If ``midpoint`` is not strictly between 0 and 1 (an anchor on + a rail has no defined log-odds). + + Example: + ```python + # Sharpen the contrast of a per-step accent profile around 0.5. + accents = [0.55, 0.3, 0.7, 0.45, 0.8, 0.25, 0.6, 0.4] + sharp = subsequence.sequence_utils.density_spread(accents, 0.75) + # values above 0.5 pushed up, below pushed down — same centre, wider spread. + ``` + """ + + if not 0.0 < midpoint < 1.0: + raise ValueError(f"midpoint must be strictly between 0 and 1, got {midpoint}") + + if isinstance(value, list): + if isinstance(amount, list): + # Both lists: an empty operand yields []; otherwise pad the shorter + # by repeating its last element (the _expand_sequence_param rule). + if not value or not amount: + return [] + + n = max(len(value), len(amount)) + vs = value if len(value) == n else value + [value[-1]] * (n - len(value)) + amt = ( + amount + if len(amount) == n + else amount + [amount[-1]] * (n - len(amount)) + ) + return [_density_spread_scalar(v, a, midpoint) for v, a in zip(vs, amt)] + + return [_density_spread_scalar(v, amount, midpoint) for v in value] + + if isinstance(amount, list): + return [_density_spread_scalar(value, a, midpoint) for a in amount] + + return _density_spread_scalar(value, amount, midpoint) + + +def _combine_geomean(values: typing.List[float]) -> float: + """Geometric mean of ``values`` — the Nth root of their product.""" + + return math.prod(values) ** (1.0 / len(values)) _combine_reducers: typing.Dict[str, typing.Callable[[typing.List[float]], float]] = { - "geomean": _combine_geomean, - "min": min, - "mean": lambda values: sum(values) / len(values), - "product": math.prod, + "geomean": _combine_geomean, + "min": min, + "mean": lambda values: sum(values) / len(values), + "product": math.prod, } -def combine_densities ( - layers: typing.List[typing.Union[float, typing.List[float]]], - strategy: str = "geomean", +def combine_densities( + layers: typing.List[typing.Union[float, typing.List[float]]], + strategy: str = "geomean", ) -> typing.Union[float, typing.List[float]]: + """Blend several density layers into one consensus density. - """Blend several density layers into one consensus density. + Takes a list of density ``layers`` — each a single value in ``[0, 1]`` or a + per-step list in ``[0, 1]`` — and reduces them, step by step, to one density + value or list. This is the "agree on how busy this bar should be" stage; its + output is meant to feed the ``amount`` of :func:`density_warp`. - Takes a list of density ``layers`` — each a single value in ``[0, 1]`` or a - per-step list in ``[0, 1]`` — and reduces them, step by step, to one density - value or list. This is the "agree on how busy this bar should be" stage; its - output is meant to feed the ``amount`` of :func:`density_warp`. + The ``strategy`` picks the reducer (all preserve ``[0, 1]`` for valid inputs, + so no clamping is applied): - The ``strategy`` picks the reducer (all preserve ``[0, 1]`` for valid inputs, - so no clamping is applied): + - ``"geomean"`` (default) — the geometric mean, ``prod(values) ** (1/N)``. + A gentle consensus where any near-zero layer pulls the result down. + - ``"min"`` — the most restrictive layer wins (a strict gate). + - ``"mean"`` — the plain arithmetic average (a balanced vote). + - ``"product"`` — multiply them all (stacks toward sparse fast). - - ``"geomean"`` (default) — the geometric mean, ``prod(values) ** (1/N)``. - A gentle consensus where any near-zero layer pulls the result down. - - ``"min"`` — the most restrictive layer wins (a strict gate). - - ``"mean"`` — the plain arithmetic average (a balanced vote). - - ``"product"`` — multiply them all (stacks toward sparse fast). + Broadcasting generalises the rule in :func:`density_warp`: if every layer is + a single value the result is a single value; if any layer is a list the + result is a list as long as the longest layer, with scalar layers applied to + every step and shorter lists extended by repeating their last value. An + empty list layer yields ``[]``. - Broadcasting generalises the rule in :func:`density_warp`: if every layer is - a single value the result is a single value; if any layer is a list the - result is a list as long as the longest layer, with scalar layers applied to - every step and shorter lists extended by repeating their last value. An - empty list layer yields ``[]``. + Parameters: + layers: The density layers to blend. Each entry is a value in + ``[0, 1]`` or a per-step list of such values. Must be non-empty. + strategy: Reducer name — ``"geomean"`` (default), ``"min"``, ``"mean"``, + or ``"product"``. - Parameters: - layers: The density layers to blend. Each entry is a value in - ``[0, 1]`` or a per-step list of such values. Must be non-empty. - strategy: Reducer name — ``"geomean"`` (default), ``"min"``, ``"mean"``, - or ``"product"``. + Returns: + A float when every layer is a float, otherwise a list of floats. - Returns: - A float when every layer is a float, otherwise a list of floats. + Raises: + ValueError: If ``layers`` is empty, or ``strategy`` is unknown. - Raises: - ValueError: If ``layers`` is empty, or ``strategy`` is unknown. + Example: + ```python + # Blend a metric accent curve, an intensity envelope, and a global knob + # into a consensus, then warp a base probability by it. + accent = subsequence.sequence_utils.build_metric_weights((4, 4), 16) + envelope = subsequence.easing.ramp(16, 0.2, 0.9, "ease_in_out") + consensus = subsequence.sequence_utils.combine_densities( + [accent, envelope, 0.6], strategy="geomean") + probs = subsequence.sequence_utils.density_warp(0.5, consensus) + ``` + """ - Example: - ```python - # Blend a metric accent curve, an intensity envelope, and a global knob - # into a consensus, then warp a base probability by it. - accent = subsequence.sequence_utils.build_metric_weights((4, 4), 16) - envelope = subsequence.easing.ramp(16, 0.2, 0.9, "ease_in_out") - consensus = subsequence.sequence_utils.combine_densities( - [accent, envelope, 0.6], strategy="geomean") - probs = subsequence.sequence_utils.density_warp(0.5, consensus) - ``` - """ + if not layers: + raise ValueError("Layers cannot be empty") - if not layers: - raise ValueError("Layers cannot be empty") + if strategy not in _combine_reducers: + available = ", ".join(f'"{k}"' for k in sorted(_combine_reducers)) + raise ValueError( + f"Unknown combine strategy {strategy!r}. Available strategies: {available}" + ) - if strategy not in _combine_reducers: - available = ", ".join(f'"{k}"' for k in sorted(_combine_reducers)) - raise ValueError( - f"Unknown combine strategy {strategy!r}. Available strategies: {available}" - ) + reduce = _combine_reducers[strategy] - reduce = _combine_reducers[strategy] + if all(not isinstance(layer, list) for layer in layers): + return reduce([layer for layer in layers if not isinstance(layer, list)]) - if all(not isinstance(layer, list) for layer in layers): - return reduce([layer for layer in layers if not isinstance(layer, list)]) + lengths = [len(layer) for layer in layers if isinstance(layer, list)] - lengths = [len(layer) for layer in layers if isinstance(layer, list)] + if 0 in lengths: + return [] - if 0 in lengths: - return [] + n = max(lengths) + result: typing.List[float] = [] - n = max(lengths) - result: typing.List[float] = [] + for i in range(n): + step: typing.List[float] = [] - for i in range(n): + for layer in layers: + if isinstance(layer, list): + step.append(layer[i] if i < len(layer) else layer[-1]) + else: + step.append(layer) - step: typing.List[float] = [] + result.append(reduce(step)) - for layer in layers: - if isinstance(layer, list): - step.append(layer[i] if i < len(layer) else layer[-1]) - else: - step.append(layer) - - result.append(reduce(step)) - - return result + return result @typing.overload -def warp_stack (value: float, amounts: typing.List[float]) -> float: ... +def warp_stack(value: float, amounts: typing.List[float]) -> float: ... @typing.overload -def warp_stack (value: float, amounts: typing.List[typing.Union[float, typing.List[float]]]) -> typing.Union[float, typing.List[float]]: ... +def warp_stack( + value: float, amounts: typing.List[typing.Union[float, typing.List[float]]] +) -> typing.Union[float, typing.List[float]]: ... @typing.overload -def warp_stack (value: typing.List[float], amounts: typing.List[typing.Union[float, typing.List[float]]]) -> typing.List[float]: ... +def warp_stack( + value: typing.List[float], + amounts: typing.List[typing.Union[float, typing.List[float]]], +) -> typing.List[float]: ... -def warp_stack ( - value: typing.Union[float, typing.List[float]], - amounts: typing.Sequence[typing.Union[float, typing.List[float]]], +def warp_stack( + value: typing.Union[float, typing.List[float]], + amounts: typing.Sequence[typing.Union[float, typing.List[float]]], ) -> typing.Union[float, typing.List[float]]: + """Apply several density knobs to ``value`` so they compound. - """Apply several density knobs to ``value`` so they compound. - - Folds :func:`density_warp` over ``amounts``: it starts from ``value`` and - warps by each knob in turn. Because :func:`density_warp` adds log-odds, - stacking knobs equals one warp whose knobs sum in log-odds, so the order of - ``amounts`` does not matter and the neutral knob ``0.5`` is a no-op. - - Each knob may itself be a single value or a per-step list (it inherits - :func:`density_warp`'s broadcasting), so you can mix a global knob with - per-step envelopes. An empty ``amounts`` list returns ``value`` unchanged. + Folds :func:`density_warp` over ``amounts``: it starts from ``value`` and + warps by each knob in turn. Because :func:`density_warp` adds log-odds, + stacking knobs equals one warp whose knobs sum in log-odds, so the order of + ``amounts`` does not matter and the neutral knob ``0.5`` is a no-op. - Stacking saturates fast: every knob above ``0.5`` pushes harder toward - ``1.0`` (and below toward ``0.0``), so a few strong knobs can pin a sequence - almost fully on or off. Treat it like gain-staging — prefer a few gentle - knobs, or blend control layers first with :func:`combine_densities` and apply - the consensus as one knob. + Each knob may itself be a single value or a per-step list (it inherits + :func:`density_warp`'s broadcasting), so you can mix a global knob with + per-step envelopes. An empty ``amounts`` list returns ``value`` unchanged. - Parameters: - value: A probability/density in ``[0, 1]``, or a list of them. - amounts: The knobs to compound, each a value in ``[0, 1]`` (``0.5`` - neutral, ``>0.5`` denser, ``<0.5`` sparser) or a per-step list. + Stacking saturates fast: every knob above ``0.5`` pushes harder toward + ``1.0`` (and below toward ``0.0``), so a few strong knobs can pin a sequence + almost fully on or off. Treat it like gain-staging — prefer a few gentle + knobs, or blend control layers first with :func:`combine_densities` and apply + the consensus as one knob. - Returns: - A float when ``value`` and every knob are floats, otherwise a list. + Parameters: + value: A probability/density in ``[0, 1]``, or a list of them. + amounts: The knobs to compound, each a value in ``[0, 1]`` (``0.5`` + neutral, ``>0.5`` denser, ``<0.5`` sparser) or a per-step list. - Example: - ```python - # Compound a global swell, a humanised drift, and a per-step accent. - accent = subsequence.easing.ramp(16, 0.4, 0.8, "ease_in") - probs = subsequence.sequence_utils.warp_stack([0.5] * 16, [0.7, 0.55, accent]) - ``` - """ + Returns: + A float when ``value`` and every knob are floats, otherwise a list. - result = value + Example: + ```python + # Compound a global swell, a humanised drift, and a per-step accent. + accent = subsequence.easing.ramp(16, 0.4, 0.8, "ease_in") + probs = subsequence.sequence_utils.warp_stack([0.5] * 16, [0.7, 0.55, accent]) + ``` + """ - for amount in amounts: - result = density_warp(result, amount) + result = value - return result + for amount in amounts: + result = density_warp(result, amount) + return result -def scale_clamp (value: float, in_min: float, in_max: float, out_min: float = 0.0, out_max: float = 1.0) -> float: - """Scale a value from an input range to an output range and clamp the result. - - Maps a value from [in_min, in_max] to [out_min, out_max]. If the result - falls outside the output range, it is clamped to the nearest bound. - Correctly handles reversed ranges (where min > max). +def scale_clamp( + value: float, + in_min: float, + in_max: float, + out_min: float = 0.0, + out_max: float = 1.0, +) -> float: + """Scale a value from an input range to an output range and clamp the result. - Parameters: - value: The number to scale and clamp. - in_min: The start of the input range. - in_max: The end of the input range. - out_min: The start of the target output range (default: 0.0). - out_max: The end of the target output range (default: 1.0). + Maps a value from [in_min, in_max] to [out_min, out_max]. If the result + falls outside the output range, it is clamped to the nearest bound. + Correctly handles reversed ranges (where min > max). - Example: - ```python - # Scale sensor data (0-1023) to a probability (0.0-1.0) - prob = subsequence.sequence_utils.scale_clamp(sensor_val, 0, 1023) + Parameters: + value: The number to scale and clamp. + in_min: The start of the input range. + in_max: The end of the input range. + out_min: The start of the target output range (default: 0.0). + out_max: The end of the target output range (default: 1.0). - # Invert a MIDI CC (0-127) to a volume multiplier (1.0-0.0) - vol = subsequence.sequence_utils.scale_clamp(cc_val, 0, 127, 1.0, 0.0) - ``` - """ + Example: + ```python + # Scale sensor data (0-1023) to a probability (0.0-1.0) + prob = subsequence.sequence_utils.scale_clamp(sensor_val, 0, 1023) - if in_min == in_max: + # Invert a MIDI CC (0-127) to a volume multiplier (1.0-0.0) + vol = subsequence.sequence_utils.scale_clamp(cc_val, 0, 127, 1.0, 0.0) + ``` + """ - raise ValueError(f"Input range cannot be zero-width ({in_min} == {in_max})") + if in_min == in_max: + raise ValueError(f"Input range cannot be zero-width ({in_min} == {in_max})") - percentage = (value - in_min) / (in_max - in_min) - scaled = out_min + percentage * (out_max - out_min) + percentage = (value - in_min) / (in_max - in_min) + scaled = out_min + percentage * (out_max - out_min) - # Handle regular and reversed ranges - if out_min < out_max: - return max(out_min, min(out_max, scaled)) - else: - return max(out_max, min(out_min, scaled)) + # Handle regular and reversed ranges + if out_min < out_max: + return max(out_min, min(out_max, scaled)) + else: + return max(out_max, min(out_min, scaled)) @typing.overload -def flip (value: float, low: float = 0.0, high: float = 1.0) -> float: ... +def flip(value: float, low: float = 0.0, high: float = 1.0) -> float: ... @typing.overload -def flip (value: typing.List[float], low: float = 0.0, high: float = 1.0) -> typing.List[float]: ... +def flip( + value: typing.List[float], low: float = 0.0, high: float = 1.0 +) -> typing.List[float]: ... -def flip ( - value: typing.Union[float, typing.List[float]], - low: float = 0.0, - high: float = 1.0, +def flip( + value: typing.Union[float, typing.List[float]], + low: float = 0.0, + high: float = 1.0, ) -> typing.Union[float, typing.List[float]]: + """Reflect a value within a range — its complement about the mid-point. - """Reflect a value within a range — its complement about the mid-point. + Returns ``low + high - value``, mirroring ``value`` to the opposite side of the + range ``[low, high]``. With the default ``[0, 1]`` this is ``1 - value`` — the + density/probability complement, and a logical NOT for a 0/1 list. Being + range-aware it also flips other scales: ``flip(100, 0, 127)`` is ``27``, + mirroring a velocity within the MIDI range. - Returns ``low + high - value``, mirroring ``value`` to the opposite side of the - range ``[low, high]``. With the default ``[0, 1]`` this is ``1 - value`` — the - density/probability complement, and a logical NOT for a 0/1 list. Being - range-aware it also flips other scales: ``flip(100, 0, 127)`` is ``27``, - mirroring a velocity within the MIDI range. + ``flip`` is its own inverse and assumes ``value`` lies within ``[low, high]``; + it does **not** clamp (compose with :func:`clamp` if the input may stray). - ``flip`` is its own inverse and assumes ``value`` lies within ``[low, high]``; - it does **not** clamp (compose with :func:`clamp` if the input may stray). + ``value`` may be a single float or a list; the return matches that shape. + ``low`` and ``high`` are single scalars applied to every element. An empty + list yields an empty list. - ``value`` may be a single float or a list; the return matches that shape. - ``low`` and ``high`` are single scalars applied to every element. An empty - list yields an empty list. + Parameters: + value: A number to reflect, or a list of them. + low: The lower bound of the range (default ``0.0``). + high: The upper bound of the range (default ``1.0``). - Parameters: - value: A number to reflect, or a list of them. - low: The lower bound of the range (default ``0.0``). - high: The upper bound of the range (default ``1.0``). + Returns: + A float when ``value`` is a float, otherwise a list of floats. - Returns: - A float when ``value`` is a float, otherwise a list of floats. + Example: + ```python + # Turn a kick density into its "everywhere the kick isn't" field. + gaps = subsequence.sequence_utils.flip(kick_density) - Example: - ```python - # Turn a kick density into its "everywhere the kick isn't" field. - gaps = subsequence.sequence_utils.flip(kick_density) + # Mirror a velocity within the MIDI range. + soft = subsequence.sequence_utils.flip(100, 0, 127) + ``` + """ - # Mirror a velocity within the MIDI range. - soft = subsequence.sequence_utils.flip(100, 0, 127) - ``` - """ + if isinstance(value, list): + return [low + high - v for v in value] - if isinstance(value, list): - return [low + high - v for v in value] - - return low + high - value + return low + high - value @typing.overload -def clamp (value: float, low: float = 0.0, high: float = 1.0) -> float: ... +def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float: ... @typing.overload -def clamp (value: typing.List[float], low: float = 0.0, high: float = 1.0) -> typing.List[float]: ... +def clamp( + value: typing.List[float], low: float = 0.0, high: float = 1.0 +) -> typing.List[float]: ... -def clamp ( - value: typing.Union[float, typing.List[float]], - low: float = 0.0, - high: float = 1.0, +def clamp( + value: typing.Union[float, typing.List[float]], + low: float = 0.0, + high: float = 1.0, ) -> typing.Union[float, typing.List[float]]: + """Bound a value (or list) to the range ``[low, high]``. + + Returns ``max(low, min(high, value))`` element-wise. This is the plain, + list-aware clamp — distinct from :func:`scale_clamp`, which *rescales* from one + range to another before clamping. Defaults to ``[0, 1]``, the usual + density range; pass ``low``/``high`` for any other scale. Assumes + ``low <= high``. + + ``value`` may be a single float or a list; the return matches that shape. + ``low`` and ``high`` are single scalars. An empty list yields an empty list. - """Bound a value (or list) to the range ``[low, high]``. - - Returns ``max(low, min(high, value))`` element-wise. This is the plain, - list-aware clamp — distinct from :func:`scale_clamp`, which *rescales* from one - range to another before clamping. Defaults to ``[0, 1]``, the usual - density range; pass ``low``/``high`` for any other scale. Assumes - ``low <= high``. - - ``value`` may be a single float or a list; the return matches that shape. - ``low`` and ``high`` are single scalars. An empty list yields an empty list. - - Parameters: - value: A number to bound, or a list of them. - low: The lower bound (default ``0.0``). - high: The upper bound (default ``1.0``). - - Returns: - A float when ``value`` is a float, otherwise a list of floats. - - Example: - ```python - # Keep a hand-scaled density field inside [0, 1] after arithmetic. - safe = subsequence.sequence_utils.clamp([d * 1.4 for d in kick_density]) - - # Bound a computed velocity to the MIDI range. - vel = subsequence.sequence_utils.clamp(raw_velocity, 0, 127) - ``` - """ - - if isinstance(value, list): - return [max(low, min(high, v)) for v in value] - - return max(low, min(high, value)) + Parameters: + value: A number to bound, or a list of them. + low: The lower bound (default ``0.0``). + high: The upper bound (default ``1.0``). + Returns: + A float when ``value`` is a float, otherwise a list of floats. -def threshold (sequence: typing.List[float], cutoff: float = 0.5) -> typing.List[int]: + Example: + ```python + # Keep a hand-scaled density field inside [0, 1] after arithmetic. + safe = subsequence.sequence_utils.clamp([d * 1.4 for d in kick_density]) - """Gate a per-step field into a deterministic 0/1 sequence. + # Bound a computed velocity to the MIDI range. + vel = subsequence.sequence_utils.clamp(raw_velocity, 0, 127) + ``` + """ - Returns ``1`` where ``sequence[i] > cutoff`` (strict) and ``0`` otherwise — the - deterministic counterpart to :func:`probability_gate`'s random roll, matching - the idiom ``cutoff < x``. Pair with :func:`sequence_to_indices` to turn the - result into the firing step indices. - - This is a sequence operation: it takes a list and returns a list of ints. An - empty sequence yields an empty list. - - Parameters: - sequence: A per-step field (e.g. a density profile in ``[0, 1]``). - cutoff: The exclusive threshold; steps strictly above it fire (default - ``0.5``). - - Returns: - A list of ``0`` / ``1`` ints the length of ``sequence``. - - Example: - ```python - # Place closed-hat hits wherever the density clears the half-way line. - gate = subsequence.sequence_utils.threshold(hh_closed_density, 0.5) - steps = subsequence.sequence_utils.sequence_to_indices(gate) - ``` - """ - - return [1 if value > cutoff else 0 for value in sequence] - - -def perlin_1d (x: float, seed: int = 0) -> float: - - """Generate smooth 1D noise at position *x*. - - Returns a value in [0.0, 1.0] that varies smoothly as *x* changes. - Same *x* and *seed* always produce the same output. Use to drive - density, velocity, or probability parameters that should wander - organically over time — the "parameter wandering within boundaries" - quality of generative electronic music systems. - - Parameters: - x: Position along the noise field. Use ``bar * scale`` where - ``scale`` controls the rate of change (smaller = slower). - 0.05–0.1 is good for bar-level wandering. - seed: Seed for the hash function. Different seeds produce - different but equally smooth noise fields. - - Example: - ```python - # Smooth density that wanders over bars - density = subsequence.sequence_utils.perlin_1d(p.cycle * 0.08, seed=42) - p.bresenham("snare_1", pulses=max(1, round(density * 6)), - velocity=35, no_overlap=True) - ``` - """ - - x0 = math.floor(x) - x1 = x0 + 1 - t = x - x0 - - def _grad (pos: int) -> float: - # Hash function using Linear Congruential Generator (LCG) constants. - # The "magic numbers" (e.g. 1103515245) distribute bits evenly and are - # drawn from standard C library rand() implementations to ensure high - # quality pseudo-randomness quickly. - h = ((pos * 1103515245 + seed * 374761393 + 12345) & 0x7FFFFFFF) - return (h / 0x3FFFFFFF) - 1.0 - - fade = subsequence.easing.s_curve(t) - - d0 = _grad(x0) * t - d1 = _grad(x1) * (t - 1.0) - - value = d0 + fade * (d1 - d0) - - # Shift and clamp into [0, 1]. In practice the output occupies only the - # middle of that range (roughly [0.22, 0.77]) and rarely nears the extremes, - # so rescale with map_value / scale_clamp if you need the full 0–1 span. - return max(0.0, min(1.0, value + 0.5)) - - -def perlin_2d (x: float, y: float, seed: int = 0) -> float: - - """Generate smooth 2D noise at position *(x, y)*. - - Returns a value in [0.0, 1.0] that varies smoothly as *x* and *y* change. - Same coordinates and *seed* always produce the same output. Use to drive - correlated parameters that should weave around each other organically over time, - or for spatialized parameter wandering. - - Parameters: - x: Position along the X axis of the noise field. - y: Position along the Y axis of the noise field. - seed: Seed for the hash function. Different seeds produce - different but equally smooth noise fields. - - Example: - ```python - # Two parameters wandering smoothly but with related movement. - # By locking X to time and slightly separating Y, the two values - # will move in a correlated, organic dance over the bars. - density = subsequence.sequence_utils.perlin_2d(p.cycle * 0.08, 0.0, seed=42) - velocity = subsequence.sequence_utils.perlin_2d(p.cycle * 0.08, 0.5, seed=42) - ``` - """ - - x0 = math.floor(x) - y0 = math.floor(y) - x1 = x0 + 1 - y1 = y0 + 1 - - tx = x - x0 - ty = y - y0 - - def _grad (pos_x: int, pos_y: int) -> float: - # Note: The math here (smootherstep fade and hash function) is deliberately - # duplicated from perlin_1d rather than extracted into helper functions. - # This avoids Python function call overhead, maximizing execution speed for - # dense sequences. See perlin_1d for details on the LCG hash constants. - h = ((pos_x * 1103515245 + pos_y * 741103597 + seed * 374761393 + 12345) & 0x7FFFFFFF) - # 4 diagonal gradients - h4 = h & 3 - dx = x - pos_x - dy = y - pos_y - if h4 == 0: return dx + dy - if h4 == 1: return -dx + dy - if h4 == 2: return dx - dy - return -dx - dy - - fadex = subsequence.easing.s_curve(tx) - fadey = subsequence.easing.s_curve(ty) - - d00 = _grad(x0, y0) - d10 = _grad(x1, y0) - d01 = _grad(x0, y1) - d11 = _grad(x1, y1) - - # Interpolate along x - ix0 = d00 + fadex * (d10 - d00) - ix1 = d01 + fadex * (d11 - d01) - - # Interpolate along y - value = ix0 + fadey * (ix1 - ix0) - - # Shift and clamp into [0, 1]. In practice the output occupies only the - # middle of that range (roughly [0.23, 0.82]); rescale with map_value / - # scale_clamp if you need the full 0–1 span. - return max(0.0, min(1.0, (value + 1.0) / 2.0)) - - -def perlin_1d_sequence (start: float, spacing: float, count: int, seed: int = 0) -> typing.List[float]: - - """Generate a sequence of smooth 1D noise values. - - Equivalent to calling :func:`perlin_1d` *count* times at evenly-spaced - positions, but expressed as a single call. Every value is in [0.0, 1.0]. - - Parameters: - start: Position of the first sample in the noise field. - Typically ``p.bar * p.grid * scale`` to anchor the sequence - to an absolute position in the piece. - spacing: Distance between consecutive samples. Matches the - ``scale`` factor used in single calls — e.g. ``0.1`` gives - the same per-sample change as ``perlin_1d(i * 0.1, seed)``. - count: Number of values to return. - seed: Noise field seed. Same seed as a matching :func:`perlin_1d` - call produces identical values at the same positions. - - Example: - ```python - # 16 smoothly-varying velocities for hi-hat ghost notes - noise = subsequence.sequence_utils.perlin_1d_sequence( - start = p.bar * p.grid * 0.1, - spacing = 0.1, - count = p.grid, - seed = 10 - ) - hat_velocities = [ - int(subsequence.easing.map_value(n, out_min=50, out_max=75, shape="ease_in")) - for n in noise - ] - ``` - """ - - return [perlin_1d(start + i * spacing, seed) for i in range(count)] - - -def perlin_2d_grid ( - x_start: float, - y_start: float, - x_step: float, - y_step: float, - x_count: int, - y_count: int, - seed: int = 0 + if isinstance(value, list): + return [max(low, min(high, v)) for v in value] + + return max(low, min(high, value)) + + +def threshold(sequence: typing.List[float], cutoff: float = 0.5) -> typing.List[int]: + """Gate a per-step field into a deterministic 0/1 sequence. + + Returns ``1`` where ``sequence[i] > cutoff`` (strict) and ``0`` otherwise — the + deterministic counterpart to :func:`probability_gate`'s random roll, matching + the idiom ``cutoff < x``. Pair with :func:`sequence_to_indices` to turn the + result into the firing step indices. + + This is a sequence operation: it takes a list and returns a list of ints. An + empty sequence yields an empty list. + + Parameters: + sequence: A per-step field (e.g. a density profile in ``[0, 1]``). + cutoff: The exclusive threshold; steps strictly above it fire (default + ``0.5``). + + Returns: + A list of ``0`` / ``1`` ints the length of ``sequence``. + + Example: + ```python + # Place closed-hat hits wherever the density clears the half-way line. + gate = subsequence.sequence_utils.threshold(hh_closed_density, 0.5) + steps = subsequence.sequence_utils.sequence_to_indices(gate) + ``` + """ + + return [1 if value > cutoff else 0 for value in sequence] + + +def perlin_1d(x: float, seed: int = 0) -> float: + """Generate smooth 1D noise at position *x*. + + Returns a value in [0.0, 1.0] that varies smoothly as *x* changes. + Same *x* and *seed* always produce the same output. Use to drive + density, velocity, or probability parameters that should wander + organically over time — the "parameter wandering within boundaries" + quality of generative electronic music systems. + + Parameters: + x: Position along the noise field. Use ``bar * scale`` where + ``scale`` controls the rate of change (smaller = slower). + 0.05–0.1 is good for bar-level wandering. + seed: Seed for the hash function. Different seeds produce + different but equally smooth noise fields. + + Example: + ```python + # Smooth density that wanders over bars + density = subsequence.sequence_utils.perlin_1d(p.cycle * 0.08, seed=42) + p.bresenham("snare_1", pulses=max(1, round(density * 6)), + velocity=35, no_overlap=True) + ``` + """ + + x0 = math.floor(x) + x1 = x0 + 1 + t = x - x0 + + def _grad(pos: int) -> float: + # Hash function using Linear Congruential Generator (LCG) constants. + # The "magic numbers" (e.g. 1103515245) distribute bits evenly and are + # drawn from standard C library rand() implementations to ensure high + # quality pseudo-randomness quickly. + h = (pos * 1103515245 + seed * 374761393 + 12345) & 0x7FFFFFFF + return (h / 0x3FFFFFFF) - 1.0 + + fade = subsequence.easing.s_curve(t) + + d0 = _grad(x0) * t + d1 = _grad(x1) * (t - 1.0) + + value = d0 + fade * (d1 - d0) + + # Shift and clamp into [0, 1]. In practice the output occupies only the + # middle of that range (roughly [0.22, 0.77]) and rarely nears the extremes, + # so rescale with map_value / scale_clamp if you need the full 0–1 span. + return max(0.0, min(1.0, value + 0.5)) + + +def perlin_2d(x: float, y: float, seed: int = 0) -> float: + """Generate smooth 2D noise at position *(x, y)*. + + Returns a value in [0.0, 1.0] that varies smoothly as *x* and *y* change. + Same coordinates and *seed* always produce the same output. Use to drive + correlated parameters that should weave around each other organically over time, + or for spatialized parameter wandering. + + Parameters: + x: Position along the X axis of the noise field. + y: Position along the Y axis of the noise field. + seed: Seed for the hash function. Different seeds produce + different but equally smooth noise fields. + + Example: + ```python + # Two parameters wandering smoothly but with related movement. + # By locking X to time and slightly separating Y, the two values + # will move in a correlated, organic dance over the bars. + density = subsequence.sequence_utils.perlin_2d(p.cycle * 0.08, 0.0, seed=42) + velocity = subsequence.sequence_utils.perlin_2d(p.cycle * 0.08, 0.5, seed=42) + ``` + """ + + x0 = math.floor(x) + y0 = math.floor(y) + x1 = x0 + 1 + y1 = y0 + 1 + + tx = x - x0 + ty = y - y0 + + def _grad(pos_x: int, pos_y: int) -> float: + # Note: The math here (smootherstep fade and hash function) is deliberately + # duplicated from perlin_1d rather than extracted into helper functions. + # This avoids Python function call overhead, maximizing execution speed for + # dense sequences. See perlin_1d for details on the LCG hash constants. + h = ( + pos_x * 1103515245 + pos_y * 741103597 + seed * 374761393 + 12345 + ) & 0x7FFFFFFF + # 4 diagonal gradients + h4 = h & 3 + dx = x - pos_x + dy = y - pos_y + if h4 == 0: + return dx + dy + if h4 == 1: + return -dx + dy + if h4 == 2: + return dx - dy + return -dx - dy + + fadex = subsequence.easing.s_curve(tx) + fadey = subsequence.easing.s_curve(ty) + + d00 = _grad(x0, y0) + d10 = _grad(x1, y0) + d01 = _grad(x0, y1) + d11 = _grad(x1, y1) + + # Interpolate along x + ix0 = d00 + fadex * (d10 - d00) + ix1 = d01 + fadex * (d11 - d01) + + # Interpolate along y + value = ix0 + fadey * (ix1 - ix0) + + # Shift and clamp into [0, 1]. In practice the output occupies only the + # middle of that range (roughly [0.23, 0.82]); rescale with map_value / + # scale_clamp if you need the full 0–1 span. + return max(0.0, min(1.0, (value + 1.0) / 2.0)) + + +def perlin_1d_sequence( + start: float, spacing: float, count: int, seed: int = 0 +) -> typing.List[float]: + """Generate a sequence of smooth 1D noise values. + + Equivalent to calling :func:`perlin_1d` *count* times at evenly-spaced + positions, but expressed as a single call. Every value is in [0.0, 1.0]. + + Parameters: + start: Position of the first sample in the noise field. + Typically ``p.bar * p.grid * scale`` to anchor the sequence + to an absolute position in the piece. + spacing: Distance between consecutive samples. Matches the + ``scale`` factor used in single calls — e.g. ``0.1`` gives + the same per-sample change as ``perlin_1d(i * 0.1, seed)``. + count: Number of values to return. + seed: Noise field seed. Same seed as a matching :func:`perlin_1d` + call produces identical values at the same positions. + + Example: + ```python + # 16 smoothly-varying velocities for hi-hat ghost notes + noise = subsequence.sequence_utils.perlin_1d_sequence( + start = p.bar * p.grid * 0.1, + spacing = 0.1, + count = p.grid, + seed = 10 + ) + hat_velocities = [ + int(subsequence.easing.map_value(n, out_min=50, out_max=75, shape="ease_in")) + for n in noise + ] + ``` + """ + + return [perlin_1d(start + i * spacing, seed) for i in range(count)] + + +def perlin_2d_grid( + x_start: float, + y_start: float, + x_step: float, + y_step: float, + x_count: int, + y_count: int, + seed: int = 0, ) -> typing.List[typing.List[float]]: - - """Generate a 2D grid of smooth noise values. - - Returns a list of ``y_count`` rows, each containing ``x_count`` values - in [0.0, 1.0]. Access as ``grid[row][col]``. Equivalent to calling - :func:`perlin_2d` for every *(x, y)* position in the grid. - - Parameters: - x_start: Starting X position. - y_start: Starting Y position. - x_step: Spacing between columns. - y_step: Spacing between rows. - x_count: Number of columns (samples along X). - y_count: Number of rows (samples along Y). - seed: Noise field seed. - - Example: - ```python - # 4x4 noise grid — rows are bars, columns are steps - grid = subsequence.sequence_utils.perlin_2d_grid( - x_start = p.bar * 0.1, - y_start = 0.0, - x_step = 0.1, - y_step = 0.25, - x_count = 4, - y_count = 4, - seed = 5, - ) - # e.g. drive density of four voices independently - for row, voice in enumerate(["kick", "snare", "hi_hat_closed", "clap"]): - density = sum(grid[row]) / len(grid[row]) - p.ghost_fill(voice, density=density, velocity=(20, 50)) - ``` - """ - - return [ - [perlin_2d(x_start + xi * x_step, y_start + yi * y_step, seed) for xi in range(x_count)] - for yi in range(y_count) - ] - - - -def logistic_map (r: float, steps: int, x0: float = 0.5) -> typing.List[float]: - - """Generate a deterministic chaos sequence using the logistic map. - - A single parameter ``r`` controls behaviour from stability to chaos: - ``r < 3.0`` converges to a fixed point; ``r`` 3.0–3.57 produces - periodic oscillations (period-2, -4, -8…); ``r > 3.57`` enters chaos. - At ``r ≈ 3.83`` a stable period-3 window briefly returns. - - Complements :func:`perlin_1d` — use Perlin for smooth organic - wandering and logistic_map when you need controllable order-to-chaos - behaviour. Feeding logistic_map values into ``hit_steps`` probability - or ghost note velocity gives ghost notes that are "the same but never - exactly the same." - - Parameters: - r: Growth rate, typically 0.0–4.0. Values outside [0, 4] will - cause ``x`` to diverge; clamp externally if needed. - steps: Number of values to generate. - x0: Seed value in the open interval (0, 1). Default 0.5. - - Example: - ```python - # Ghost snare density that hovers at the edge of chaos - chaos = subsequence.sequence_utils.logistic_map(r=3.7, steps=16) - for i, v in enumerate(chaos): - if v > 0.5: - p.hit_steps("snare_2", [i], velocity=round(30 + 50 * v), no_overlap=True) - ``` - """ - - if steps <= 0: - return [] - - x = x0 - result: typing.List[float] = [] - - for _ in range(steps): - x = r * x * (1.0 - x) - result.append(x) - - return result - - -def pink_noise (steps: int, sources: int = 16, seed: int = 0) -> typing.List[float]: - - """Generate a 1/f (pink) noise sequence using the Voss-McCartney algorithm. - - Pink noise has equal energy per octave — it contains both slow drift - and fast jitter in a single signal, matching how musical parameters - naturally vary. Voss and Clarke (1978) showed that pitch and loudness - fluctuations in real music follow 1/f statistics. - - Sits between :func:`perlin_1d` (smooth, predictable) and - :func:`logistic_map` (chaos, controllable order-to-randomness): use - pink noise when you want statistically "natural" variation without - tuning octave weights manually. - - Parameters: - steps: Number of output samples. - sources: Number of independent random sources. More sources extend - the low-frequency range. Default 16 is a good general value. - seed: RNG seed. Same seed always produces the same sequence. - - Returns: - List of floats in [0.0, 1.0]. - - Example: - ```python - # Humanise hi-hat velocity with pink noise - noise = subsequence.sequence_utils.pink_noise(steps=p.grid, seed=p.bar) - for i, level in enumerate(noise): - if level > 0.3: - p.hit_steps("hi_hat_closed", [i], velocity=round(40 + 50 * level), no_overlap=True) - ``` - """ - - if steps <= 0: - return [] - - if sources <= 0: - raise ValueError(f"pink_noise() needs at least one random source to sum — got sources={sources}") - - rng = random.Random(seed) - - source_values = [rng.random() for _ in range(sources)] - total = sum(source_values) - - result: typing.List[float] = [] - - for i in range(steps): - # Count trailing zeros of i+1 to select which source to update. - # This distributes updates so lower-indexed sources change less - # frequently, creating the 1/f spectral slope. - counter = i + 1 - trailing = 0 - while counter & 1 == 0 and trailing < sources - 1: - trailing += 1 - counter >>= 1 - - old_val = source_values[trailing] - new_val = rng.random() - source_values[trailing] = new_val - total = total - old_val + new_val - - result.append(total / sources) - - # Normalise to [0.0, 1.0]. - lo = min(result) - hi = max(result) - if hi > lo: - result = [(v - lo) / (hi - lo) for v in result] - - return result - - -def lsystem_expand ( - axiom: str, - rules: typing.Dict[str, typing.Union[str, typing.List[typing.Tuple[str, float]]]], - generations: int, - rng: typing.Optional[random.Random] = None, + """Generate a 2D grid of smooth noise values. + + Returns a list of ``y_count`` rows, each containing ``x_count`` values + in [0.0, 1.0]. Access as ``grid[row][col]``. Equivalent to calling + :func:`perlin_2d` for every *(x, y)* position in the grid. + + Parameters: + x_start: Starting X position. + y_start: Starting Y position. + x_step: Spacing between columns. + y_step: Spacing between rows. + x_count: Number of columns (samples along X). + y_count: Number of rows (samples along Y). + seed: Noise field seed. + + Example: + ```python + # 4x4 noise grid — rows are bars, columns are steps + grid = subsequence.sequence_utils.perlin_2d_grid( + x_start = p.bar * 0.1, + y_start = 0.0, + x_step = 0.1, + y_step = 0.25, + x_count = 4, + y_count = 4, + seed = 5, + ) + # e.g. drive density of four voices independently + for row, voice in enumerate(["kick", "snare", "hi_hat_closed", "clap"]): + density = sum(grid[row]) / len(grid[row]) + p.ghost_fill(voice, density=density, velocity=(20, 50)) + ``` + """ + + return [ + [ + perlin_2d(x_start + xi * x_step, y_start + yi * y_step, seed) + for xi in range(x_count) + ] + for yi in range(y_count) + ] + + +def logistic_map(r: float, steps: int, x0: float = 0.5) -> typing.List[float]: + """Generate a deterministic chaos sequence using the logistic map. + + A single parameter ``r`` controls behaviour from stability to chaos: + ``r < 3.0`` converges to a fixed point; ``r`` 3.0–3.57 produces + periodic oscillations (period-2, -4, -8…); ``r > 3.57`` enters chaos. + At ``r ≈ 3.83`` a stable period-3 window briefly returns. + + Complements :func:`perlin_1d` — use Perlin for smooth organic + wandering and logistic_map when you need controllable order-to-chaos + behaviour. Feeding logistic_map values into ``hit_steps`` probability + or ghost note velocity gives ghost notes that are "the same but never + exactly the same." + + Parameters: + r: Growth rate, typically 0.0–4.0. Values outside [0, 4] will + cause ``x`` to diverge; clamp externally if needed. + steps: Number of values to generate. + x0: Seed value in the open interval (0, 1). Default 0.5. + + Example: + ```python + # Ghost snare density that hovers at the edge of chaos + chaos = subsequence.sequence_utils.logistic_map(r=3.7, steps=16) + for i, v in enumerate(chaos): + if v > 0.5: + p.hit_steps("snare_2", [i], velocity=round(30 + 50 * v), no_overlap=True) + ``` + """ + + if steps <= 0: + return [] + + x = x0 + result: typing.List[float] = [] + + for _ in range(steps): + x = r * x * (1.0 - x) + result.append(x) + + return result + + +def pink_noise(steps: int, sources: int = 16, seed: int = 0) -> typing.List[float]: + """Generate a 1/f (pink) noise sequence using the Voss-McCartney algorithm. + + Pink noise has equal energy per octave — it contains both slow drift + and fast jitter in a single signal, matching how musical parameters + naturally vary. Voss and Clarke (1978) showed that pitch and loudness + fluctuations in real music follow 1/f statistics. + + Sits between :func:`perlin_1d` (smooth, predictable) and + :func:`logistic_map` (chaos, controllable order-to-randomness): use + pink noise when you want statistically "natural" variation without + tuning octave weights manually. + + Parameters: + steps: Number of output samples. + sources: Number of independent random sources. More sources extend + the low-frequency range. Default 16 is a good general value. + seed: RNG seed. Same seed always produces the same sequence. + + Returns: + List of floats in [0.0, 1.0]. + + Example: + ```python + # Humanise hi-hat velocity with pink noise + noise = subsequence.sequence_utils.pink_noise(steps=p.grid, seed=p.bar) + for i, level in enumerate(noise): + if level > 0.3: + p.hit_steps("hi_hat_closed", [i], velocity=round(40 + 50 * level), no_overlap=True) + ``` + """ + + if steps <= 0: + return [] + + if sources <= 0: + raise ValueError( + f"pink_noise() needs at least one random source to sum — got sources={sources}" + ) + + rng = random.Random(seed) + + source_values = [rng.random() for _ in range(sources)] + total = sum(source_values) + + result: typing.List[float] = [] + + for i in range(steps): + # Count trailing zeros of i+1 to select which source to update. + # This distributes updates so lower-indexed sources change less + # frequently, creating the 1/f spectral slope. + counter = i + 1 + trailing = 0 + while counter & 1 == 0 and trailing < sources - 1: + trailing += 1 + counter >>= 1 + + old_val = source_values[trailing] + new_val = rng.random() + source_values[trailing] = new_val + total = total - old_val + new_val + + result.append(total / sources) + + # Normalise to [0.0, 1.0]. + lo = min(result) + hi = max(result) + if hi > lo: + result = [(v - lo) / (hi - lo) for v in result] + + return result + + +def lsystem_expand( + axiom: str, + rules: typing.Dict[str, typing.Union[str, typing.List[typing.Tuple[str, float]]]], + generations: int, + rng: typing.Optional[random.Random] = None, ) -> str: + """Expand an L-system string by applying production rules. + + An L-system rewrites every symbol in the current string simultaneously, + each generation replacing symbols according to ``rules``. After enough + generations the string exhibits self-similarity: its large-scale structure + mirrors its small-scale structure — the same property found in natural + music, where motifs recur at phrase, section, and movement level. + + Symbols not present in ``rules`` pass through unchanged (identity rule). + Symbols are single characters; each character in the string is one symbol. - """Expand an L-system string by applying production rules. - - An L-system rewrites every symbol in the current string simultaneously, - each generation replacing symbols according to ``rules``. After enough - generations the string exhibits self-similarity: its large-scale structure - mirrors its small-scale structure — the same property found in natural - music, where motifs recur at phrase, section, and movement level. - - Symbols not present in ``rules`` pass through unchanged (identity rule). - Symbols are single characters; each character in the string is one symbol. - - Rules may be deterministic (a single replacement string) or stochastic - (a list of ``(replacement, weight)`` pairs). Stochastic rules require - ``rng`` to be provided. - - .. note:: - String length can grow exponentially. A doubling rule applied for - 30 generations produces ~1 billion characters. Keep ``generations`` - to 3–8 for practical use. + Rules may be deterministic (a single replacement string) or stochastic + (a list of ``(replacement, weight)`` pairs). Stochastic rules require + ``rng`` to be provided. - Parameters: - axiom: Initial string (e.g. ``"A"``). - rules: Production rules. Deterministic: ``{"A": "AB", "B": "A"}``. - Stochastic: ``{"A": [("AB", 3), ("BA", 1)]}`` — weights are - relative and do not need to sum to 1. - generations: Number of rewriting iterations. - rng: Random number generator. Required when any rule is stochastic; - ignored for fully deterministic rule sets. + .. note:: + String length can grow exponentially. A doubling rule applied for + 30 generations produces ~1 billion characters. Keep ``generations`` + to 3–8 for practical use. - Returns: - Expanded string after ``generations`` iterations. + Parameters: + axiom: Initial string (e.g. ``"A"``). + rules: Production rules. Deterministic: ``{"A": "AB", "B": "A"}``. + Stochastic: ``{"A": [("AB", 3), ("BA", 1)]}`` — weights are + relative and do not need to sum to 1. + generations: Number of rewriting iterations. + rng: Random number generator. Required when any rule is stochastic; + ignored for fully deterministic rule sets. - Raises: - ValueError: If stochastic rules are present but ``rng`` is ``None``. + Returns: + Expanded string after ``generations`` iterations. - Example: - ```python - # Fibonacci rhythm — hits distributed at golden-ratio spacing - expanded = subsequence.sequence_utils.lsystem_expand( - axiom="A", rules={"A": "AB", "B": "A"}, generations=6 - ) - # expanded is "ABAABABAABAABABAABABA" (length 21, the gen-6 Fibonacci word) + Raises: + ValueError: If stochastic rules are present but ``rng`` is ``None``. - # Stochastic — different output each bar - expanded = subsequence.sequence_utils.lsystem_expand( - axiom="A", - rules={"A": [("AB", 3), ("BA", 1)]}, - generations=4, - rng=rng, - ) - ``` - """ + Example: + ```python + # Fibonacci rhythm — hits distributed at golden-ratio spacing + expanded = subsequence.sequence_utils.lsystem_expand( + axiom="A", rules={"A": "AB", "B": "A"}, generations=6 + ) + # expanded is "ABAABABAABAABABAABABA" (length 21, the gen-6 Fibonacci word) - # Validate: stochastic rules need an rng. - for production in rules.values(): - if isinstance(production, list): - if rng is None: - raise ValueError( - "lsystem_expand: rng is required when rules contain stochastic productions" - ) - break + # Stochastic — different output each bar + expanded = subsequence.sequence_utils.lsystem_expand( + axiom="A", + rules={"A": [("AB", 3), ("BA", 1)]}, + generations=4, + rng=rng, + ) + ``` + """ - current = axiom + # Validate: stochastic rules need an rng. + for production in rules.values(): + if isinstance(production, list): + if rng is None: + raise ValueError( + "lsystem_expand: rng is required when rules contain stochastic productions" + ) + break - for _ in range(generations): - parts: typing.List[str] = [] + current = axiom - for symbol in current: - if symbol not in rules: - parts.append(symbol) - continue + for _ in range(generations): + parts: typing.List[str] = [] - production = rules[symbol] + for symbol in current: + if symbol not in rules: + parts.append(symbol) + continue - if isinstance(production, str): - parts.append(production) - else: - # Stochastic: pick one replacement weighted by the float weights. - chosen = weighted_choice(production, rng) # type: ignore[arg-type] - parts.append(chosen) + production = rules[symbol] - current = "".join(parts) + if isinstance(production, str): + parts.append(production) + else: + # Stochastic: pick one replacement weighted by the float weights. + chosen = weighted_choice(production, rng) # type: ignore[arg-type] + parts.append(chosen) - return current + current = "".join(parts) + return current -_ca_1d_cache: typing.Dict[typing.Tuple[int, int, int], typing.Tuple[int, typing.List[int]]] = {} +_ca_1d_cache: typing.Dict[ + typing.Tuple[int, int, int], typing.Tuple[int, typing.List[int]] +] = {} -def _ca_1d_initial_state (steps: int, seed: int) -> typing.List[int]: - """Build the generation-0 row for an elementary CA (``seed=1`` → centre cell).""" +def _ca_1d_initial_state(steps: int, seed: int) -> typing.List[int]: + """Build the generation-0 row for an elementary CA (``seed=1`` → centre cell).""" - state = [0] * steps + state = [0] * steps - if seed == 1: - state[steps // 2] = 1 - else: - for i in range(min(steps, seed.bit_length())): - if seed & (1 << i): - state[i] = 1 + if seed == 1: + state[steps // 2] = 1 + else: + for i in range(min(steps, seed.bit_length())): + if seed & (1 << i): + state[i] = 1 - return state + return state -def _ca_1d_step (state: typing.List[int], rule: int, steps: int) -> typing.List[int]: +def _ca_1d_step(state: typing.List[int], rule: int, steps: int) -> typing.List[int]: + """Advance an elementary-CA row by one generation (toroidal neighbourhood).""" - """Advance an elementary-CA row by one generation (toroidal neighbourhood).""" + new_state = [0] * steps - new_state = [0] * steps + for i in range(steps): + left = state[(i - 1) % steps] + center = state[i] + right = state[(i + 1) % steps] + neighborhood = (left << 2) | (center << 1) | right + new_state[i] = (rule >> neighborhood) & 1 - for i in range(steps): - left = state[(i - 1) % steps] - center = state[i] - right = state[(i + 1) % steps] - neighborhood = (left << 2) | (center << 1) | right - new_state[i] = (rule >> neighborhood) & 1 + return new_state - return new_state +def generate_cellular_automaton_1d( + steps: int, rule: int = 30, generation: int = 0, seed: int = 1 +) -> typing.List[int]: + """Generate a binary sequence using an elementary cellular automaton. -def generate_cellular_automaton_1d (steps: int, rule: int = 30, generation: int = 0, seed: int = 1) -> typing.List[int]: - - """Generate a binary sequence using an elementary cellular automaton. - - Evolves a 1D CA from an initial state for the specified number of - generations, returning the final state as a binary rhythm. Each - generation the pattern evolves — use ``p.cycle`` as the generation - to get a rhythm that changes every bar. - - Rule 30 produces "structured chaos" — patterns that look random but - have hidden self-similarity. Rule 90 produces fractal (Sierpiński - triangle) patterns. Rule 110 is Turing-complete. - - Parameters: - steps: Length of the output sequence. - rule: Wolfram rule number (0–255). Default 30. - generation: Number of generations to evolve from the initial state. - seed: Initial state as a bit field. Default 1 (single centre cell). - - Returns: - Binary list of length *steps* (0s and 1s). + Evolves a 1D CA from an initial state for the specified number of + generations, returning the final state as a binary rhythm. Each + generation the pattern evolves — use ``p.cycle`` as the generation + to get a rhythm that changes every bar. - Example: - ```python - seq = subsequence.sequence_utils.generate_cellular_automaton_1d( - 16, rule=30, generation=p.cycle - ) - indices = subsequence.sequence_utils.sequence_to_indices(seq) - p.hit_steps("snare_1", indices, velocity=35) - ``` - """ + Rule 30 produces "structured chaos" — patterns that look random but + have hidden self-similarity. Rule 90 produces fractal (Sierpiński + triangle) patterns. Rule 110 is Turing-complete. - if steps <= 0: - return [] + Parameters: + steps: Length of the output sequence. + rule: Wolfram rule number (0–255). Default 30. + generation: Number of generations to evolve from the initial state. + seed: Initial state as a bit field. Default 1 (single centre cell). - # Memoise the evolution: the common idiom drives `generation` from `p.cycle`, - # advancing one generation per bar, so without a cache each bar re-ran every - # prior generation from scratch (cost growing without bound over a long set). - # The cache holds the latest (generation, state) per (steps, rule, seed) and - # advances incrementally; a request for an earlier generation than cached - # recomputes from the initial state. Correct because the CA is Markovian — - # generation N+1 depends only on generation N. - cache_key = (steps, rule, seed) - cached = _ca_1d_cache.get(cache_key) + Returns: + Binary list of length *steps* (0s and 1s). - if cached is not None and cached[0] <= generation: - current_gen, state = cached[0], list(cached[1]) - else: - current_gen, state = 0, _ca_1d_initial_state(steps, seed) + Example: + ```python + seq = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=p.cycle + ) + indices = subsequence.sequence_utils.sequence_to_indices(seq) + p.hit_steps("snare_1", indices, velocity=35) + ``` + """ - for _ in range(current_gen, generation): - state = _ca_1d_step(state, rule, steps) + if steps <= 0: + return [] - _ca_1d_cache[cache_key] = (generation, list(state)) + # Memoise the evolution: the common idiom drives `generation` from `p.cycle`, + # advancing one generation per bar, so without a cache each bar re-ran every + # prior generation from scratch (cost growing without bound over a long set). + # The cache holds the latest (generation, state) per (steps, rule, seed) and + # advances incrementally; a request for an earlier generation than cached + # recomputes from the initial state. Correct because the CA is Markovian — + # generation N+1 depends only on generation N. + cache_key = (steps, rule, seed) + cached = _ca_1d_cache.get(cache_key) - return state + if cached is not None and cached[0] <= generation: + current_gen, state = cached[0], list(cached[1]) + else: + current_gen, state = 0, _ca_1d_initial_state(steps, seed) + for _ in range(current_gen, generation): + state = _ca_1d_step(state, rule, steps) -def _parse_life_rule (rule: str) -> typing.Tuple[typing.Set[int], typing.Set[int]]: + _ca_1d_cache[cache_key] = (generation, list(state)) - """Parse a Life-like rule string in Birth/Survival notation. + return state - Parameters: - rule: Rule string in the form ``"B/S"``, e.g. - ``"B3/S23"`` for Conway's Life or ``"B368/S245"`` for Morley. - Returns: - ``(birth_set, survival_set)`` — sets of neighbour counts that - trigger birth or survival respectively. +def _parse_life_rule(rule: str) -> typing.Tuple[typing.Set[int], typing.Set[int]]: + """Parse a Life-like rule string in Birth/Survival notation. - Raises: - ValueError: If the rule string is not valid Birth/Survival notation. - """ + Parameters: + rule: Rule string in the form ``"B/S"``, e.g. + ``"B3/S23"`` for Conway's Life or ``"B368/S245"`` for Morley. - rule = rule.strip().upper() - parts = rule.split("/") + Returns: + ``(birth_set, survival_set)`` — sets of neighbour counts that + trigger birth or survival respectively. - if len(parts) != 2: - raise ValueError(f"Invalid Life rule: {rule!r} — expected 'B.../S...' format") + Raises: + ValueError: If the rule string is not valid Birth/Survival notation. + """ - birth_part, survival_part = parts + rule = rule.strip().upper() + parts = rule.split("/") - if not birth_part.startswith("B") or not survival_part.startswith("S"): - raise ValueError(f"Invalid Life rule: {rule!r} — expected 'B.../S...' format") + if len(parts) != 2: + raise ValueError(f"Invalid Life rule: {rule!r} — expected 'B.../S...' format") - try: - birth_set: typing.Set[int] = {int(c) for c in birth_part[1:]} - survival_set: typing.Set[int] = {int(c) for c in survival_part[1:]} - except ValueError: - raise ValueError(f"Invalid Life rule: {rule!r} — neighbour counts must be digits 0–8") + birth_part, survival_part = parts - for n in birth_set | survival_set: - if n > 8: - raise ValueError(f"Invalid Life rule: {rule!r} — neighbour count {n} exceeds maximum of 8") + if not birth_part.startswith("B") or not survival_part.startswith("S"): + raise ValueError(f"Invalid Life rule: {rule!r} — expected 'B.../S...' format") - return birth_set, survival_set + try: + birth_set: typing.Set[int] = {int(c) for c in birth_part[1:]} + survival_set: typing.Set[int] = {int(c) for c in survival_part[1:]} + except ValueError: + raise ValueError( + f"Invalid Life rule: {rule!r} — neighbour counts must be digits 0–8" + ) + for n in birth_set | survival_set: + if n > 8: + raise ValueError( + f"Invalid Life rule: {rule!r} — neighbour count {n} exceeds maximum of 8" + ) -_ca_2d_cache: typing.Dict[typing.Tuple[int, int, str, int, float], typing.Tuple[int, typing.List[typing.List[int]]]] = {} + return birth_set, survival_set -def _ca_2d_initial_grid (rows: int, cols: int, seed: int, density: float) -> typing.List[typing.List[int]]: +_ca_2d_cache: typing.Dict[ + typing.Tuple[int, int, str, int, float], + typing.Tuple[int, typing.List[typing.List[int]]], +] = {} - """Build the generation-0 grid for an int-seeded 2D CA.""" - if seed == 1: - grid = [[0] * cols for _ in range(rows)] - grid[rows // 2][cols // 2] = 1 - return grid +def _ca_2d_initial_grid( + rows: int, cols: int, seed: int, density: float +) -> typing.List[typing.List[int]]: + """Build the generation-0 grid for an int-seeded 2D CA.""" - rng = random.Random(seed) - return [[1 if rng.random() < density else 0 for _ in range(cols)] for _ in range(rows)] + if seed == 1: + grid = [[0] * cols for _ in range(rows)] + grid[rows // 2][cols // 2] = 1 + return grid + rng = random.Random(seed) + return [ + [1 if rng.random() < density else 0 for _ in range(cols)] for _ in range(rows) + ] -def _ca_2d_step (grid: typing.List[typing.List[int]], rows: int, cols: int, birth_set: typing.Set[int], survival_set: typing.Set[int]) -> typing.List[typing.List[int]]: - """Advance a Life-like 2D grid by one generation (toroidal Moore neighbourhood).""" +def _ca_2d_step( + grid: typing.List[typing.List[int]], + rows: int, + cols: int, + birth_set: typing.Set[int], + survival_set: typing.Set[int], +) -> typing.List[typing.List[int]]: + """Advance a Life-like 2D grid by one generation (toroidal Moore neighbourhood).""" - new_grid = [[0] * cols for _ in range(rows)] + new_grid = [[0] * cols for _ in range(rows)] - for r in range(rows): - for c in range(cols): - neighbours = 0 + for r in range(rows): + for c in range(cols): + neighbours = 0 - for dr in (-1, 0, 1): - for dc in (-1, 0, 1): - if dr == 0 and dc == 0: - continue - neighbours += grid[(r + dr) % rows][(c + dc) % cols] + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + if dr == 0 and dc == 0: + continue + neighbours += grid[(r + dr) % rows][(c + dc) % cols] - alive = grid[r][c] + alive = grid[r][c] - if alive: - new_grid[r][c] = 1 if neighbours in survival_set else 0 - else: - new_grid[r][c] = 1 if neighbours in birth_set else 0 + if alive: + new_grid[r][c] = 1 if neighbours in survival_set else 0 + else: + new_grid[r][c] = 1 if neighbours in birth_set else 0 - return new_grid + return new_grid -def generate_cellular_automaton_2d ( - rows: int, - cols: int, - rule: str = "B368/S245", - generation: int = 0, - seed: typing.Union[int, typing.List[typing.List[int]]] = 1, - density: float = 0.5, +def generate_cellular_automaton_2d( + rows: int, + cols: int, + rule: str = "B368/S245", + generation: int = 0, + seed: typing.Union[int, typing.List[typing.List[int]]] = 1, + density: float = 0.5, ) -> typing.List[typing.List[int]]: - - """Generate a 2D cellular automaton grid using Life-like rules. - - Evolves a 2D grid of cells from an initial state using Birth/Survival - notation rules. The resulting grid maps rows to pitches or instruments - and columns to time steps, producing polyphonic rhythmic patterns. - - The default rule B368/S245 (Morley/"Move") produces chaotic, active - patterns well-suited to generative music. B3/S23 is Conway's Life. - - Parameters: - rows: Number of rows (maps to pitches or instruments). - cols: Number of columns (maps to time steps / rhythm grid). - rule: Birth/Survival notation, e.g. ``"B3/S23"`` for Conway's Life, - ``"B368/S245"`` for Morley. - generation: Number of evolution steps to run from the initial seed. - seed: Initial grid state. ``1`` places a single live cell at the - centre. Any other ``int`` seeds a :class:`random.Random` and - fills cells with probability *density*. A - ``list[list[int]]`` provides an explicit starting grid (must be - rows × cols). - density: Fill probability when *seed* is a random int (0.0–1.0). - - Returns: - 2D grid as a list of lists (rows × cols), each cell 0 or 1. - - Example: - ```python - grid = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=16, rule="B3/S23", generation=p.cycle, seed=42 - ) - for row_idx, pitch in enumerate([60, 62, 64, 67]): - hits = [c for c, v in enumerate(grid[row_idx]) if v] - p.hit_steps(pitch, hits, velocity=80) - ``` - """ - - # A grid with no rows or no columns has no cells to evolve — return the - # degenerate empty grid, matching the 1D kernel's steps<=0 no-op. - if rows <= 0 or cols <= 0: - return [[] for _ in range(max(0, rows))] - - birth_set, survival_set = _parse_life_rule(rule) - - # Memoise int-seeded evolutions incrementally, like the 1D version, so a - # `generation`-per-bar idiom doesn't re-run every prior generation each call. - # A list seed (an explicit one-off starting grid) is not cached. - cache_key: typing.Optional[typing.Tuple[int, int, str, int, float]] = None - - if isinstance(seed, list): - current_gen = 0 - grid = [[int(bool(seed[r][c])) for c in range(cols)] for r in range(rows)] - else: - cache_key = (rows, cols, rule, seed, density) - cached = _ca_2d_cache.get(cache_key) - if cached is not None and cached[0] <= generation: - current_gen, grid = cached[0], [row[:] for row in cached[1]] - else: - current_gen, grid = 0, _ca_2d_initial_grid(rows, cols, seed, density) - - for _ in range(current_gen, generation): - grid = _ca_2d_step(grid, rows, cols, birth_set, survival_set) - - if cache_key is not None: - _ca_2d_cache[cache_key] = (generation, [row[:] for row in grid]) - - return grid - - -def thue_morse (n: int) -> typing.List[int]: - - """ - Generate the Thue-Morse sequence. - - The Thue-Morse sequence is an infinite aperiodic binary sequence defined - by ``t(i) = popcount(i) mod 2`` — the parity of the number of 1-bits in - ``i``. It is perfectly balanced (equal density of 0s and 1s over any - power-of-two window) and overlap-free: no subsequence occurs three times - consecutively. It is self-similar but never strictly periodic, making it - useful for rhythmic patterns that feel structured yet avoid monotony. - - Parameters: - n: Number of values to generate. - - Returns: - Binary list of length ``n`` (0s and 1s). - - Example: - ```python - # 16-step Thue-Morse rhythm — first 8 values: 0 1 1 0 1 0 0 1 - seq = subsequence.sequence_utils.thue_morse(16) - ``` - """ - - if n <= 0: - return [] - - return [bin(i).count("1") % 2 for i in range(n)] + """Generate a 2D cellular automaton grid using Life-like rules. + + Evolves a 2D grid of cells from an initial state using Birth/Survival + notation rules. The resulting grid maps rows to pitches or instruments + and columns to time steps, producing polyphonic rhythmic patterns. + + The default rule B368/S245 (Morley/"Move") produces chaotic, active + patterns well-suited to generative music. B3/S23 is Conway's Life. + + Parameters: + rows: Number of rows (maps to pitches or instruments). + cols: Number of columns (maps to time steps / rhythm grid). + rule: Birth/Survival notation, e.g. ``"B3/S23"`` for Conway's Life, + ``"B368/S245"`` for Morley. + generation: Number of evolution steps to run from the initial seed. + seed: Initial grid state. ``1`` places a single live cell at the + centre. Any other ``int`` seeds a :class:`random.Random` and + fills cells with probability *density*. A + ``list[list[int]]`` provides an explicit starting grid (must be + rows × cols). + density: Fill probability when *seed* is a random int (0.0–1.0). + + Returns: + 2D grid as a list of lists (rows × cols), each cell 0 or 1. + + Example: + ```python + grid = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=16, rule="B3/S23", generation=p.cycle, seed=42 + ) + for row_idx, pitch in enumerate([60, 62, 64, 67]): + hits = [c for c, v in enumerate(grid[row_idx]) if v] + p.hit_steps(pitch, hits, velocity=80) + ``` + """ + + # A grid with no rows or no columns has no cells to evolve — return the + # degenerate empty grid, matching the 1D kernel's steps<=0 no-op. + if rows <= 0 or cols <= 0: + return [[] for _ in range(max(0, rows))] + + birth_set, survival_set = _parse_life_rule(rule) + + # Memoise int-seeded evolutions incrementally, like the 1D version, so a + # `generation`-per-bar idiom doesn't re-run every prior generation each call. + # A list seed (an explicit one-off starting grid) is not cached. + cache_key: typing.Optional[typing.Tuple[int, int, str, int, float]] = None + + if isinstance(seed, list): + current_gen = 0 + grid = [[int(bool(seed[r][c])) for c in range(cols)] for r in range(rows)] + else: + cache_key = (rows, cols, rule, seed, density) + cached = _ca_2d_cache.get(cache_key) + if cached is not None and cached[0] <= generation: + current_gen, grid = cached[0], [row[:] for row in cached[1]] + else: + current_gen, grid = 0, _ca_2d_initial_grid(rows, cols, seed, density) + + for _ in range(current_gen, generation): + grid = _ca_2d_step(grid, rows, cols, birth_set, survival_set) + + if cache_key is not None: + _ca_2d_cache[cache_key] = (generation, [row[:] for row in grid]) + + return grid + + +def thue_morse(n: int) -> typing.List[int]: + """ + Generate the Thue-Morse sequence. + + The Thue-Morse sequence is an infinite aperiodic binary sequence defined + by ``t(i) = popcount(i) mod 2`` — the parity of the number of 1-bits in + ``i``. It is perfectly balanced (equal density of 0s and 1s over any + power-of-two window) and overlap-free: no subsequence occurs three times + consecutively. It is self-similar but never strictly periodic, making it + useful for rhythmic patterns that feel structured yet avoid monotony. + + Parameters: + n: Number of values to generate. + + Returns: + Binary list of length ``n`` (0s and 1s). + + Example: + ```python + # 16-step Thue-Morse rhythm — first 8 values: 0 1 1 0 1 0 0 1 + seq = subsequence.sequence_utils.thue_morse(16) + ``` + """ + + if n <= 0: + return [] + + return [bin(i).count("1") % 2 for i in range(n)] _MORSE_CODE = { - "a": ".-", "b": "-...", "c": "-.-.", "d": "-..", "e": ".", "f": "..-.", - "g": "--.", "h": "....", "i": "..", "j": ".---", "k": "-.-", "l": ".-..", - "m": "--", "n": "-.", "o": "---", "p": ".--.", "q": "--.-", "r": ".-.", - "s": "...", "t": "-", "u": "..-", "v": "...-", "w": ".--", "x": "-..-", - "y": "-.--", "z": "--..", - "0": "-----", "1": ".----", "2": "..---", "3": "...--", "4": "....-", - "5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.", - ".": ".-.-.-", ",": "--..--", "?": "..--..", "'": ".----.", "!": "-.-.--", - "/": "-..-.", "(": "-.--.", ")": "-.--.-", "&": ".-...", ":": "---...", - ";": "-.-.-.", "=": "-...-", "+": ".-.-.", "-": "-....-", "_": "..--.-", - '"': ".-..-.", "$": "...-..-", "@": ".--.-.", + "a": ".-", + "b": "-...", + "c": "-.-.", + "d": "-..", + "e": ".", + "f": "..-.", + "g": "--.", + "h": "....", + "i": "..", + "j": ".---", + "k": "-.-", + "l": ".-..", + "m": "--", + "n": "-.", + "o": "---", + "p": ".--.", + "q": "--.-", + "r": ".-.", + "s": "...", + "t": "-", + "u": "..-", + "v": "...-", + "w": ".--", + "x": "-..-", + "y": "-.--", + "z": "--..", + "0": "-----", + "1": ".----", + "2": "..---", + "3": "...--", + "4": "....-", + "5": ".....", + "6": "-....", + "7": "--...", + "8": "---..", + "9": "----.", + ".": ".-.-.-", + ",": "--..--", + "?": "..--..", + "'": ".----.", + "!": "-.-.--", + "/": "-..-.", + "(": "-.--.", + ")": "-.--.-", + "&": ".-...", + ":": "---...", + ";": "-.-.-.", + "=": "-...-", + "+": ".-.-.", + "-": "-....-", + "_": "..--.-", + '"': ".-..-.", + "$": "...-..-", + "@": ".--.-.", } -def morse_code ( - text: str, - dot: float = 0.25, - dash: float = 0.75, - symbol_gap: float = 0.25, - letter_gap: float = 0.75, - word_gap: float = 1.75, +def morse_code( + text: str, + dot: float = 0.25, + dash: float = 0.75, + symbol_gap: float = 0.25, + letter_gap: float = 0.75, + word_gap: float = 1.75, ) -> typing.List[float]: - - """ - Translate text into an International Morse Code rhythm. - - Encodes ``text`` as Morse and lays it out as a per-step duration list — the - same shape as :func:`generate_legato_durations`, where ``0.0`` marks a rest. - Each dot becomes a note of length ``dot`` and each dash a note of length - ``dash``; the gaps between elements, characters and words are spans of rests. - - The ``dot`` is the base time unit: every element and gap occupies - ``round(duration / dot)`` cells, so the genuine 1:3 dot/dash ratio and the - 1/3/7-unit gaps fall straight out of the defaults. Place the result on a - grid whose step equals ``dot`` for authentic timing — a dash then sustains - across its three cells. - - Pair it with :func:`sequence_to_indices` to get the note positions, then read - the matching durations from the list. (Not to be confused with - :func:`thue_morse`, the unrelated Thue-Morse mathematical sequence.) - - The full International Morse alphabet is supported: A-Z, 0-9 and the standard - punctuation. Input is normalised first — folded to one case (Morse is - caseless), runs of whitespace collapsed to a single word gap and trimmed from - the ends, and any unencodable character dropped — so arbitrary text is safe. - - Parameters: - text: The message to encode. - dot: Note length of a dot, and the base time unit (default ``0.25``). - dash: Note length of a dash (default ``0.75`` — three units). - symbol_gap: Rest between elements within a character (default ``0.25``). - letter_gap: Rest between characters (default ``0.75`` — three units). - word_gap: Rest between words (default ``1.75`` — seven units). - - Returns: - A per-step duration list with ``0.0`` for rests, or ``[]`` when the text - has no encodable characters. - - Raises: - ValueError: If ``dot`` is not positive — it is the base time unit. - - Example: - ```python - # "sos" -> the canonical ...---... rhythm. - rhythm = subsequence.sequence_utils.morse_code("sos") - steps = subsequence.sequence_utils.sequence_to_indices(rhythm) - durations = [rhythm[s] for s in steps] - p.sequence(steps=steps, pitches=40, durations=durations, velocities=90) - ``` - """ - - if dot <= 0: - raise ValueError("morse_code: dot must be positive (it is the base time unit)") - - normalised = " ".join(text.lower().split()) - if not normalised: - return [] - - words: typing.List[typing.List[str]] = [] - for word in normalised.split(" "): - codes = [_MORSE_CODE[ch] for ch in word if ch in _MORSE_CODE] - if codes: - words.append(codes) - - if not words: - return [] - - dash_cells = max(1, round(dash / dot)) - symbol_cells = max(0, round(symbol_gap / dot)) - letter_cells = max(0, round(letter_gap / dot)) - word_cells = max(0, round(word_gap / dot)) - - result: typing.List[float] = [] - - for w, codes in enumerate(words): - if w > 0: - result.extend([0.0] * word_cells) - for c, code in enumerate(codes): - if c > 0: - result.extend([0.0] * letter_cells) - for e, element in enumerate(code): - if e > 0: - result.extend([0.0] * symbol_cells) - if element == "-": - result.append(dash) - # These 0.0 cells are occupied by the dash's sustain, not rests — - # the duration-at-onset convention, as in generate_legato_durations. - result.extend([0.0] * (dash_cells - 1)) - else: - result.append(dot) - - return result - - -def de_bruijn (k: int, n: int) -> typing.List[int]: - - """ - Generate a de Bruijn sequence B(k, n). - - A de Bruijn sequence over an alphabet of size ``k`` with window ``n`` is a - cyclic sequence in which every possible subsequence of length ``n`` appears - exactly once. The output length is ``k ** n``. - - Uses Martin's algorithm (Lyndon word decomposition), which produces a - lexicographically minimal sequence with no external dependencies. - - Parameters: - k: Alphabet size (number of distinct symbols, e.g. 2 for binary or - ``len(pitches)`` for a pitch list). - n: Window size. Every ``n``-gram over the ``k`` symbols appears - exactly once in the cyclic output. - - Returns: - List of integers in ``[0, k-1]`` of length ``k ** n``. - - Example: - ```python - # Map each integer to a pitch index - indices = subsequence.sequence_utils.de_bruijn(3, 2) # length 9 - pitches = [60, 62, 64] - melody = [pitches[i] for i in indices] - ``` - """ - - if k <= 0 or n <= 0: - return [] - - sequence: typing.List[int] = [] - a = [0] * (n + 1) - - def _db (t: int, p: int) -> None: - if t > n: - if n % p == 0: - sequence.extend(a[1:p + 1]) - else: - a[t] = a[t - p] - _db(t + 1, p) - for j in range(a[t - p] + 1, k): - a[t] = j - _db(t + 1, t) - - _db(1, 1) - return sequence - - -def fibonacci_rhythm (steps: int, length: float = 4.0) -> typing.List[float]: - - """ - Generate beat positions spaced by the golden ratio (Fibonacci spiral). - - Uses the golden angle method: ``position_i = frac(i * φ) * length``, where - ``φ = (1 + √5) / 2 ≈ 1.618``. The result is sorted into ascending order. - This distributes events with the maximum possible spread — analogous to - how sunflower seeds are arranged — producing a quasi-random but - aesthetically pleasing timing distribution that is distinct from both - even grids (Euclidean) and pure randomness. - - Parameters: - steps: Number of beat positions to generate. - length: Total span in beats to fill. Defaults to 4.0 (one bar of 4/4). - - Returns: - Sorted list of ``steps`` float beat positions in ``[0.0, length)``. - - Example: - ```python - beats = subsequence.sequence_utils.fibonacci_rhythm(8, length=4.0) - for beat in beats: - p.note(pitch=60, beat=beat, velocity=80, duration=0.2) - ``` - """ - - if steps <= 0: - return [] - - phi = (1.0 + math.sqrt(5.0)) / 2.0 - - # Take the *fractional* part of i·φ to get a low-discrepancy point in [0, 1), - # then scale to the span. Applying ``% length`` directly to ``i·φ`` (φ ≈ 1.618, - # typically < length) does not equidistribute — it clusters two notes near the - # start — so the fractional part is what produces the documented sunflower spread. - positions = sorted(((i * phi) % 1.0) * length for i in range(steps)) - return positions - - -def lorenz_attractor ( - steps: int, - dt: float = 0.01, - sigma: float = 10.0, - rho: float = 28.0, - beta: float = 8.0 / 3.0, - x0: float = 0.1, - y0: float = 0.0, - z0: float = 0.0, + """ + Translate text into an International Morse Code rhythm. + + Encodes ``text`` as Morse and lays it out as a per-step duration list — the + same shape as :func:`generate_legato_durations`, where ``0.0`` marks a rest. + Each dot becomes a note of length ``dot`` and each dash a note of length + ``dash``; the gaps between elements, characters and words are spans of rests. + + The ``dot`` is the base time unit: every element and gap occupies + ``round(duration / dot)`` cells, so the genuine 1:3 dot/dash ratio and the + 1/3/7-unit gaps fall straight out of the defaults. Place the result on a + grid whose step equals ``dot`` for authentic timing — a dash then sustains + across its three cells. + + Pair it with :func:`sequence_to_indices` to get the note positions, then read + the matching durations from the list. (Not to be confused with + :func:`thue_morse`, the unrelated Thue-Morse mathematical sequence.) + + The full International Morse alphabet is supported: A-Z, 0-9 and the standard + punctuation. Input is normalised first — folded to one case (Morse is + caseless), runs of whitespace collapsed to a single word gap and trimmed from + the ends, and any unencodable character dropped — so arbitrary text is safe. + + Parameters: + text: The message to encode. + dot: Note length of a dot, and the base time unit (default ``0.25``). + dash: Note length of a dash (default ``0.75`` — three units). + symbol_gap: Rest between elements within a character (default ``0.25``). + letter_gap: Rest between characters (default ``0.75`` — three units). + word_gap: Rest between words (default ``1.75`` — seven units). + + Returns: + A per-step duration list with ``0.0`` for rests, or ``[]`` when the text + has no encodable characters. + + Raises: + ValueError: If ``dot`` is not positive — it is the base time unit. + + Example: + ```python + # "sos" -> the canonical ...---... rhythm. + rhythm = subsequence.sequence_utils.morse_code("sos") + steps = subsequence.sequence_utils.sequence_to_indices(rhythm) + durations = [rhythm[s] for s in steps] + p.sequence(steps=steps, pitches=40, durations=durations, velocities=90) + ``` + """ + + if dot <= 0: + raise ValueError("morse_code: dot must be positive (it is the base time unit)") + + normalised = " ".join(text.lower().split()) + if not normalised: + return [] + + words: typing.List[typing.List[str]] = [] + for word in normalised.split(" "): + codes = [_MORSE_CODE[ch] for ch in word if ch in _MORSE_CODE] + if codes: + words.append(codes) + + if not words: + return [] + + dash_cells = max(1, round(dash / dot)) + symbol_cells = max(0, round(symbol_gap / dot)) + letter_cells = max(0, round(letter_gap / dot)) + word_cells = max(0, round(word_gap / dot)) + + result: typing.List[float] = [] + + for w, codes in enumerate(words): + if w > 0: + result.extend([0.0] * word_cells) + for c, code in enumerate(codes): + if c > 0: + result.extend([0.0] * letter_cells) + for e, element in enumerate(code): + if e > 0: + result.extend([0.0] * symbol_cells) + if element == "-": + result.append(dash) + # These 0.0 cells are occupied by the dash's sustain, not rests — + # the duration-at-onset convention, as in generate_legato_durations. + result.extend([0.0] * (dash_cells - 1)) + else: + result.append(dot) + + return result + + +def de_bruijn(k: int, n: int) -> typing.List[int]: + """ + Generate a de Bruijn sequence B(k, n). + + A de Bruijn sequence over an alphabet of size ``k`` with window ``n`` is a + cyclic sequence in which every possible subsequence of length ``n`` appears + exactly once. The output length is ``k ** n``. + + Uses Martin's algorithm (Lyndon word decomposition), which produces a + lexicographically minimal sequence with no external dependencies. + + Parameters: + k: Alphabet size (number of distinct symbols, e.g. 2 for binary or + ``len(pitches)`` for a pitch list). + n: Window size. Every ``n``-gram over the ``k`` symbols appears + exactly once in the cyclic output. + + Returns: + List of integers in ``[0, k-1]`` of length ``k ** n``. + + Example: + ```python + # Map each integer to a pitch index + indices = subsequence.sequence_utils.de_bruijn(3, 2) # length 9 + pitches = [60, 62, 64] + melody = [pitches[i] for i in indices] + ``` + """ + + if k <= 0 or n <= 0: + return [] + + sequence: typing.List[int] = [] + a = [0] * (n + 1) + + def _db(t: int, p: int) -> None: + if t > n: + if n % p == 0: + sequence.extend(a[1 : p + 1]) + else: + a[t] = a[t - p] + _db(t + 1, p) + for j in range(a[t - p] + 1, k): + a[t] = j + _db(t + 1, t) + + _db(1, 1) + return sequence + + +def fibonacci_rhythm(steps: int, length: float = 4.0) -> typing.List[float]: + """ + Generate beat positions spaced by the golden ratio (Fibonacci spiral). + + Uses the golden angle method: ``position_i = frac(i * φ) * length``, where + ``φ = (1 + √5) / 2 ≈ 1.618``. The result is sorted into ascending order. + This distributes events with the maximum possible spread — analogous to + how sunflower seeds are arranged — producing a quasi-random but + aesthetically pleasing timing distribution that is distinct from both + even grids (Euclidean) and pure randomness. + + Parameters: + steps: Number of beat positions to generate. + length: Total span in beats to fill. Defaults to 4.0 (one bar of 4/4). + + Returns: + Sorted list of ``steps`` float beat positions in ``[0.0, length)``. + + Example: + ```python + beats = subsequence.sequence_utils.fibonacci_rhythm(8, length=4.0) + for beat in beats: + p.note(pitch=60, beat=beat, velocity=80, duration=0.2) + ``` + """ + + if steps <= 0: + return [] + + phi = (1.0 + math.sqrt(5.0)) / 2.0 + + # Take the *fractional* part of i·φ to get a low-discrepancy point in [0, 1), + # then scale to the span. Applying ``% length`` directly to ``i·φ`` (φ ≈ 1.618, + # typically < length) does not equidistribute — it clusters two notes near the + # start — so the fractional part is what produces the documented sunflower spread. + positions = sorted(((i * phi) % 1.0) * length for i in range(steps)) + return positions + + +def lorenz_attractor( + steps: int, + dt: float = 0.01, + sigma: float = 10.0, + rho: float = 28.0, + beta: float = 8.0 / 3.0, + x0: float = 0.1, + y0: float = 0.0, + z0: float = 0.0, ) -> typing.List[typing.Tuple[float, float, float]]: - - """ - Integrate the Lorenz attractor and return normalised (x, y, z) tuples. - - The Lorenz system is a set of three coupled differential equations - originally derived to model atmospheric convection. Its trajectories - orbit a butterfly-shaped strange attractor: deterministic yet sensitive to - initial conditions, never exactly repeating. The three axes provide - independent but correlated modulation sources — ideal for simultaneously - shaping pitch, velocity, and duration from a single generative process. - - Integration uses the Euler method with step ``dt``. Each axis is - independently min-max normalised to ``[0.0, 1.0]`` across the full - trajectory so that outputs span the full musical range regardless of - the chosen parameters. - - Parameters: - steps: Number of points to generate. - dt: Integration time step. Smaller values produce smoother - trajectories. Default 0.01. - sigma: Lorenz σ parameter. Default 10.0. - rho: Lorenz ρ parameter. Default 28.0 (classic chaotic regime). - beta: Lorenz β parameter. Default 8/3. - x0: Initial x position. Small changes diverge over time. - y0: Initial y position. - z0: Initial z position. - - Returns: - List of ``(x, y, z)`` tuples, each component in ``[0.0, 1.0]``. - - Example: - ```python - points = subsequence.sequence_utils.lorenz_attractor(16, x0=p.cycle * 0.001) - pitches = [60, 62, 64, 65, 67, 69, 71, 72] - for i, (x, y, z) in enumerate(points): - pitch = pitches[int(x * len(pitches)) % len(pitches)] - vel = int(40 + y * 87) - p.note(pitch=pitch, beat=i * 0.25, velocity=vel, duration=0.05 + z * 0.2) - ``` - """ - - if steps <= 0: - return [] - - x, y, z = x0, y0, z0 - xs: typing.List[float] = [] - ys: typing.List[float] = [] - zs: typing.List[float] = [] - - for _ in range(steps): - dx = sigma * (y - x) * dt - dy = (x * (rho - z) - y) * dt - dz = (x * y - beta * z) * dt - x += dx - y += dy - z += dz - xs.append(x) - ys.append(y) - zs.append(z) - - def _normalise (values: typing.List[float]) -> typing.List[float]: - lo = min(values) - hi = max(values) - if hi == lo: - return [0.5] * len(values) - span = hi - lo - return [(v - lo) / span for v in values] - - return list(zip(_normalise(xs), _normalise(ys), _normalise(zs))) - - -def reaction_diffusion_1d ( - width: int, - steps: int = 1000, - feed_rate: float = 0.055, - kill_rate: float = 0.062, - du: float = 0.16, - dv: float = 0.08, + """ + Integrate the Lorenz attractor and return normalised (x, y, z) tuples. + + The Lorenz system is a set of three coupled differential equations + originally derived to model atmospheric convection. Its trajectories + orbit a butterfly-shaped strange attractor: deterministic yet sensitive to + initial conditions, never exactly repeating. The three axes provide + independent but correlated modulation sources — ideal for simultaneously + shaping pitch, velocity, and duration from a single generative process. + + Integration uses the Euler method with step ``dt``. Each axis is + independently min-max normalised to ``[0.0, 1.0]`` across the full + trajectory so that outputs span the full musical range regardless of + the chosen parameters. + + Parameters: + steps: Number of points to generate. + dt: Integration time step. Smaller values produce smoother + trajectories. Default 0.01. + sigma: Lorenz σ parameter. Default 10.0. + rho: Lorenz ρ parameter. Default 28.0 (classic chaotic regime). + beta: Lorenz β parameter. Default 8/3. + x0: Initial x position. Small changes diverge over time. + y0: Initial y position. + z0: Initial z position. + + Returns: + List of ``(x, y, z)`` tuples, each component in ``[0.0, 1.0]``. + + Example: + ```python + points = subsequence.sequence_utils.lorenz_attractor(16, x0=p.cycle * 0.001) + pitches = [60, 62, 64, 65, 67, 69, 71, 72] + for i, (x, y, z) in enumerate(points): + pitch = pitches[int(x * len(pitches)) % len(pitches)] + vel = int(40 + y * 87) + p.note(pitch=pitch, beat=i * 0.25, velocity=vel, duration=0.05 + z * 0.2) + ``` + """ + + if steps <= 0: + return [] + + x, y, z = x0, y0, z0 + xs: typing.List[float] = [] + ys: typing.List[float] = [] + zs: typing.List[float] = [] + + for _ in range(steps): + dx = sigma * (y - x) * dt + dy = (x * (rho - z) - y) * dt + dz = (x * y - beta * z) * dt + x += dx + y += dy + z += dz + xs.append(x) + ys.append(y) + zs.append(z) + + def _normalise(values: typing.List[float]) -> typing.List[float]: + lo = min(values) + hi = max(values) + if hi == lo: + return [0.5] * len(values) + span = hi - lo + return [(v - lo) / span for v in values] + + return list(zip(_normalise(xs), _normalise(ys), _normalise(zs))) + + +def reaction_diffusion_1d( + width: int, + steps: int = 1000, + feed_rate: float = 0.055, + kill_rate: float = 0.062, + du: float = 0.16, + dv: float = 0.08, ) -> typing.List[float]: - - """ - Simulate a 1D Gray-Scott reaction-diffusion system. - - The Gray-Scott model describes two interacting chemicals U and V - on a 1D ring. V is introduced as a small seed in the centre; the - simulation evolves until a stable spatial pattern forms. The resulting - V-concentration profile — spots, stripes, or travelling waves depending - on the feed/kill parameters — is returned as a normalised float sequence. - - This is fundamentally different from cellular automata: the state is - continuous, the update rule is a PDE (not a binary function), and the - patterns are governed by diffusion rates rather than neighbour counts. - The spatial structure maps naturally to a rhythm grid. - - Parameters: - width: Number of cells (maps to the pattern's step grid). - steps: Number of simulation iterations. More steps = more developed - pattern. Default 1000. - feed_rate: Rate at which U is replenished. Default 0.055. - kill_rate: Rate at which V is removed. Default 0.062. - du: Diffusion rate for U. Default 0.16. - dv: Diffusion rate for V. Default 0.08. - - Returns: - List of ``width`` floats in ``[0.0, 1.0]`` representing final V - concentration. - - Example: - ```python - # Use as a rhythm grid — threshold to place notes - conc = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=2000) - hits = [i for i, v in enumerate(conc) if v > 0.5] - p.hit_steps("kick_1", hits, velocity=90) - ``` - """ - - if width <= 0: - return [] - - u = [1.0] * width - v = [0.0] * width - - # Seed the centre half (width//4 to 3*width//4) with a small V concentration. - seed_start = width // 4 - seed_end = 3 * width // 4 - for i in range(seed_start, seed_end): - v[i] = 0.25 - - for _ in range(steps): - - new_u = [0.0] * width - new_v = [0.0] * width - - for i in range(width): - lap_u = u[(i - 1) % width] - 2.0 * u[i] + u[(i + 1) % width] - lap_v = v[(i - 1) % width] - 2.0 * v[i] + v[(i + 1) % width] - uvv = u[i] * v[i] * v[i] - new_u[i] = u[i] + du * lap_u - uvv + feed_rate * (1.0 - u[i]) - new_v[i] = v[i] + dv * lap_v + uvv - (feed_rate + kill_rate) * v[i] - - u = new_u - v = new_v - - lo = min(v) - hi = max(v) - - if hi == lo: - return [0.0] * width - - span = hi - lo - return [(x - lo) / span for x in v] - - -def self_avoiding_walk ( - n: int, - low: int, - high: int, - rng: random.Random, - start: typing.Optional[int] = None, + """ + Simulate a 1D Gray-Scott reaction-diffusion system. + + The Gray-Scott model describes two interacting chemicals U and V + on a 1D ring. V is introduced as a small seed in the centre; the + simulation evolves until a stable spatial pattern forms. The resulting + V-concentration profile — spots, stripes, or travelling waves depending + on the feed/kill parameters — is returned as a normalised float sequence. + + This is fundamentally different from cellular automata: the state is + continuous, the update rule is a PDE (not a binary function), and the + patterns are governed by diffusion rates rather than neighbour counts. + The spatial structure maps naturally to a rhythm grid. + + Parameters: + width: Number of cells (maps to the pattern's step grid). + steps: Number of simulation iterations. More steps = more developed + pattern. Default 1000. + feed_rate: Rate at which U is replenished. Default 0.055. + kill_rate: Rate at which V is removed. Default 0.062. + du: Diffusion rate for U. Default 0.16. + dv: Diffusion rate for V. Default 0.08. + + Returns: + List of ``width`` floats in ``[0.0, 1.0]`` representing final V + concentration. + + Example: + ```python + # Use as a rhythm grid — threshold to place notes + conc = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=2000) + hits = [i for i, v in enumerate(conc) if v > 0.5] + p.hit_steps("kick_1", hits, velocity=90) + ``` + """ + + if width <= 0: + return [] + + u = [1.0] * width + v = [0.0] * width + + # Seed the centre half (width//4 to 3*width//4) with a small V concentration. + seed_start = width // 4 + seed_end = 3 * width // 4 + for i in range(seed_start, seed_end): + v[i] = 0.25 + + for _ in range(steps): + new_u = [0.0] * width + new_v = [0.0] * width + + for i in range(width): + lap_u = u[(i - 1) % width] - 2.0 * u[i] + u[(i + 1) % width] + lap_v = v[(i - 1) % width] - 2.0 * v[i] + v[(i + 1) % width] + uvv = u[i] * v[i] * v[i] + new_u[i] = u[i] + du * lap_u - uvv + feed_rate * (1.0 - u[i]) + new_v[i] = v[i] + dv * lap_v + uvv - (feed_rate + kill_rate) * v[i] + + u = new_u + v = new_v + + lo = min(v) + hi = max(v) + + if hi == lo: + return [0.0] * width + + span = hi - lo + return [(x - lo) / span for x in v] + + +def self_avoiding_walk( + n: int, + low: int, + high: int, + rng: random.Random, + start: typing.Optional[int] = None, ) -> typing.List[int]: + """ + Generate a self-avoiding random walk on an integer lattice. + + Unlike :func:`random_walk`, which can revisit any position, this walk + tracks all visited positions and avoids them. Each step moves ±1 to an + unvisited neighbour. When the walk is trapped (all neighbours have been + visited), the visited set is reset at the current position and the walk + continues — this creates natural phrase boundaries with a fresh sense of + direction after each reset. + + The constraint guarantees pitch diversity: within each "phrase" (before a + reset), no pitch is repeated and the melody explores the range in a + continuous, step-wise manner. + + Parameters: + n: Number of values to generate. + low: Minimum value (inclusive). + high: Maximum value (inclusive). + rng: Random number generator instance. + start: Starting value. Defaults to the midpoint of ``[low, high]``. + + Returns: + List of ``n`` integers in ``[low, high]``. + + Example: + ```python + # Self-avoiding bassline across a 2-octave range (MIDI 40–64) + notes = subsequence.sequence_utils.self_avoiding_walk(16, low=40, high=64, rng=p.rng) + ``` + """ + + if n <= 0: + return [] + + if low > high: + raise ValueError(f"low ({low}) must be <= high ({high})") + + if start is not None: + current = max(low, min(high, start)) + else: + current = (low + high) // 2 + + visited: typing.Set[int] = {current} + result: typing.List[int] = [current] + + for _ in range(n - 1): + candidates = [ + p + for p in (current - 1, current + 1) + if low <= p <= high and p not in visited + ] + + if not candidates: + # Trapped: reset visited and pick any valid neighbour. + visited = {current} + candidates = [p for p in (current - 1, current + 1) if low <= p <= high] + + if not candidates: + # Range is a single value; stay put. + result.append(current) + continue + + current = rng.choice(candidates) + visited.add(current) + result.append(current) + + return result + + +def _branch_retrograde( + seq: typing.List[int], root: int, interval: int +) -> typing.List[int]: + """Reverse the pitch order (rhythm untouched — callers own timing).""" - """ - Generate a self-avoiding random walk on an integer lattice. - - Unlike :func:`random_walk`, which can revisit any position, this walk - tracks all visited positions and avoids them. Each step moves ±1 to an - unvisited neighbour. When the walk is trapped (all neighbours have been - visited), the visited set is reset at the current position and the walk - continues — this creates natural phrase boundaries with a fresh sense of - direction after each reset. - - The constraint guarantees pitch diversity: within each "phrase" (before a - reset), no pitch is repeated and the melody explores the range in a - continuous, step-wise manner. - - Parameters: - n: Number of values to generate. - low: Minimum value (inclusive). - high: Maximum value (inclusive). - rng: Random number generator instance. - start: Starting value. Defaults to the midpoint of ``[low, high]``. - - Returns: - List of ``n`` integers in ``[low, high]``. - - Example: - ```python - # Self-avoiding bassline across a 2-octave range (MIDI 40–64) - notes = subsequence.sequence_utils.self_avoiding_walk(16, low=40, high=64, rng=p.rng) - ``` - """ - - if n <= 0: - return [] - - if low > high: - raise ValueError(f"low ({low}) must be <= high ({high})") - - if start is not None: - current = max(low, min(high, start)) - else: - current = (low + high) // 2 - - visited: typing.Set[int] = {current} - result: typing.List[int] = [current] - - for _ in range(n - 1): - candidates = [ - p for p in (current - 1, current + 1) - if low <= p <= high and p not in visited - ] - - if not candidates: - # Trapped: reset visited and pick any valid neighbour. - visited = {current} - candidates = [p for p in (current - 1, current + 1) if low <= p <= high] - - if not candidates: - # Range is a single value; stay put. - result.append(current) - continue - - current = rng.choice(candidates) - visited.add(current) - result.append(current) - - return result - - -def _branch_retrograde (seq: typing.List[int], root: int, interval: int) -> typing.List[int]: - - """Reverse the pitch order (rhythm untouched — callers own timing).""" - - return list(reversed(seq)) - - -def _branch_invert (seq: typing.List[int], root: int, interval: int) -> typing.List[int]: - - """Mirror each pitch around the root (the sequence's first note).""" - - return [root + (root - p) for p in seq] - + return list(reversed(seq)) -def _branch_transpose (seq: typing.List[int], root: int, interval: int) -> typing.List[int]: - """Shift all pitches by the interval between the first two notes.""" +def _branch_invert(seq: typing.List[int], root: int, interval: int) -> typing.List[int]: + """Mirror each pitch around the root (the sequence's first note).""" - return [p + interval for p in seq] + return [root + (root - p) for p in seq] -def _branch_rotate (seq: typing.List[int], root: int, interval: int) -> typing.List[int]: +def _branch_transpose( + seq: typing.List[int], root: int, interval: int +) -> typing.List[int]: + """Shift all pitches by the interval between the first two notes.""" - """Rotate the pitch order by one position (first note moves to the end).""" + return [p + interval for p in seq] - if not seq: - return seq - return seq[1:] + seq[:1] +def _branch_rotate(seq: typing.List[int], root: int, interval: int) -> typing.List[int]: + """Rotate the pitch order by one position (first note moves to the end).""" + if not seq: + return seq -def _branch_compress (seq: typing.List[int], root: int, interval: int) -> typing.List[int]: + return seq[1:] + seq[:1] - """Halve every interval from the root, rounded to the nearest semitone.""" - return [root + round((p - root) * 0.5) for p in seq] +def _branch_compress( + seq: typing.List[int], root: int, interval: int +) -> typing.List[int]: + """Halve every interval from the root, rounded to the nearest semitone.""" + return [root + round((p - root) * 0.5) for p in seq] -def _branch_expand (seq: typing.List[int], root: int, interval: int) -> typing.List[int]: - """Double every interval from the root.""" +def _branch_expand(seq: typing.List[int], root: int, interval: int) -> typing.List[int]: + """Double every interval from the root.""" - return [root + round((p - root) * 2.0) for p in seq] + return [root + round((p - root) * 2.0) for p in seq] # Indexed by the tree RNG — the ORDER of this list is part of branch()'s # deterministic output contract; append new transforms, never reorder. _BRANCH_TRANSFORMS = [ - _branch_retrograde, - _branch_invert, - _branch_transpose, - _branch_rotate, - _branch_compress, - _branch_expand, + _branch_retrograde, + _branch_invert, + _branch_transpose, + _branch_rotate, + _branch_compress, + _branch_expand, ] -def branch_sequence ( - pitches: typing.List[int], - depth: int = 2, - path: int = 0, - mutation: float = 0.0, - rng: typing.Optional[random.Random] = None, +def branch_sequence( + pitches: typing.List[int], + depth: int = 2, + path: int = 0, + mutation: float = 0.0, + rng: typing.Optional[random.Random] = None, ) -> typing.List[int]: - - """ - Navigate a fractal tree of pitch-sequence transforms and return one variation. - - The ``pitches`` sequence is the trunk. At each of ``depth`` levels two - transforms are assigned deterministically (derived from the pitch content - itself, so the tree is identical for the same trunk regardless of any - seed), and ``path`` selects left or right per level — ``2 ** depth`` - variations before the index wraps. The transforms are order and interval - operations (retrograde, inversion, transposition, rotation, interval - compression/expansion): deliberately rhythm-free, so the caller owns - timing. Feed the result to ``Motif.notes()`` for a storable variation, - or let ``p.branch()`` place it directly. - - Parameters: - pitches: The trunk — absolute MIDI pitches. All variations derive - from this. - depth: Branching levels (``2 ** depth`` variations). - path: Which variation (0-based; wraps modulo ``2 ** depth``). - mutation: Probability per step of substituting a random trunk pitch - on top of the deterministic branching. - rng: Random source for ``mutation`` draws only (the tree itself is - deterministic). Unseeded when omitted. - - Returns: - A pitch list the same length as the trunk. - """ - - if not pitches: - raise ValueError("pitches list cannot be empty") - - # Tree RNG derived from the pitch content (never the caller's rng) so the - # tree structure is always the same for the same trunk. hash() here is - # deterministic across runs: PYTHONHASHSEED randomises only str/bytes - # hashes, never ints or tuples of ints. - branch_rng = random.Random(hash(tuple(pitches))) - - path_index = path % (2 ** depth) - sequence = list(pitches) - root = pitches[0] - interval = (pitches[1] - pitches[0]) if len(pitches) >= 2 else 0 - - for level in range(depth): - left = _BRANCH_TRANSFORMS[branch_rng.randint(0, len(_BRANCH_TRANSFORMS) - 1)] - right = _BRANCH_TRANSFORMS[branch_rng.randint(0, len(_BRANCH_TRANSFORMS) - 1)] - direction = (path_index >> level) & 1 - sequence = left(sequence, root, interval) if direction == 0 else right(sequence, root, interval) - - if mutation > 0.0: - - if rng is None: - rng = random.Random() - - for i in range(len(sequence)): - if rng.random() < mutation: - sequence[i] = rng.choice(pitches) - - return sequence - - -def build_metric_weights (time_signature: typing.Tuple[int, int] = (4, 4), grid: int = 16) -> typing.List[float]: - - """ - Per-step metric weights for one bar — how "strong" each grid position is. - - The hierarchy: the downbeat is 1.0; the half-bar (even meters only) is - 0.75; other beats are 0.5; off-beat eighths are 0.25; everything finer is - 0.125. Derived from the time signature by default; pass a custom weight - list instead wherever a metric table is accepted (additive and - non-isochronous meters define their own strong beats). - - Parameters: - time_signature: ``(beats_per_bar, beat_unit)`` — only the beat count - shapes the table. - grid: Number of equal steps across the bar. - - Returns: - ``grid`` floats in step order. - - Example: - ```python - build_metric_weights((4, 4), grid=8) - # → [1.0, 0.25, 0.5, 0.25, 0.75, 0.25, 0.5, 0.25] - ``` - """ - - if grid < 1: - raise ValueError(f"grid must be at least 1 — got {grid}") - - beats_per_bar = time_signature[0] - weights = [] - - for i in range(grid): - - numerator = i * beats_per_bar # beat position = numerator / grid - - if i == 0: - weights.append(1.0) - elif beats_per_bar % 2 == 0 and 2 * i == grid: - weights.append(0.75) - elif numerator % grid == 0: - weights.append(0.5) - elif (2 * numerator) % grid == 0: - weights.append(0.25) - else: - weights.append(0.125) - - return weights - - -def vl_distance ( - source: typing.Sequence[int], - target: typing.Sequence[int], - pitch_classes: bool = True, + """ + Navigate a fractal tree of pitch-sequence transforms and return one variation. + + The ``pitches`` sequence is the trunk. At each of ``depth`` levels two + transforms are assigned deterministically (derived from the pitch content + itself, so the tree is identical for the same trunk regardless of any + seed), and ``path`` selects left or right per level — ``2 ** depth`` + variations before the index wraps. The transforms are order and interval + operations (retrograde, inversion, transposition, rotation, interval + compression/expansion): deliberately rhythm-free, so the caller owns + timing. Feed the result to ``Motif.notes()`` for a storable variation, + or let ``p.branch()`` place it directly. + + Parameters: + pitches: The trunk — absolute MIDI pitches. All variations derive + from this. + depth: Branching levels (``2 ** depth`` variations). + path: Which variation (0-based; wraps modulo ``2 ** depth``). + mutation: Probability per step of substituting a random trunk pitch + on top of the deterministic branching. + rng: Random source for ``mutation`` draws only (the tree itself is + deterministic). Unseeded when omitted. + + Returns: + A pitch list the same length as the trunk. + """ + + if not pitches: + raise ValueError("pitches list cannot be empty") + + # Tree RNG derived from the pitch content (never the caller's rng) so the + # tree structure is always the same for the same trunk. hash() here is + # deterministic across runs: PYTHONHASHSEED randomises only str/bytes + # hashes, never ints or tuples of ints. + branch_rng = random.Random(hash(tuple(pitches))) + + path_index = path % (2**depth) + sequence = list(pitches) + root = pitches[0] + interval = (pitches[1] - pitches[0]) if len(pitches) >= 2 else 0 + + for level in range(depth): + left = _BRANCH_TRANSFORMS[branch_rng.randint(0, len(_BRANCH_TRANSFORMS) - 1)] + right = _BRANCH_TRANSFORMS[branch_rng.randint(0, len(_BRANCH_TRANSFORMS) - 1)] + direction = (path_index >> level) & 1 + sequence = ( + left(sequence, root, interval) + if direction == 0 + else right(sequence, root, interval) + ) + + if mutation > 0.0: + if rng is None: + rng = random.Random() + + for i in range(len(sequence)): + if rng.random() < mutation: + sequence[i] = rng.choice(pitches) + + return sequence + + +def build_metric_weights( + time_signature: typing.Tuple[int, int] = (4, 4), grid: int = 16 +) -> typing.List[float]: + """ + Per-step metric weights for one bar — how "strong" each grid position is. + + The hierarchy: the downbeat is 1.0; the half-bar (even meters only) is + 0.75; other beats are 0.5; off-beat eighths are 0.25; everything finer is + 0.125. Derived from the time signature by default; pass a custom weight + list instead wherever a metric table is accepted (additive and + non-isochronous meters define their own strong beats). + + Parameters: + time_signature: ``(beats_per_bar, beat_unit)`` — only the beat count + shapes the table. + grid: Number of equal steps across the bar. + + Returns: + ``grid`` floats in step order. + + Example: + ```python + build_metric_weights((4, 4), grid=8) + # → [1.0, 0.25, 0.5, 0.25, 0.75, 0.25, 0.5, 0.25] + ``` + """ + + if grid < 1: + raise ValueError(f"grid must be at least 1 — got {grid}") + + beats_per_bar = time_signature[0] + weights = [] + + for i in range(grid): + numerator = i * beats_per_bar # beat position = numerator / grid + + if i == 0: + weights.append(1.0) + elif beats_per_bar % 2 == 0 and 2 * i == grid: + weights.append(0.75) + elif numerator % grid == 0: + weights.append(0.5) + elif (2 * numerator) % grid == 0: + weights.append(0.25) + else: + weights.append(0.125) + + return weights + + +def vl_distance( + source: typing.Sequence[int], + target: typing.Sequence[int], + pitch_classes: bool = True, ) -> int: - - """Voice-leading distance between two chords (Tymoczko's taxicab metric). - - The smallest total semitone movement that turns *source* into *target*, - minimised over every way of assigning source notes to target notes. When - the chords have different sizes, notes of the smaller chord may be doubled - (every note of both chords takes part — nothing is dropped). - - Parameters: - source: Pitches of the first chord. - target: Pitches of the second chord. - pitch_classes: When True (default), pitches are reduced mod 12 and each - voice moves by the shortest path around the pitch-class circle (a - tritone is 6). When False, pitches are absolute MIDI notes and - each voice moves by its literal semitone interval — use this to - score concrete voicings rather than abstract chords. - - Returns: - Total semitone movement (an int). - - Example: - ```python - vl_distance([0, 4, 7], [9, 0, 4]) # C → Am: G moves to A, distance 2 - vl_distance([0, 4, 7], [5, 9, 0]) # C → F: distance 3 - ``` - """ - - if not source or not target: - raise ValueError("vl_distance needs at least one pitch in each chord") - - if pitch_classes: - left = [pitch % 12 for pitch in source] - right = [pitch % 12 for pitch in target] - else: - left = list(source) - right = list(target) - - def step (a: int, b: int) -> int: - if pitch_classes: - diff = abs(a - b) % 12 - return min(diff, 12 - diff) - return abs(a - b) - - # Orient so `small` is doubled into `large`: every large note is assigned - # one small note, and every small note must be used at least once. - small, large = (left, right) if len(left) <= len(right) else (right, left) - - best: typing.Optional[int] = None - - for assignment in itertools.product(range(len(small)), repeat = len(large)): - - if len(set(assignment)) < len(small): - continue # a small-chord note was dropped — not a voice leading - - total = sum(step(small[index], large[position]) for position, index in enumerate(assignment)) - - if best is None or total < best: - best = total - - assert best is not None # guaranteed: the identity-style assignment always survives - return best - - -def _constraint_label (node: typing.Any) -> str: - - """A readable label for a graph node in constraint error messages.""" - - name = getattr(node, "name", None) - - if callable(name): - return str(name()) - - return repr(node) - - -def constrained_walk ( - graph: "subsequence.weighted_graph.WeightedGraph[T]", - start: T, - length: int, - rng: random.Random, - pins: typing.Optional[typing.Dict[int, T]] = None, - end: typing.Optional[T] = None, - avoid: typing.Optional[typing.Collection[T]] = None, - weight_modifier: typing.Optional[typing.Callable[[T, T, int], float]] = None, - before_choice: typing.Optional[typing.Callable[[T], None]] = None, - after_choice: typing.Optional[typing.Callable[[T], None]] = None, + """Voice-leading distance between two chords (Tymoczko's taxicab metric). + + The smallest total semitone movement that turns *source* into *target*, + minimised over every way of assigning source notes to target notes. When + the chords have different sizes, notes of the smaller chord may be doubled + (every note of both chords takes part — nothing is dropped). + + Parameters: + source: Pitches of the first chord. + target: Pitches of the second chord. + pitch_classes: When True (default), pitches are reduced mod 12 and each + voice moves by the shortest path around the pitch-class circle (a + tritone is 6). When False, pitches are absolute MIDI notes and + each voice moves by its literal semitone interval — use this to + score concrete voicings rather than abstract chords. + + Returns: + Total semitone movement (an int). + + Example: + ```python + vl_distance([0, 4, 7], [9, 0, 4]) # C → Am: G moves to A, distance 2 + vl_distance([0, 4, 7], [5, 9, 0]) # C → F: distance 3 + ``` + """ + + if not source or not target: + raise ValueError("vl_distance needs at least one pitch in each chord") + + if pitch_classes: + left = [pitch % 12 for pitch in source] + right = [pitch % 12 for pitch in target] + else: + left = list(source) + right = list(target) + + def step(a: int, b: int) -> int: + if pitch_classes: + diff = abs(a - b) % 12 + return min(diff, 12 - diff) + return abs(a - b) + + # Orient so `small` is doubled into `large`: every large note is assigned + # one small note, and every small note must be used at least once. + small, large = (left, right) if len(left) <= len(right) else (right, left) + + best: typing.Optional[int] = None + + for assignment in itertools.product(range(len(small)), repeat=len(large)): + if len(set(assignment)) < len(small): + continue # a small-chord note was dropped — not a voice leading + + total = sum( + step(small[index], large[position]) + for position, index in enumerate(assignment) + ) + + if best is None or total < best: + best = total + + assert best is not None # guaranteed: the identity-style assignment always survives + return best + + +def _constraint_label(node: typing.Any) -> str: + """A readable label for a graph node in constraint error messages.""" + + name = getattr(node, "name", None) + + if callable(name): + return str(name()) + + return repr(node) + + +def constrained_walk( + graph: "subsequence.weighted_graph.WeightedGraph[T]", + start: T, + length: int, + rng: random.Random, + pins: typing.Optional[typing.Dict[int, T]] = None, + end: typing.Optional[T] = None, + avoid: typing.Optional[typing.Collection[T]] = None, + weight_modifier: typing.Optional[typing.Callable[[T, T, int], float]] = None, + before_choice: typing.Optional[typing.Callable[[T], None]] = None, + after_choice: typing.Optional[typing.Callable[[T], None]] = None, ) -> typing.List[T]: + """Walk a weighted graph under constraints — the shared hybrid kernel. + + A backward **feasibility** pass (boolean reachability of every pin, so + unsatisfiability is known before a note is emitted) followed by a forward + walk through the graph's **real, possibly history-dependent** weights + masked to surviving candidates. This guarantees *satisfaction*, not an + exact conditional distribution — history-dependent weighting (NIR, + gravity, diversity) keeps its character rather than being flattened. + + Positions are **1-based** (the musician count); position 1 is *start*, + which is fixed — pins may name it only redundantly. ``avoid`` applies + to the chosen positions (2..length); the start is exempt. + + With no constraints, the walk consumes the RNG exactly as repeated + ``graph.choose_next()`` calls would (one draw per step, same candidate + order), so an unconstrained walk reproduces the unconstrained engine. + + Parameters: + graph: The transition graph. + start: The node at position 1. + length: Total walk length, including *start*. + rng: Random stream for the weighted draws. + pins: ``{position: node}`` — the node that MUST sound at a 1-based + position. + end: The node at the final position — sugar for ``pins[length]``. + avoid: Nodes excluded from every chosen position. Naming a node + the graph does not contain is allowed (trivially satisfied). + weight_modifier: ``fn(source, target, weight) -> float`` — the + engine's soft weighting, applied inside the feasible mask. If + it suppresses every feasible candidate at some step (all + modifiers <= 0), the step falls back to the unmodified weights + (stall detection) — satisfaction beats character. + before_choice: Called with the source node before each draw — the + seam for history bookkeeping (append the source to history, as + the live engine's ``step()`` does, so *weight_modifier* sees + the same context it would live). + after_choice: Called with each chosen node after its draw. + + Returns: + The walked nodes, ``[start, ...]``, exactly *length* long. + + Raises: + ValueError: If the constraints are contradictory or unsatisfiable — + before any RNG draw, naming the failing position. + """ + + if length < 1: + raise ValueError(f"a walk needs at least one position, got length={length}") + + pins = dict(pins) if pins else {} + + if end is not None: + if length in pins and pins[length] != end: + raise ValueError( + f"end={_constraint_label(end)} conflicts with pins[{length}]=" + f"{_constraint_label(pins[length])} — they name the same position" + ) + pins[length] = end + + for position in pins: + if not 1 <= position <= length: + raise ValueError( + f"pin position {position} is outside the walk (1–{length})" + ) + + avoid_set = set(avoid) if avoid else set() + + for position, node in pins.items(): + if node in avoid_set: + raise ValueError( + f"pins[{position}]={_constraint_label(node)} is also in avoid — " + "a chord cannot be both required and forbidden" + ) + + if 1 in pins and pins[1] != start: + raise ValueError( + f"pins[1]={_constraint_label(pins[1])} conflicts with the walk's start " + f"({_constraint_label(start)}) — position 1 is where the walk begins" + ) + + all_nodes = graph.nodes() + + for position, node in pins.items(): + if node not in all_nodes and node != start: + raise ValueError( + f"pins[{position}]={_constraint_label(node)} is not in this graph's " + "vocabulary — it can never sound" + ) + + if length == 1: + return [start] + + # Backward feasibility: ok[i] = nodes allowed at position i from which a + # valid completion exists. Position 1 is the fixed start. + def allowed(position: int) -> typing.List[T]: + if position in pins: + return [pins[position]] + return [node for node in all_nodes if node not in avoid_set] + + ok: typing.Dict[int, typing.Set[T]] = {length: set(allowed(length))} + + if not ok[length]: + raise ValueError(f"no chord can satisfy the constraints at position {length}") + + for position in range(length - 1, 1, -1): + ok[position] = { + node + for node in allowed(position) + if any( + target in ok[position + 1] for target, _ in graph.get_transitions(node) + ) + } + if not ok[position]: + raise ValueError( + f"no chord can satisfy the constraints at position {position} — " + "there is no way through to the pins after it" + ) + + if not any(target in ok[2] for target, _ in graph.get_transitions(start)): + raise ValueError( + f"no path from {_constraint_label(start)} satisfies the constraints " + f"(pins at {sorted(pins)}, {len(avoid_set)} avoided) within {length} positions" + ) + + # Forward walk: real weights, masked to the feasible sets. + walk: typing.List[T] = [start] + + for position in range(2, length + 1): + source = walk[-1] + + if before_choice is not None: + before_choice(source) + + candidates: typing.List[typing.Tuple[T, float]] = [] + total = 0.0 - """Walk a weighted graph under constraints — the shared hybrid kernel. - - A backward **feasibility** pass (boolean reachability of every pin, so - unsatisfiability is known before a note is emitted) followed by a forward - walk through the graph's **real, possibly history-dependent** weights - masked to surviving candidates. This guarantees *satisfaction*, not an - exact conditional distribution — history-dependent weighting (NIR, - gravity, diversity) keeps its character rather than being flattened. - - Positions are **1-based** (the musician count); position 1 is *start*, - which is fixed — pins may name it only redundantly. ``avoid`` applies - to the chosen positions (2..length); the start is exempt. - - With no constraints, the walk consumes the RNG exactly as repeated - ``graph.choose_next()`` calls would (one draw per step, same candidate - order), so an unconstrained walk reproduces the unconstrained engine. - - Parameters: - graph: The transition graph. - start: The node at position 1. - length: Total walk length, including *start*. - rng: Random stream for the weighted draws. - pins: ``{position: node}`` — the node that MUST sound at a 1-based - position. - end: The node at the final position — sugar for ``pins[length]``. - avoid: Nodes excluded from every chosen position. Naming a node - the graph does not contain is allowed (trivially satisfied). - weight_modifier: ``fn(source, target, weight) -> float`` — the - engine's soft weighting, applied inside the feasible mask. If - it suppresses every feasible candidate at some step (all - modifiers <= 0), the step falls back to the unmodified weights - (stall detection) — satisfaction beats character. - before_choice: Called with the source node before each draw — the - seam for history bookkeeping (append the source to history, as - the live engine's ``step()`` does, so *weight_modifier* sees - the same context it would live). - after_choice: Called with each chosen node after its draw. - - Returns: - The walked nodes, ``[start, ...]``, exactly *length* long. - - Raises: - ValueError: If the constraints are contradictory or unsatisfiable — - before any RNG draw, naming the failing position. - """ - - if length < 1: - raise ValueError(f"a walk needs at least one position, got length={length}") - - pins = dict(pins) if pins else {} - - if end is not None: - if length in pins and pins[length] != end: - raise ValueError( - f"end={_constraint_label(end)} conflicts with pins[{length}]=" - f"{_constraint_label(pins[length])} — they name the same position" - ) - pins[length] = end - - for position in pins: - if not 1 <= position <= length: - raise ValueError(f"pin position {position} is outside the walk (1–{length})") - - avoid_set = set(avoid) if avoid else set() - - for position, node in pins.items(): - if node in avoid_set: - raise ValueError( - f"pins[{position}]={_constraint_label(node)} is also in avoid — " - "a chord cannot be both required and forbidden" - ) - - if 1 in pins and pins[1] != start: - raise ValueError( - f"pins[1]={_constraint_label(pins[1])} conflicts with the walk's start " - f"({_constraint_label(start)}) — position 1 is where the walk begins" - ) - - all_nodes = graph.nodes() - - for position, node in pins.items(): - if node not in all_nodes and node != start: - raise ValueError( - f"pins[{position}]={_constraint_label(node)} is not in this graph's " - "vocabulary — it can never sound" - ) - - if length == 1: - return [start] - - # Backward feasibility: ok[i] = nodes allowed at position i from which a - # valid completion exists. Position 1 is the fixed start. - def allowed (position: int) -> typing.List[T]: - if position in pins: - return [pins[position]] - return [node for node in all_nodes if node not in avoid_set] - - ok: typing.Dict[int, typing.Set[T]] = {length: set(allowed(length))} - - if not ok[length]: - raise ValueError(f"no chord can satisfy the constraints at position {length}") - - for position in range(length - 1, 1, -1): - ok[position] = { - node for node in allowed(position) - if any(target in ok[position + 1] for target, _ in graph.get_transitions(node)) - } - if not ok[position]: - raise ValueError( - f"no chord can satisfy the constraints at position {position} — " - "there is no way through to the pins after it" - ) - - if not any(target in ok[2] for target, _ in graph.get_transitions(start)): - raise ValueError( - f"no path from {_constraint_label(start)} satisfies the constraints " - f"(pins at {sorted(pins)}, {len(avoid_set)} avoided) within {length} positions" - ) - - # Forward walk: real weights, masked to the feasible sets. - walk: typing.List[T] = [start] - - for position in range(2, length + 1): - - source = walk[-1] - - if before_choice is not None: - before_choice(source) - - candidates: typing.List[typing.Tuple[T, float]] = [] - total = 0.0 + for target, weight in graph.get_transitions(source): + if target not in ok[position]: + continue - for target, weight in graph.get_transitions(source): + modifier = ( + 1.0 + if weight_modifier is None + else float(weight_modifier(source, target, weight)) + ) - if target not in ok[position]: - continue + if modifier <= 0: + continue - modifier = 1.0 if weight_modifier is None else float(weight_modifier(source, target, weight)) + adjusted = float(weight) * modifier + candidates.append((target, adjusted)) + total += adjusted - if modifier <= 0: - continue + if total <= 0: + # Stall: the soft weighting suppressed every feasible candidate. + # Satisfaction beats character — fall back to the bare weights. + candidates = [ + (target, float(weight)) + for target, weight in graph.get_transitions(source) + if target in ok[position] + ] + total = sum(weight for _, weight in candidates) - adjusted = float(weight) * modifier - candidates.append((target, adjusted)) - total += adjusted + roll = rng.uniform(0, total) + accum = 0.0 + chosen = candidates[-1][0] - if total <= 0: - # Stall: the soft weighting suppressed every feasible candidate. - # Satisfaction beats character — fall back to the bare weights. - candidates = [ - (target, float(weight)) - for target, weight in graph.get_transitions(source) - if target in ok[position] - ] - total = sum(weight for _, weight in candidates) + for target, adjusted in candidates: + accum += adjusted + if roll <= accum: + chosen = target + break - roll = rng.uniform(0, total) - accum = 0.0 - chosen = candidates[-1][0] + if after_choice is not None: + after_choice(chosen) - for target, adjusted in candidates: - accum += adjusted - if roll <= accum: - chosen = target - break + walk.append(chosen) - if after_choice is not None: - after_choice(chosen) + return walk - walk.append(chosen) - return walk +def cseg(pitches: typing.Sequence[float]) -> typing.List[int]: + """Contour segment: each pitch's rank within the line (Morris's CSEG). + The melodic shape abstracted from exact pitch: ``[60, 67, 64]`` and + ``[50, 59, 55]`` both rank ``[0, 2, 1]``. Equal pitches share a rank. -def cseg (pitches: typing.Sequence[float]) -> typing.List[int]: + Example: + ```python + cseg([60, 67, 64]) # → [0, 2, 1] + cseg([5, 5, 3]) # → [1, 1, 0] + ``` + """ - """Contour segment: each pitch's rank within the line (Morris's CSEG). + if not pitches: + return [] - The melodic shape abstracted from exact pitch: ``[60, 67, 64]`` and - ``[50, 59, 55]`` both rank ``[0, 2, 1]``. Equal pitches share a rank. + distinct = sorted(set(pitches)) - Example: - ```python - cseg([60, 67, 64]) # → [0, 2, 1] - cseg([5, 5, 3]) # → [1, 1, 0] - ``` - """ + return [distinct.index(pitch) for pitch in pitches] - if not pitches: - return [] - distinct = sorted(set(pitches)) +def csim(a: typing.Sequence[float], b: typing.Sequence[float]) -> float: + """Contour similarity between two equal-length lines (Marvin/Laprade CSIM). - return [distinct.index(pitch) for pitch in pitches] + The fraction of pairwise order relations (above/below/equal) the two + contours share — 1.0 for identical shapes, regardless of exact pitch. + Raises ``ValueError`` for mismatched lengths (similarity between + different-length contours is not defined here). + """ -def csim (a: typing.Sequence[float], b: typing.Sequence[float]) -> float: + if len(a) != len(b): + raise ValueError( + f"csim() compares equal-length lines — got {len(a)} and {len(b)}" + ) - """Contour similarity between two equal-length lines (Marvin/Laprade CSIM). + count = len(a) - The fraction of pairwise order relations (above/below/equal) the two - contours share — 1.0 for identical shapes, regardless of exact pitch. + if count < 2: + return 1.0 - Raises ``ValueError`` for mismatched lengths (similarity between - different-length contours is not defined here). - """ + def relation(x: float, y: float) -> int: + return (x > y) - (x < y) - if len(a) != len(b): - raise ValueError(f"csim() compares equal-length lines — got {len(a)} and {len(b)}") + agreements = 0 + pairs = 0 - count = len(a) + for i in range(count): + for j in range(i + 1, count): + pairs += 1 + if relation(a[i], a[j]) == relation(b[i], b[j]): + agreements += 1 - if count < 2: - return 1.0 - - def relation (x: float, y: float) -> int: - return (x > y) - (x < y) - - agreements = 0 - pairs = 0 - - for i in range(count): - for j in range(i + 1, count): - pairs += 1 - if relation(a[i], a[j]) == relation(b[i], b[j]): - agreements += 1 - - return agreements / pairs + return agreements / pairs # --------------------------------------------------------------------------- @@ -2850,131 +2931,122 @@ def relation (x: float, y: float) -> int: # --------------------------------------------------------------------------- -def sieve ( - classes: typing.Sequence[typing.Tuple[int, int]], - hi: int, - lo: int = 0, +def sieve( + classes: typing.Sequence[typing.Tuple[int, int]], + hi: int, + lo: int = 0, ) -> typing.List[int]: - - """Xenakis sieve: the sorted integers in ``[lo, hi)`` in any of the classes. - - A sieve (Xenakis's *crible*) is a logical formula over **residual - classes** that denotes a subset of the integers. This primary form takes - a list of ``(modulus, residue)`` pairs and returns their **union** over a - bounded range — every ``x`` in ``[lo, hi)`` with ``x % modulus == residue`` - for at least one class. The integers index *any* ordered parameter, so - one kernel builds custom scales (over 0–11 semitones), non-octave pitch - pools, rhythm grids, and bar-selection masks. - - For intersection and complement, compose :func:`residual_class` objects - with ``&``, ``|``, ``~`` and evaluate the result (see :class:`Sieve`). - - Parameters: - classes: ``(modulus, residue)`` pairs. ``modulus`` must be ≥ 1; the - residue is taken modulo the modulus. - hi: Exclusive upper bound. - lo: Inclusive lower bound (default 0). - - Returns: - The sorted, de-duplicated integers in range that satisfy any class. - - Raises: - ValueError: If a modulus is below 1. - - Example: - ```python - sieve([(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12) - # → [0, 2, 4, 5, 7, 9, 11] — the major scale as a sieve - sieve([(2, 0)], hi=12) # → [0, 2, 4, 6, 8, 10] — whole-tone - sieve([(5, 0), (7, 1)], lo=60, hi=96) # a non-octave pitch pool - ``` - """ - - for modulus, _residue in classes: - if modulus < 1: - raise ValueError(f"sieve modulus must be at least 1 — got {modulus}") - - hits = { - x - for x in range(lo, hi) - for modulus, residue in classes - if x % modulus == residue % modulus - } - - return sorted(hits) + """Xenakis sieve: the sorted integers in ``[lo, hi)`` in any of the classes. + + A sieve (Xenakis's *crible*) is a logical formula over **residual + classes** that denotes a subset of the integers. This primary form takes + a list of ``(modulus, residue)`` pairs and returns their **union** over a + bounded range — every ``x`` in ``[lo, hi)`` with ``x % modulus == residue`` + for at least one class. The integers index *any* ordered parameter, so + one kernel builds custom scales (over 0–11 semitones), non-octave pitch + pools, rhythm grids, and bar-selection masks. + + For intersection and complement, compose :func:`residual_class` objects + with ``&``, ``|``, ``~`` and evaluate the result (see :class:`Sieve`). + + Parameters: + classes: ``(modulus, residue)`` pairs. ``modulus`` must be ≥ 1; the + residue is taken modulo the modulus. + hi: Exclusive upper bound. + lo: Inclusive lower bound (default 0). + + Returns: + The sorted, de-duplicated integers in range that satisfy any class. + + Raises: + ValueError: If a modulus is below 1. + + Example: + ```python + sieve([(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12) + # → [0, 2, 4, 5, 7, 9, 11] — the major scale as a sieve + sieve([(2, 0)], hi=12) # → [0, 2, 4, 6, 8, 10] — whole-tone + sieve([(5, 0), (7, 1)], lo=60, hi=96) # a non-octave pitch pool + ``` + """ + + for modulus, _residue in classes: + if modulus < 1: + raise ValueError(f"sieve modulus must be at least 1 — got {modulus}") + + hits = { + x + for x in range(lo, hi) + for modulus, residue in classes + if x % modulus == residue % modulus + } + + return sorted(hits) class Sieve: + """A composable Xenakis sieve — residual classes under ``&`` ``|`` ``~``. - """A composable Xenakis sieve — residual classes under ``&`` ``|`` ``~``. - - The full algebra layer over :func:`sieve`: build a :class:`Sieve` with - :func:`residual_class` and combine with union (``|``), intersection - (``&``), and complement (``~``), then :meth:`evaluate` over a range. - Membership is decided per integer, so complement is well-defined - without a range until evaluation. - - Example: - ```python - # Multiples of 2 or 3, excluding steps one past a multiple of 4. - evens = subsequence.sequence_utils.residual_class(2, 0) - thirds = subsequence.sequence_utils.residual_class(3, 0) - off_fours = subsequence.sequence_utils.residual_class(4, 1) - ((evens | thirds) & ~off_fours).evaluate(hi=24) - ``` - """ - - def __init__ (self, predicate: typing.Callable[[int], bool]) -> None: - - """Wrap a membership predicate (use :func:`residual_class` to make one).""" - - self._predicate = predicate - - def __contains__ (self, value: int) -> bool: - - """Membership test for a single integer.""" - - return self._predicate(value) + The full algebra layer over :func:`sieve`: build a :class:`Sieve` with + :func:`residual_class` and combine with union (``|``), intersection + (``&``), and complement (``~``), then :meth:`evaluate` over a range. + Membership is decided per integer, so complement is well-defined + without a range until evaluation. - def __or__ (self, other: "Sieve") -> "Sieve": + Example: + ```python + # Multiples of 2 or 3, excluding steps one past a multiple of 4. + evens = subsequence.sequence_utils.residual_class(2, 0) + thirds = subsequence.sequence_utils.residual_class(3, 0) + off_fours = subsequence.sequence_utils.residual_class(4, 1) + ((evens | thirds) & ~off_fours).evaluate(hi=24) + ``` + """ - """Union — in either sieve.""" + def __init__(self, predicate: typing.Callable[[int], bool]) -> None: + """Wrap a membership predicate (use :func:`residual_class` to make one).""" - return Sieve(lambda x: self._predicate(x) or other._predicate(x)) + self._predicate = predicate - def __and__ (self, other: "Sieve") -> "Sieve": + def __contains__(self, value: int) -> bool: + """Membership test for a single integer.""" - """Intersection — in both sieves.""" + return self._predicate(value) - return Sieve(lambda x: self._predicate(x) and other._predicate(x)) + def __or__(self, other: "Sieve") -> "Sieve": + """Union — in either sieve.""" - def __invert__ (self) -> "Sieve": + return Sieve(lambda x: self._predicate(x) or other._predicate(x)) - """Complement — every integer NOT in this sieve.""" + def __and__(self, other: "Sieve") -> "Sieve": + """Intersection — in both sieves.""" - return Sieve(lambda x: not self._predicate(x)) + return Sieve(lambda x: self._predicate(x) and other._predicate(x)) - def evaluate (self, hi: int, lo: int = 0) -> typing.List[int]: + def __invert__(self) -> "Sieve": + """Complement — every integer NOT in this sieve.""" - """The sorted integers in ``[lo, hi)`` that belong to this sieve.""" + return Sieve(lambda x: not self._predicate(x)) - return [x for x in range(lo, hi) if self._predicate(x)] + def evaluate(self, hi: int, lo: int = 0) -> typing.List[int]: + """The sorted integers in ``[lo, hi)`` that belong to this sieve.""" + return [x for x in range(lo, hi) if self._predicate(x)] -def residual_class (modulus: int, residue: int) -> Sieve: - """A single residual class ``{x : x % modulus == residue}`` as a :class:`Sieve`. +def residual_class(modulus: int, residue: int) -> Sieve: + """A single residual class ``{x : x % modulus == residue}`` as a :class:`Sieve`. - The atom of sieve algebra (Xenakis's notation ``modulus @ residue``). - Combine with ``&`` ``|`` ``~`` and call :meth:`Sieve.evaluate`. - """ + The atom of sieve algebra (Xenakis's notation ``modulus @ residue``). + Combine with ``&`` ``|`` ``~`` and call :meth:`Sieve.evaluate`. + """ - if modulus < 1: - raise ValueError(f"residual-class modulus must be at least 1 — got {modulus}") + if modulus < 1: + raise ValueError(f"residual-class modulus must be at least 1 — got {modulus}") - reduced = residue % modulus + reduced = residue % modulus - return Sieve(lambda x: x % modulus == reduced) + return Sieve(lambda x: x % modulus == reduced) # --------------------------------------------------------------------------- @@ -2983,141 +3055,146 @@ def residual_class (modulus: int, residue: int) -> Sieve: # --------------------------------------------------------------------------- -def rhythmic_evenness (onsets: typing.Sequence[int], grid: int, normalize: bool = True) -> float: - - """How evenly onsets are spread around the cycle (Toussaint's evenness). - - Places the *grid* pulses as equally spaced points on a unit circle and - sums the chord lengths between every pair of onsets (``2·sin(π·d/grid)`` - for a pulse distance ``d``). A maximally even rhythm (a regular polygon — - the Euclidean rhythms) maximises this sum; clustered onsets minimise it. +def rhythmic_evenness( + onsets: typing.Sequence[int], grid: int, normalize: bool = True +) -> float: + """How evenly onsets are spread around the cycle (Toussaint's evenness). - Parameters: - onsets: 0-based onset indices (duplicates and out-of-range values are - reduced modulo *grid* and de-duplicated). - grid: Pulses in the cycle. - normalize: Divide by the maximally-even sum for *k* onsets, giving - ``(0, 1]`` (1.0 = perfectly even). ``False`` returns the raw sum. + Places the *grid* pulses as equally spaced points on a unit circle and + sums the chord lengths between every pair of onsets (``2·sin(π·d/grid)`` + for a pulse distance ``d``). A maximally even rhythm (a regular polygon — + the Euclidean rhythms) maximises this sum; clustered onsets minimise it. - Returns: - The evenness, in ``(0, 1]`` when normalised (1.0 for fewer than two - onsets). + Parameters: + onsets: 0-based onset indices (duplicates and out-of-range values are + reduced modulo *grid* and de-duplicated). + grid: Pulses in the cycle. + normalize: Divide by the maximally-even sum for *k* onsets, giving + ``(0, 1]`` (1.0 = perfectly even). ``False`` returns the raw sum. - Example: - ```python - rhythmic_evenness([0, 3, 6], 8) # tresillo — near 1.0 - rhythmic_evenness([0, 1, 2], 8) # clustered — much lower - ``` - """ + Returns: + The evenness, in ``(0, 1]`` when normalised (1.0 for fewer than two + onsets). - if grid < 1: - raise ValueError(f"grid must be at least 1 — got {grid}") + Example: + ```python + rhythmic_evenness([0, 3, 6], 8) # tresillo — near 1.0 + rhythmic_evenness([0, 1, 2], 8) # clustered — much lower + ``` + """ - reduced = sorted({o % grid for o in onsets}) - k = len(reduced) + if grid < 1: + raise ValueError(f"grid must be at least 1 — got {grid}") - if k < 2: - return 1.0 + reduced = sorted({o % grid for o in onsets}) + k = len(reduced) - raw = sum( - 2.0 * math.sin(math.pi * abs(reduced[a] - reduced[b]) / grid) - for a in range(k) - for b in range(a + 1, k) - ) + if k < 2: + return 1.0 - if not normalize: - return raw + raw = sum( + 2.0 * math.sin(math.pi * abs(reduced[a] - reduced[b]) / grid) + for a in range(k) + for b in range(a + 1, k) + ) - # The maximally-even arrangement of k points on the grid is the regular - # k-gon: pairwise distances 2·sin(π·i/k) for each of the k rotations. - maximum = sum(2.0 * math.sin(math.pi * i / k) * (k - i) for i in range(1, k)) + if not normalize: + return raw - return raw / maximum if maximum > 0 else 1.0 + # The maximally-even arrangement of k points on the grid is the regular + # k-gon: pairwise distances 2·sin(π·i/k) for each of the k rotations. + maximum = sum(2.0 * math.sin(math.pi * i / k) * (k - i) for i in range(1, k)) + return raw / maximum if maximum > 0 else 1.0 -def offbeatness (onsets: typing.Sequence[int], grid: int) -> int: - """How many onsets fall on intrinsically off-beat pulses (Toussaint). +def offbeatness(onsets: typing.Sequence[int], grid: int) -> int: + """How many onsets fall on intrinsically off-beat pulses (Toussaint). - The on-beat pulses are the vertices of every regular sub-polygon of the - cycle (the divisor meters); the off-beat pulses are exactly those coprime - to *grid*. Off-beatness counts onsets landing on coprime positions — a - meter-independent syncopation flavour (high for rhythms that fight every - even subdivision). + The on-beat pulses are the vertices of every regular sub-polygon of the + cycle (the divisor meters); the off-beat pulses are exactly those coprime + to *grid*. Off-beatness counts onsets landing on coprime positions — a + meter-independent syncopation flavour (high for rhythms that fight every + even subdivision). - Parameters: - onsets: 0-based onset indices (reduced modulo *grid*, de-duplicated). - grid: Pulses in the cycle. + Parameters: + onsets: 0-based onset indices (reduced modulo *grid*, de-duplicated). + grid: Pulses in the cycle. - Returns: - The count of distinct onsets on off-beat (coprime) pulses. + Returns: + The count of distinct onsets on off-beat (coprime) pulses. - Example: - ```python - offbeatness([0, 4, 8, 12], 16) # 0 — all on the strong polygon - offbeatness([0, 3, 6, 10, 13], 16) # 2 — bossa, off-beats on pulses 3 and 13 - ``` - """ + Example: + ```python + offbeatness([0, 4, 8, 12], 16) # 0 — all on the strong polygon + offbeatness([0, 3, 6, 10, 13], 16) # 2 — bossa, off-beats on pulses 3 and 13 + ``` + """ - if grid < 1: - raise ValueError(f"grid must be at least 1 — got {grid}") + if grid < 1: + raise ValueError(f"grid must be at least 1 — got {grid}") - reduced = {o % grid for o in onsets} + reduced = {o % grid for o in onsets} - # The downbeat (pulse 0) is always on-beat; the explicit guard also handles - # grid==1, where gcd(0, 1) == 1 would otherwise misclassify it. - return sum(1 for p in reduced if p != 0 and math.gcd(p, grid) == 1) + # The downbeat (pulse 0) is always on-beat; the explicit guard also handles + # grid==1, where gcd(0, 1) == 1 would otherwise misclassify it. + return sum(1 for p in reduced if p != 0 and math.gcd(p, grid) == 1) -def syncopation ( - onsets: typing.Sequence[int], - grid: int, - time_signature: typing.Tuple[int, int] = (4, 4), - weights: typing.Optional[typing.Sequence[float]] = None, +def syncopation( + onsets: typing.Sequence[int], + grid: int, + time_signature: typing.Tuple[int, int] = (4, 4), + weights: typing.Optional[typing.Sequence[float]] = None, ) -> float: - - """How much a rhythm pulls away from its metric strong points. - - A weighted off-beat measure: each onset contributes ``max_weight − - weight_at_its_pulse`` from the metric-weight table (so onsets on the - downbeat add nothing, onsets on the weakest pulses add the most), - normalised by the onset count. Uses :func:`build_metric_weights` by - default; pass a custom per-pulse *weights* list for additive or - non-isochronous meters. - - Parameters: - onsets: 0-based onset indices (reduced modulo *grid*, de-duplicated). - grid: Pulses in the cycle. - time_signature: Shapes the default weight table. - weights: Optional explicit per-pulse weights (length *grid*). - - Returns: - Mean weight deficit per onset, in ``[0, max_weight − min_weight]`` - (0.0 only when every onset sits on the downbeat, or there are none; - the downbeat alone carries the maximum weight, so even on-beat - rhythms score a little above 0). - - Example: - ```python - syncopation([0], 16) # 0.0 — the downbeat only - syncopation([0, 4, 8, 12], 16) # low — on the beats, but beat 1 is strongest - syncopation([3, 7, 11, 15], 16) # high — every onset on a weak pulse - ``` - """ - - if grid < 1: - raise ValueError(f"grid must be at least 1 — got {grid}") - - table = list(weights) if weights is not None else build_metric_weights(time_signature, grid) - - if len(table) != grid: - raise ValueError(f"weights must have one value per grid step ({grid}) — got {len(table)}") - - reduced = sorted({o % grid for o in onsets}) - - if not reduced: - return 0.0 - - strongest = max(table) - - return sum(strongest - table[p] for p in reduced) / len(reduced) + """How much a rhythm pulls away from its metric strong points. + + A weighted off-beat measure: each onset contributes ``max_weight − + weight_at_its_pulse`` from the metric-weight table (so onsets on the + downbeat add nothing, onsets on the weakest pulses add the most), + normalised by the onset count. Uses :func:`build_metric_weights` by + default; pass a custom per-pulse *weights* list for additive or + non-isochronous meters. + + Parameters: + onsets: 0-based onset indices (reduced modulo *grid*, de-duplicated). + grid: Pulses in the cycle. + time_signature: Shapes the default weight table. + weights: Optional explicit per-pulse weights (length *grid*). + + Returns: + Mean weight deficit per onset, in ``[0, max_weight − min_weight]`` + (0.0 only when every onset sits on the downbeat, or there are none; + the downbeat alone carries the maximum weight, so even on-beat + rhythms score a little above 0). + + Example: + ```python + syncopation([0], 16) # 0.0 — the downbeat only + syncopation([0, 4, 8, 12], 16) # low — on the beats, but beat 1 is strongest + syncopation([3, 7, 11, 15], 16) # high — every onset on a weak pulse + ``` + """ + + if grid < 1: + raise ValueError(f"grid must be at least 1 — got {grid}") + + table = ( + list(weights) + if weights is not None + else build_metric_weights(time_signature, grid) + ) + + if len(table) != grid: + raise ValueError( + f"weights must have one value per grid step ({grid}) — got {len(table)}" + ) + + reduced = sorted({o % grid for o in onsets}) + + if not reduced: + return 0.0 + + strongest = max(table) + + return sum(strongest - table[p] for p in reduced) / len(reduced) diff --git a/subsequence/sequencer.py b/subsequence/sequencer.py index b25019a..326ad5f 100644 --- a/subsequence/sequencer.py +++ b/subsequence/sequencer.py @@ -29,2058 +29,2177 @@ @typing.runtime_checkable -class PatternLike (typing.Protocol): +class PatternLike(typing.Protocol): + """ + Protocol for pattern objects that can be scheduled. + """ - """ - Protocol for pattern objects that can be scheduled. - """ + channel: int + device: int + length: float + reschedule_lookahead: float + steps: typing.Dict[int, typing.Any] + _cycle_start_pulse: int - channel: int - device: int - length: float - reschedule_lookahead: float - steps: typing.Dict[int, typing.Any] - _cycle_start_pulse: int + def on_reschedule(self) -> None: + """ + Hook called immediately before the pattern is rescheduled. + """ + ... - def on_reschedule (self) -> None: - """ - Hook called immediately before the pattern is rescheduled. - """ - - ... - - -@dataclasses.dataclass (order=True) +@dataclasses.dataclass(order=True) class MidiEvent: - - """ - Represents a MIDI event scheduled at a specific pulse. - - ``sequence`` is a FIFO tie-breaker: when two events share a pulse, the - one pushed first dispatches first. Without it, ``heapq`` ordering of - same-pulse events is undefined, which would break NRPN/RPN sequences - (CC 99 → 98 → 6 → 38) and risk reordering bank-select-before-program-change. - The Sequencer assigns ``sequence`` via its monotonic ``_event_counter`` - at push time (see ``Sequencer._push_event``); direct constructions in - tests can leave it at the default. - - ``priority`` outranks the FIFO tie-breaker at a shared pulse: events - with a lower priority dispatch first. Notes are pushed before CC - events, so a tuning onset bend (which must reach the synth BEFORE its - note_on) carries priority ``-1``; everything else stays at ``0``. - """ - - pulse: int - message_type: str = dataclasses.field(compare=False) - channel: int = dataclasses.field(compare=False) - note: int = dataclasses.field(compare=False, default=0) - velocity: int = dataclasses.field(compare=False, default=0) - control: int = dataclasses.field(compare=False, default=0) - value: int = dataclasses.field(compare=False, default=0) - data: typing.Any = dataclasses.field(compare=False, default=None) - device: int = dataclasses.field(compare=False, default=0) - priority: int = 0 - sequence: int = 0 - - - def to_mido (self) -> typing.Optional[typing.Union[mido.Message, mido.MetaMessage]]: - - """Convert this event to a mido.Message, or None if it's an internal type (like OSC).""" - - if self.message_type in ('note_on', 'note_off'): - return mido.Message( - self.message_type, - channel = self.channel, - note = self.note, - velocity = self.velocity - ) - - if self.message_type == 'control_change': - return mido.Message( - 'control_change', - channel = self.channel, - control = self.control, - value = self.value - ) - - if self.message_type == 'pitchwheel': - return mido.Message( - 'pitchwheel', - channel = self.channel, - pitch = self.value - ) - - if self.message_type == 'program_change': - return mido.Message( - 'program_change', - channel = self.channel, - program = self.value - ) - - if self.message_type == 'sysex': - return mido.Message( - 'sysex', - data = self.data if self.data is not None else b'' - ) - - return None - - - @classmethod - def from_mido (cls, pulse: int, msg: typing.Union[mido.Message, mido.MetaMessage], device: int = 0) -> "MidiEvent": - - """Convert a mido.Message to a MidiEvent.""" - - if msg.type == 'pitchwheel': - return cls( - pulse = pulse, - message_type = 'pitchwheel', - channel = msg.channel, - value = msg.pitch, - device = device, - ) - - if msg.type == 'control_change': - return cls( - pulse = pulse, - message_type = 'control_change', - channel = msg.channel, - control = msg.control, - value = msg.value, - device = device, - ) - - return cls( - pulse = pulse, - message_type = msg.type, - channel = getattr(msg, 'channel', 0), - value = getattr(msg, 'value', 0), - note = getattr(msg, 'note', 0), - velocity = getattr(msg, 'velocity', 0), - data = getattr(msg, 'data', None), - control = getattr(msg, 'control', 0), - device = device, - ) + """ + Represents a MIDI event scheduled at a specific pulse. + + ``sequence`` is a FIFO tie-breaker: when two events share a pulse, the + one pushed first dispatches first. Without it, ``heapq`` ordering of + same-pulse events is undefined, which would break NRPN/RPN sequences + (CC 99 → 98 → 6 → 38) and risk reordering bank-select-before-program-change. + The Sequencer assigns ``sequence`` via its monotonic ``_event_counter`` + at push time (see ``Sequencer._push_event``); direct constructions in + tests can leave it at the default. + + ``priority`` outranks the FIFO tie-breaker at a shared pulse: events + with a lower priority dispatch first. Notes are pushed before CC + events, so a tuning onset bend (which must reach the synth BEFORE its + note_on) carries priority ``-1``; everything else stays at ``0``. + """ + + pulse: int + message_type: str = dataclasses.field(compare=False) + channel: int = dataclasses.field(compare=False) + note: int = dataclasses.field(compare=False, default=0) + velocity: int = dataclasses.field(compare=False, default=0) + control: int = dataclasses.field(compare=False, default=0) + value: int = dataclasses.field(compare=False, default=0) + data: typing.Any = dataclasses.field(compare=False, default=None) + device: int = dataclasses.field(compare=False, default=0) + priority: int = 0 + sequence: int = 0 + + def to_mido(self) -> typing.Optional[typing.Union[mido.Message, mido.MetaMessage]]: + """Convert this event to a mido.Message, or None if it's an internal type (like OSC).""" + + if self.message_type in ("note_on", "note_off"): + return mido.Message( + self.message_type, + channel=self.channel, + note=self.note, + velocity=self.velocity, + ) + + if self.message_type == "control_change": + return mido.Message( + "control_change", + channel=self.channel, + control=self.control, + value=self.value, + ) + + if self.message_type == "pitchwheel": + return mido.Message("pitchwheel", channel=self.channel, pitch=self.value) + + if self.message_type == "program_change": + return mido.Message( + "program_change", channel=self.channel, program=self.value + ) + + if self.message_type == "sysex": + return mido.Message( + "sysex", data=self.data if self.data is not None else b"" + ) + + return None + + @classmethod + def from_mido( + cls, + pulse: int, + msg: typing.Union[mido.Message, mido.MetaMessage], + device: int = 0, + ) -> "MidiEvent": + """Convert a mido.Message to a MidiEvent.""" + + if msg.type == "pitchwheel": + return cls( + pulse=pulse, + message_type="pitchwheel", + channel=msg.channel, + value=msg.pitch, + device=device, + ) + + if msg.type == "control_change": + return cls( + pulse=pulse, + message_type="control_change", + channel=msg.channel, + control=msg.control, + value=msg.value, + device=device, + ) + + return cls( + pulse=pulse, + message_type=msg.type, + channel=getattr(msg, "channel", 0), + value=getattr(msg, "value", 0), + note=getattr(msg, "note", 0), + velocity=getattr(msg, "velocity", 0), + data=getattr(msg, "data", None), + control=getattr(msg, "control", 0), + device=device, + ) @dataclasses.dataclass class _MirrorTarget: + """A resolved fan-out destination — a ``(device, channel)`` plus an optional + per-device ``drum_note_map`` used to re-resolve mirrored drum names. - """A resolved fan-out destination — a ``(device, channel)`` plus an optional - per-device ``drum_note_map`` used to re-resolve mirrored drum names. - - Constructed transiently inside ``schedule_pattern`` (never stored on the - Pattern, never public) purely for ergonomic attribute access in the - fan-out loops. Stored mirror entries remain plain tuples — a dict-bearing - entry would be unhashable and could not live in the ``set`` used by - ``_stop_pattern_notes``. - """ + Constructed transiently inside ``schedule_pattern`` (never stored on the + Pattern, never public) purely for ergonomic attribute access in the + fan-out loops. Stored mirror entries remain plain tuples — a dict-bearing + entry would be unhashable and could not live in the ``set`` used by + ``_stop_pattern_notes``. + """ - device: int - channel: int - drum_note_map: typing.Optional[typing.Dict[str, int]] = None + device: int + channel: int + drum_note_map: typing.Optional[typing.Dict[str, int]] = None -def _to_target (entry: typing.Sequence[typing.Any]) -> _MirrorTarget: +def _to_target(entry: typing.Sequence[typing.Any]) -> _MirrorTarget: + """Coerce a stored mirror entry to a ``_MirrorTarget``. - """Coerce a stored mirror entry to a ``_MirrorTarget``. + *entry* is ``(device, channel)`` or ``(device, channel, drum_note_map)`` — + a tuple or list. Branching on length here keeps the 2-vs-3 split in one + place (and off the typed ``MirrorSpec`` union). + """ - *entry* is ``(device, channel)`` or ``(device, channel, drum_note_map)`` — - a tuple or list. Branching on length here keeps the 2-vs-3 split in one - place (and off the typed ``MirrorSpec`` union). - """ + drum_map = entry[2] if len(entry) == 3 else None + return _MirrorTarget(entry[0], entry[1], drum_map) - drum_map = entry[2] if len(entry) == 3 else None - return _MirrorTarget(entry[0], entry[1], drum_map) +def _destination_pitch( + note: typing.Any, target: _MirrorTarget, primary: bool +) -> typing.Optional[int]: + """Return the MIDI note to emit for *note* at *target*, or None to drop it. -def _destination_pitch (note: typing.Any, target: _MirrorTarget, primary: bool) -> typing.Optional[int]: + Drum names are resolved per destination: a destination that has no voice for + the name stays silent (returns None) rather than sounding a wrong number. - """Return the MIDI note to emit for *note* at *target*, or None to drop it. + - A ``primary_unmapped`` note (its ``origin`` was absent from the pattern's + own map) has no real primary pitch — only a mirror whose map contains the + name voices it; the primary and raw (map-less) mirrors return None. + - Otherwise the primary and raw 2-tuple mirrors copy ``note.pitch``; a + symbolic (map-bearing) mirror re-resolves ``note.origin`` through its own + map, dropping a named voice it lacks and copying an origin-less literal + pitch. + """ - Drum names are resolved per destination: a destination that has no voice for - the name stays silent (returns None) rather than sounding a wrong number. + if note.primary_unmapped: + # No real primary note exists; only a mapping mirror can sound it. + if primary or target.drum_note_map is None: + return None + if note.origin is not None and note.origin in target.drum_note_map: + return target.drum_note_map[note.origin] + return None - - A ``primary_unmapped`` note (its ``origin`` was absent from the pattern's - own map) has no real primary pitch — only a mirror whose map contains the - name voices it; the primary and raw (map-less) mirrors return None. - - Otherwise the primary and raw 2-tuple mirrors copy ``note.pitch``; a - symbolic (map-bearing) mirror re-resolves ``note.origin`` through its own - map, dropping a named voice it lacks and copying an origin-less literal - pitch. - """ + if primary or target.drum_note_map is None: + return typing.cast(int, note.pitch) - if note.primary_unmapped: - # No real primary note exists; only a mapping mirror can sound it. - if primary or target.drum_note_map is None: - return None - if note.origin is not None and note.origin in target.drum_note_map: - return target.drum_note_map[note.origin] - return None + if note.origin is not None: + if note.origin in target.drum_note_map: + return target.drum_note_map[note.origin] + return None # a named voice this device lacks → silent (not a wrong note) - if primary or target.drum_note_map is None: - return typing.cast(int, note.pitch) - - if note.origin is not None: - if note.origin in target.drum_note_map: - return target.drum_note_map[note.origin] - return None # a named voice this device lacks → silent (not a wrong note) - - return typing.cast(int, note.pitch) + return typing.cast(int, note.pitch) @dataclasses.dataclass class ScheduledPattern: + """ + Tracks a repeating pattern and its scheduling metadata. + """ - """ - Tracks a repeating pattern and its scheduling metadata. - """ - - pattern: PatternLike - cycle_start_pulse: int - length_pulses: int - lookahead_pulses: int - next_reschedule_pulse: int + pattern: PatternLike + cycle_start_pulse: int + length_pulses: int + lookahead_pulses: int + next_reschedule_pulse: int @dataclasses.dataclass class ScheduledCallback: + """ + Tracks a repeating callback and its scheduling metadata. + """ - """ - Tracks a repeating callback and its scheduling metadata. - """ - - callback: typing.Callable[[int], typing.Any] - cycle_start_pulse: int - interval_pulses: int - lookahead_pulses: int - next_fire_pulse: int + callback: typing.Callable[[int], typing.Any] + cycle_start_pulse: int + interval_pulses: int + lookahead_pulses: int + next_fire_pulse: int @dataclasses.dataclass class ScheduledCallbackSequence: - - """Tracks a self-rescheduling callback whose firing interval varies per hop. - - The variable-interval counterpart to :class:`ScheduledCallback` — built - for clocks that walk irregular spans (the harmonic clock under a bound - progression). Each fire targets a *boundary* pulse; the callback's - return value sets the distance to the next boundary. - - Attributes: - callback: Called with the boundary pulse it is preparing. Returns - the number of **beats** to the next boundary, or ``None`` to - stop firing (the sequence is dropped from the queue). May be - sync or async. - boundary_pulse: The pulse this fire prepares (the musical boundary, - not the fire time). - lookahead_pulses: How far before each boundary the callback fires. - next_fire_pulse: When the next fire is due. - """ - - callback: typing.Callable[[int], typing.Union[typing.Optional[float], typing.Coroutine[typing.Any, typing.Any, typing.Optional[float]]]] - boundary_pulse: int - lookahead_pulses: int - next_fire_pulse: int + """Tracks a self-rescheduling callback whose firing interval varies per hop. + + The variable-interval counterpart to :class:`ScheduledCallback` — built + for clocks that walk irregular spans (the harmonic clock under a bound + progression). Each fire targets a *boundary* pulse; the callback's + return value sets the distance to the next boundary. + + Attributes: + callback: Called with the boundary pulse it is preparing. Returns + the number of **beats** to the next boundary, or ``None`` to + stop firing (the sequence is dropped from the queue). May be + sync or async. + boundary_pulse: The pulse this fire prepares (the musical boundary, + not the fire time). + lookahead_pulses: How far before each boundary the callback fires. + next_fire_pulse: When the next fire is due. + """ + + callback: typing.Callable[ + [int], + typing.Union[ + typing.Optional[float], + typing.Coroutine[typing.Any, typing.Any, typing.Optional[float]], + ], + ] + boundary_pulse: int + lookahead_pulses: int + next_fire_pulse: int @dataclasses.dataclass class BpmTransition: + """State for a gradual BPM transition.""" - """State for a gradual BPM transition.""" - - start_bpm: float - target_bpm: float - total_pulses: int - elapsed_pulses: int = 0 - easing_fn: typing.Callable[[float], float] = dataclasses.field(default=subsequence.easing.linear) + start_bpm: float + target_bpm: float + total_pulses: int + elapsed_pulses: int = 0 + easing_fn: typing.Callable[[float], float] = dataclasses.field( + default=subsequence.easing.linear + ) class Sequencer: - - """ - The engine that drives Subsequence timing and MIDI output. - - The ``Sequencer`` maintains a stable clock (internal or external), - handles the scheduling of MIDI events, and triggers pattern rebuilds. - """ - - def __init__ ( - self, - output_device_name: typing.Optional[str] = None, - initial_bpm: float = 125, - time_signature: typing.Tuple[int, int] = (4, 4), - input_device_name: typing.Optional[str] = None, - clock_follow: bool = False, - clock_output: bool = False, - record: bool = False, - record_filename: typing.Optional[str] = None, - spin_wait: bool = True, - _jitter_log: typing.Optional[typing.List[float]] = None - ) -> None: - - """Initialize the sequencer with MIDI devices and initial BPM. - - Parameters: - output_device_name: MIDI output device name. When omitted, auto-discovers - available devices - uses the only device if one is found, or prompts - the user to choose if multiple are available. - initial_bpm: Tempo in BPM (ignored when clock_follow is True) - input_device_name: Optional MIDI input device name for clock/transport - clock_follow: When True, follow external MIDI clock instead of internal clock - clock_output: When True, send MIDI timing clock (0xF8), start (0xFA), and - stop (0xFC) messages so connected hardware can sync to Subsequence's - tempo. Mutually exclusive with ``clock_follow`` (ignored when both - are set, to prevent feedback loops). - record: When True, record all MIDI events to a file. - record_filename: Optional filename for the recording (defaults to timestamp). - spin_wait: When True (default), use a hybrid sleep+spin strategy for the - final sub-millisecond of each pulse interval. This significantly - reduces clock jitter at the cost of ~1–5% extra CPU. Set to False - to use pure ``asyncio.sleep()`` (lower CPU, higher jitter). - _jitter_log: Optional list to append per-pulse jitter values (seconds) - to during playback. Intended for the clock jitter benchmark — not - for general use. - """ - - if clock_follow and input_device_name is None: - raise ValueError("clock_follow=True requires an input_device_name") - - self.output_device_name = output_device_name - self.input_device_name = input_device_name - self.time_signature = time_signature - self.clock_follow = clock_follow - self.clock_device_idx: int = 0 - self.clock_output = clock_output and not clock_follow - self.pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE - - # Recording state - self.recording = record - self.record_filename = record_filename - self.recorded_events: typing.List[typing.Tuple[float, typing.Union[mido.Message, mido.MetaMessage]]] = [] - - # Render mode: run as fast as possible and stop after render_bars or render_max_seconds. - # Both limits are optional — at least one must be set (enforced in Composition.render). - self.render_mode: bool = False - self.render_bars: int = 0 # 0 = no bar limit - self.render_max_seconds: typing.Optional[float] = None # None = no time limit - self._render_elapsed_seconds: float = 0.0 - - - # CC input mapping — populated from Composition.cc_map() - self.cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] - # CC forwarding — populated from Composition.cc_forward() - self.cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] - # Buffer for queued CC forwards: deque of (pulse, mido.Message) tuples. - # Appended on the mido callback thread; drained on the event loop thread. - self._forward_buffer: collections.deque = collections.deque() - # Shared reference to composition.data so CC mappings can update it - self._composition_data: typing.Dict[str, typing.Any] = {} - - # Held-note input — populated from Composition.note_input(). - # The tracker (created in _run when note_input was declared) and a buffer - # of raw (is_on, pitch, velocity, perf_counter) note events. The buffer is - # appended on the mido callback thread and drained on the loop thread in - # _advance_pulse, so all tracker state stays single-threaded — no lock. - self._held_notes: typing.Optional[subsequence.held_notes.HeldNotes] = None - self._note_input_buffer: collections.deque = collections.deque() - self._note_input_channel: typing.Optional[int] = None # None = any channel - self._note_input_device: typing.Optional[int] = None # None = any input device - - # Ableton Link clock — set by Composition._run() when comp.link() was called. - # Must be initialized before set_bpm() is called below. - self._link_clock: typing.Optional[typing.Any] = None - - # Multi-device MIDI port registries. - # Output device 0 is always the primary/default device. - self._output_devices: subsequence.midi_utils.MidiDeviceRegistry = subsequence.midi_utils.MidiDeviceRegistry() - self._input_devices: subsequence.midi_utils.MidiDeviceRegistry = subsequence.midi_utils.MidiDeviceRegistry() - - # Internal state initialization (needed before set_bpm) - self._midi_input_queue: typing.Optional[asyncio.Queue] = None - self._input_loop: typing.Optional[asyncio.AbstractEventLoop] = None - self._event_loop: typing.Optional[asyncio.AbstractEventLoop] = None - self._clock_tick_times: typing.List[float] = [] - self._waiting_for_start: bool = False - - self.event_queue: typing.List[MidiEvent] = [] - self.task: typing.Optional[asyncio.Task] = None - self.start_time = 0.0 - self.pulse_count = 0 - self.current_bar: int = -1 - self.current_beat: int = -1 - self.active_notes: typing.Set[typing.Tuple[int, int, int]] = set() # (device, channel, note) - - # Device latency compensation: cached max across all output devices, and - # the set of in-flight deferred sends (call_later handles) awaiting their - # per-device offset. See _dispatch_with_compensation() and stop(). - self._max_device_latency_ms: float = 0.0 - self._pending_sends: typing.Set[asyncio.TimerHandle] = set() - - # Strong references to fire-and-forget bar/beat/event tasks. Without - # retaining them asyncio holds only a weak reference and the task can be - # garbage-collected mid-flight; the done-callback also surfaces exceptions - # that a bare create_task would otherwise swallow until GC. - self._background_tasks: typing.Set[asyncio.Task] = set() - - self.queue_lock = asyncio.Lock() - self.pattern_lock = asyncio.Lock() - self.reschedule_queue: typing.List[typing.Tuple[int, int, ScheduledPattern]] = [] - self._reschedule_counter = itertools.count() - self.events = subsequence.event_emitter.EventEmitter() - self.callback_lock = asyncio.Lock() - self.callback_queue: typing.List[typing.Tuple[int, int, ScheduledCallback]] = [] - self._callback_counter = itertools.count() - - # Variable-interval callback sequences share callback_lock; they fire - # after the fixed callbacks at the same pulse (see - # _maybe_reschedule_patterns), preserving form-before-harmony ordering. - self.callback_sequence_queue: typing.List[typing.Tuple[int, int, ScheduledCallbackSequence]] = [] - - # FIFO tie-breaker for same-pulse MidiEvents in event_queue. Without - # it, ``heapq`` ordering of equal-pulse events is undefined, which - # breaks NRPN/RPN bursts (CC 99 → 98 → 6 → 38 must stay in order). - self._event_counter = itertools.count() - - # Serialises actual port writes. Every send runs on the event-loop - # thread EXCEPT instant-mode cc_forward, which fires on the mido input - # callback thread — without this lock the two threads could interleave - # bytes on the same port and corrupt a multi-byte message. - self._port_send_lock = threading.Lock() - - self.data: typing.Dict[str, typing.Any] = {} - - # Timing variables - self.current_bpm: float = 0 - self.seconds_per_beat = 0.0 - self.seconds_per_pulse = 0.0 - self.running = False - self._bpm_transition: typing.Optional[BpmTransition] = None - self._spin_wait: bool = spin_wait - # Spin threshold: sleep all the way to this many seconds before the target, - # then busy-wait for the remainder. 1ms is enough to absorb OS wakeup latency - # while keeping spin time short enough not to starve the event loop. - self._spin_threshold: float = 0.001 - self._jitter_log: typing.Optional[typing.List[float]] = _jitter_log - - self.set_bpm(initial_bpm) - - self._init_midi_output() - - # OSC server reference — set by Composition after osc_server.start() - self.osc_server: typing.Optional[typing.Any] = None - - # ------------------------------------------------------------------ - # Backward-compatible properties: midi_out / midi_in - # External code and tests may reference these directly. They always - # resolve to device 0 of the respective registry. - # ------------------------------------------------------------------ - - @property - def midi_out (self) -> typing.Optional[typing.Any]: - """Return the primary output port (device 0), or None.""" - return self._output_devices.get(0) - - @midi_out.setter - def midi_out (self, value: typing.Optional[typing.Any]) -> None: - """Allow test code to inject a fake output port as device 0.""" - if value is None: - return - if len(self._output_devices) == 0: - self._output_devices.add("default", value) - else: - self._output_devices.replace(0, value) - - @property - def midi_in (self) -> typing.Optional[typing.Any]: - """Return the primary input port (device 0), or None.""" - return self._input_devices.get(0) - - @midi_in.setter - def midi_in (self, value: typing.Optional[typing.Any]) -> None: - """Allow test code to inject a fake input port as device 0.""" - if value is None: - return - if len(self._input_devices) == 0: - self._input_devices.add("default", value) - else: - self._input_devices.replace(0, value) - - def add_output_device (self, name: str, port: typing.Any, latency_ms: float = 0.0) -> int: - """Register an additional output device. Returns the device index. - - *latency_ms* is the device's physical output latency for compensation - (see :meth:`set_device_latency`). - """ - idx = self._output_devices.add(name, port, latency_ms) - self._max_device_latency_ms = self._output_devices.max_latency() - return idx - - def add_input_device (self, name: str, port: typing.Any) -> int: - """Register an additional input device. Returns the device index.""" - return self._input_devices.add(name, port) - - def set_device_latency (self, device: subsequence.midi_utils.DeviceId, latency_ms: float) -> None: - - """Set an output device's physical latency (ms) for delay compensation. - - Latency is normalised engine-wide: the slowest device plays at its - logical time and every faster device's output is deferred by - ``max_latency − its_latency`` so all devices sound together. See - :meth:`_dispatch_with_compensation`. - - Parameters: - device: Output device id (int index, name str, or None for device 0). - latency_ms: Non-negative physical output latency in milliseconds. - Raises ``ValueError`` if negative or the device is unknown. - """ - - self._output_devices.set_latency(device, latency_ms) - self._max_device_latency_ms = self._output_devices.max_latency() - - def _record_event (self, pulse: int, message: typing.Union[mido.Message, mido.MetaMessage]) -> None: - - """Record a MIDI message with an absolute pulse timestamp for later export.""" - - if not self.recording: - return - - self.recorded_events.append((float(pulse), message)) - - def save_recording (self) -> None: - - """Save the recorded session to a MIDI file.""" - - if not self.recording or not self.recorded_events: - return - - if self.record_filename: - filename = self.record_filename - else: - now = datetime.datetime.now() - filename = now.strftime("session_%Y%m%d_%H%M%S.mid") - - logger.info(f"Saving MIDI recording ({len(self.recorded_events)} events) to {filename}...") - - mid = mido.MidiFile(type=1) # Type 1 = multiple tracks (though we might just use one) - track = mido.MidiTrack() - mid.tracks.append(track) - - # Resolution (ticks per beat). Standard is 480. - # Subsequence uses 24 PPQN internal. - # To get 480 PPQN output without losing precision, we scale up by 20. - ticks_per_pulse = 20 - mid.ticks_per_beat = 480 - - # Sort events by pulse just in case - self.recorded_events.sort(key=lambda x: x[0]) - - last_pulse = 0.0 - - for pulse, message in self.recorded_events: - - delta_pulses = pulse - last_pulse - delta_ticks = int(delta_pulses * ticks_per_pulse) - - # Ensure delta is non-negative (floating point jitter?) - if delta_ticks < 0: - delta_ticks = 0 - - message.time = delta_ticks - track.append(message) - - last_pulse = pulse - - try: - mid.save(filename) - logger.info(f"Saved {filename}") - except Exception as e: - logger.error(f"Failed to save MIDI recording: {e}") - - def disable_spin_wait (self) -> None: - - """Disable the hybrid sleep+spin timing strategy. - - By default the sequencer busy-waits for the final sub-millisecond of each - pulse interval to minimise clock jitter. Call this to revert to pure - ``asyncio.sleep()`` — lower CPU usage at the cost of higher jitter (typically - ±0.5–2 ms on Linux vs ~3–4 μs with spin-wait enabled (see the README clock-accuracy benchmark)). - - Can also be set at construction time: ``Sequencer(spin_wait=False)``. - """ - - self._spin_wait = False - - - def set_bpm (self, bpm: float) -> None: - - """ - Instantly change the tempo. - - Note: If ``clock_follow`` is enabled and the sequencer is running, - this method will be ignored as the tempo is slaved to the external source. - When Ableton Link is active, the new BPM is proposed to the Link network - instead of being applied locally — the network-authoritative tempo is - then picked up on the next pulse. - """ - - # Validate BEFORE the Link branch — a zero/negative tempo must never - # be proposed to the whole Link session. - if bpm <= 0: - raise ValueError("BPM must be positive") - - if self.clock_follow and self.running: - logger.info("BPM is controlled by external clock - set_bpm() ignored") - return - - if self._link_clock is not None and self.running: - self._link_clock.request_tempo(bpm) - logger.info(f"BPM {bpm:.2f} proposed to Ableton Link session") - return - - self._bpm_transition = None - self.current_bpm = bpm - self.seconds_per_beat = 60.0 / self.current_bpm - self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat - - logger.info(f"BPM set to {self.current_bpm:.2f}") - - if self.recording: - tempo = mido.bpm2tempo(self.current_bpm) - self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=tempo)) - - - def set_target_bpm (self, target_bpm: float, bars: int, shape: typing.Union[str, subsequence.easing.EasingFn] = "linear") -> None: - - """ - Smoothly transition to a new tempo over a fixed number of bars. - - Parameters: - target_bpm: The BPM to ramp toward. - bars: Duration of the transition in bars. - shape: Easing curve — a name string (e.g. ``"ease_in_out"``) or any - callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. - ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- - sounding tempo changes. See :mod:`subsequence.easing`. - - Note: - When Ableton Link is active the shared network tempo is authoritative, - so a local ramp cannot be honoured — this call is ignored. Use - ``set_bpm()`` to propose a new tempo to the Link session instead. - """ - - if self.clock_follow and self.running: - logger.info("BPM is controlled by external clock - set_target_bpm() ignored") - return - - if self._link_clock is not None and self.running: - logger.info("Tempo is controlled by the Ableton Link session - set_target_bpm() ramp ignored; use set_bpm() to propose a new Link tempo") - return - - if target_bpm <= 0: - raise ValueError("Target BPM must be positive") - - if bars <= 0: - raise ValueError("Transition bars must be positive") - - total_pulses = bars * self.pulses_per_beat * self.time_signature[0] - - self._bpm_transition = BpmTransition( - start_bpm=self.current_bpm, - target_bpm=target_bpm, - total_pulses=total_pulses, - easing_fn=subsequence.easing.get_easing(shape) - ) - - logger.info(f"BPM transition: {self.current_bpm:.2f} → {target_bpm:.2f} over {bars} bars ({shape!r})") - - - def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None: - - """ - Register a callback for a named event. - """ - - self.events.on(event_name, callback) - - - def _init_midi_output (self) -> None: - - """Initialize the primary MIDI output port (device 0). - - When ``output_device_name`` was provided, opens that device directly. - When omitted, auto-discovers available devices: uses the only one if - exactly one is found, or prompts the user to choose if several exist. - """ - - device_name, midi_out = subsequence.midi_utils.select_output_device(self.output_device_name) - - if device_name and midi_out is not None: - self.output_device_name = device_name - self._output_devices.add(device_name, midi_out) - - - def _open_midi_inputs (self) -> None: - - """ - Set up the internal event loops and MIDI input ports. - - This is called automatically by start(), but may be called manually - by Composition._run() earlier in the startup sequence to ensure - MIDI CC configuration is shared before ports begin background draining. - """ - - if self.input_device_name is not None and self._midi_input_queue is None: - self._input_loop = asyncio.get_running_loop() - self._midi_input_queue = asyncio.Queue() - self._init_midi_input() - - - def _init_midi_input (self) -> None: - - """Initialize the primary MIDI input port (device 0) with a callback.""" - - if self.input_device_name is None: - return - - callback = self._make_input_callback(0) - device_name, midi_in = subsequence.midi_utils.select_input_device(self.input_device_name, callback) - - if device_name and midi_in is not None: - self.input_device_name = device_name - self._input_devices.add(device_name, midi_in) - - def _make_input_callback (self, device_idx: int) -> typing.Callable: - """Return a mido callback closure that tags messages with *device_idx*.""" - - def _callback (message: typing.Any) -> None: - self._on_midi_input(message, device_idx) - - return _callback - - - def _on_midi_input (self, message: typing.Any, device_idx: int = 0) -> None: - - """Handle incoming MIDI messages from the input port callback thread. - - This runs in mido's callback thread. Clock/transport messages are - forwarded to the asyncio event loop via call_soon_threadsafe. - - CC input mappings are applied immediately here. Single dict writes - are safe from a non-asyncio thread under CPython's GIL. - - Parameters: - message: The incoming mido.Message. - device_idx: Index of the input device this message arrived on (0 = primary). - """ - - if self._midi_input_queue is None or self._input_loop is None: - return - - # The queue feeds only the external-clock loop, so enqueue only when - # following: with the internal clock nothing ever drains it, and a - # synced device's 24-ticks-per-beat clock would grow it forever. - if self.clock_follow: - self._input_loop.call_soon_threadsafe( - self._midi_input_queue.put_nowait, (device_idx, message) - ) - - # Apply CC input mappings: map incoming CC values to composition.data. - if message.type == 'control_change' and self.cc_mappings: - for mapping in self.cc_mappings: - if message.control != mapping['cc']: - continue - ch = mapping.get('channel') - if ch is not None and message.channel != ch: - continue - # Filter by input device if specified (None = any device). - in_dev = mapping.get('input_device') - if in_dev is not None and device_idx != in_dev: - continue - scaled = mapping['min_val'] + (message.value / 127.0) * (mapping['max_val'] - mapping['min_val']) - self._composition_data[mapping['data_key']] = scaled - - # Apply CC forwards: route incoming CC to MIDI output in real-time. - if message.type == 'control_change' and self.cc_forwards: - for fwd in self.cc_forwards: - if message.control != fwd['cc']: - continue - ch = fwd.get('channel') - if ch is not None and message.channel != ch: - continue - # Filter by input device if specified (None = any device). - in_dev = fwd.get('input_device') - if in_dev is not None and device_idx != in_dev: - continue - try: - out_msg = fwd['transform'](message.value, message.channel) - except Exception: - logger.exception("CC forward transform failed") - continue - if out_msg is None: - continue - if fwd['mode'] == 'instant': - # Route to the specified output device (default: device 0). - out_dev = fwd.get('output_device', 0) - port = self._output_devices.get(out_dev) - if port is not None: - try: - # Instant mode fires on the mido callback thread, so the - # send must take the lock the loop thread also holds. - self._locked_send(port, out_msg) - except Exception: - logger.exception("CC forward send failed") - else: - # Queued: buffer for drain in _process_pulse on the event loop - # thread. The output device travels with the message so the - # drain can route it (dropping it sent everything to device 0). - out_dev = fwd.get('output_device') - self._forward_buffer.append((self.pulse_count, out_msg, 0 if out_dev is None else out_dev)) - - # Buffer incoming note on/off for the held-note tracker. Only the - # GIL-atomic deque.append happens here; the tracker is updated when the - # loop thread drains the buffer in _advance_pulse. - if self._held_notes is not None and message.type in ('note_on', 'note_off'): - if self._note_input_channel is not None and message.channel != self._note_input_channel: - return - if self._note_input_device is not None and device_idx != self._note_input_device: - return - # A note_on with velocity 0 is the running-status form of note-off. - if message.type == 'note_on' and message.velocity > 0: - self._note_input_buffer.append((True, message.note, message.velocity, time.perf_counter())) - else: - self._note_input_buffer.append((False, message.note, 0, time.perf_counter())) - - - - def _estimate_bpm (self, tick_time: float) -> None: - - """Estimate BPM from recent MIDI clock tick timestamps for display and recording.""" - - self._clock_tick_times.append(tick_time) - - # Keep last 48 ticks (2 beats) for averaging. - if len(self._clock_tick_times) > 48: - self._clock_tick_times = self._clock_tick_times[-48:] - - if len(self._clock_tick_times) >= 24: - # Average interval over last 24 ticks (1 beat). - recent = self._clock_tick_times[-24:] - interval = (recent[-1] - recent[0]) / (len(recent) - 1) - - if interval > 0: - new_bpm = int(round(60.0 / (interval * self.pulses_per_beat))) - - # Record tempo changes so a clock-following session's .mid - # plays back at the external tempo, not the constructor BPM. - # Integer rounding above already filters tick jitter. - if self.recording and new_bpm != self.current_bpm and new_bpm > 0: - self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(new_bpm))) - - self.current_bpm = new_bpm - - - def _get_schedule_timing (self, length_beats: float, lookahead_beats: float) -> typing.Tuple[int, int]: - - """ - Convert schedule length and reschedule lookahead from beats to pulses. - """ - - if length_beats <= 0: - raise ValueError("Schedule length must be positive") - - if lookahead_beats < 0: - raise ValueError("Reschedule lookahead cannot be negative") - - if lookahead_beats > length_beats: - raise ValueError("Reschedule lookahead cannot exceed schedule length") - - length_pulses = int(length_beats * self.pulses_per_beat) - lookahead_pulses = int(lookahead_beats * self.pulses_per_beat) - - if length_pulses <= 0: - raise ValueError("Schedule length must be at least one pulse") - - return length_pulses, lookahead_pulses - - - def _get_pattern_timing (self, pattern: PatternLike) -> typing.Tuple[int, int]: - - """ - Convert pattern length and reschedule lookahead from beats to pulses. - """ - - return self._get_schedule_timing(pattern.length, pattern.reschedule_lookahead) - - - def _push_event (self, event: MidiEvent) -> None: - - """Push a MidiEvent onto the queue, stamping a FIFO tie-breaker. - - The ``sequence`` field guarantees that events sharing a ``pulse`` - dispatch in insertion order — required for NRPN/RPN bursts and any - future protocol that emits multiple co-scheduled CCs (e.g. Bank - Select before Program Change). - """ - - event.sequence = next(self._event_counter) - heapq.heappush(self.event_queue, event) - - - def _spawn (self, coro: typing.Coroutine) -> None: - - """Fire-and-forget *coro* on the event loop, tracked and exception-safe. - - Retains a strong reference until the task completes (so it cannot be - collected mid-flight) and surfaces any exception via the done-callback — - a bare ``asyncio.create_task`` drops both, silently losing a raising bar - or beat callback. - """ - - task = asyncio.create_task(coro) - self._background_tasks.add(task) - task.add_done_callback(self._reap_background_task) - - - def _reap_background_task (self, task: "asyncio.Task") -> None: - - """Done-callback: drop the reference and log any exception.""" - - self._background_tasks.discard(task) - - if not task.cancelled() and task.exception() is not None: - logger.error("Background task failed", exc_info=task.exception()) - - - async def schedule_pattern (self, pattern: PatternLike, start_pulse: int) -> None: - - """ - Schedules a pattern's notes and CC events into the sequencer's event queue. - - If ``pattern.mirrors`` is non-empty, every note, CC, pitch bend, program - change, SysEx, NRPN/RPN burst, and drone event is duplicated onto each - mirror destination — see the "MIDI mirroring" section of the README. - - **Bandwidth note**: each mirror destination multiplies the per-pattern - event count. A dense pattern with two mirrors emits 3× the original. - For DIN-MIDI hardware on a saturated bus this can matter; over USB or - IAC it is rarely a concern. - - **Tuning + mirrors**: ``CcEvent.channel`` and ``CcEvent.device`` overrides - (used by polyphonic microtonal tuning to rotate notes onto separate - channels) apply to the *primary* destination only. Mirror destinations - always use their own pinned ``(device, channel)`` — i.e. a polyphonic- - tuning pattern mirrored to another synth will collapse all channel - rotations onto the mirror's single channel, losing per-note bend - isolation on that destination. Apply tuning per-pattern if both ends - need it. - """ - - # Primary destination first; mirrors follow. Iteration order matters - # only for human readability when inspecting the queue — FIFO ordering - # at equal pulses is enforced by ``_push_event`` (the ``sequence`` - # tie-breaker on ``MidiEvent``). - mirrors = getattr(pattern, 'mirrors', []) - destinations: typing.List[_MirrorTarget] = [_MirrorTarget(pattern.device, pattern.channel, None)] + [_to_target(entry) for entry in mirrors] - - async with self.queue_lock: - - for position, step in pattern.steps.items(): - - abs_pulse = start_pulse + position - - for note in step.notes: - - for i, target in enumerate(destinations): - - # Resolve the drum name for this destination; None means the - # destination has no voice for it, so it stays silent (no - # note_on AND no note_off — nothing can hang). - note_value = _destination_pitch(note, target, primary = (i == 0)) - if note_value is None: - # A mirror carrying its own map that lacks this named - # voice drops it here by design (faithful-core: silence, - # never a wrong note). Surface it at debug level so - # "why is the clap missing on the sampler?" is answerable - # without guessing — the build-time warning only covers a - # name absent from *every* destination. - if i != 0 and note.origin is not None and target.drum_note_map is not None: - logger.debug( - "Mirror device %d channel %d has no voice for drum '%s' — dropped for this destination", - target.device, target.channel, note.origin, - ) - continue - - # Primary preserves the Note's own channel (so polyphonic - # tuning's per-voice channel rotation lands correctly). - # Mirrors collapse onto the mirror's pinned channel and - # re-resolve the drum name through their own map. - note_channel = note.channel if i == 0 else target.channel - note_device = pattern.device if i == 0 else target.device - - on_event = MidiEvent( - pulse = abs_pulse, - message_type = 'note_on', - channel = note_channel, - note = note_value, - velocity = note.velocity, - device = note_device, - ) - self._push_event(on_event) - - off_event = MidiEvent( - pulse = abs_pulse + note.duration, - message_type = 'note_off', - channel = note_channel, - note = note_value, - velocity = 0, - device = note_device, - ) - self._push_event(off_event) - - # CC / pitch bend / program change / SysEx events - for cc_event in getattr(pattern, 'cc_events', []): - - abs_pulse = start_pulse + cc_event.pulse - - for i, target in enumerate(destinations): - - if i == 0: - # Primary: respect per-event channel/device override if set - # (e.g. tuning pitch bends targeting specific channels). - event_channel = cc_event.channel if cc_event.channel is not None else target.channel - event_device = cc_event.device if cc_event.device is not None else target.device - else: - # Mirror: always use the mirror's pinned (device, channel). - # See class docstring for the tuning interaction note. - event_channel = target.channel - event_device = target.device - - midi_event = MidiEvent( - pulse = abs_pulse, - message_type = cc_event.message_type, - channel = event_channel, - control = cc_event.control, - value = cc_event.value, - data = cc_event.data, - device = event_device, - priority = getattr(cc_event, 'priority', 0), - ) - self._push_event(midi_event) - - # Raw Note On/Off events (drones) - for note_ev in getattr(pattern, 'raw_note_events', []): - - abs_pulse = start_pulse + note_ev.pulse - - for i, target in enumerate(destinations): - - # Same faithful-core rule as step notes: a named drone is - # re-resolved through each mirror's own drum_note_map, and - # a destination lacking the voice stays silent rather than - # sounding the primary device's note number. - note_value = _destination_pitch(note_ev, target, primary = (i == 0)) - if note_value is None: - if i != 0 and note_ev.origin is not None and target.drum_note_map is not None: - logger.debug( - "Mirror device %d channel %d has no voice for drum '%s' — dropped for this destination", - target.device, target.channel, note_ev.origin, - ) - continue - - midi_event = MidiEvent( - pulse = abs_pulse, - message_type = note_ev.message_type, - channel = target.channel, - note = note_value, - velocity = note_ev.velocity, - device = target.device, - ) - self._push_event(midi_event) - - # OSC events — never mirrored. OSC isn't bound to a MIDI port and - # mirroring it would require a different abstraction (multiple OSC - # servers / addresses). If users want to broadcast OSC to several - # endpoints, that belongs in the OSC server config, not here. - for osc_event in getattr(pattern, 'osc_events', []): - - abs_pulse = start_pulse + osc_event.pulse - - osc_midi_event = MidiEvent( - pulse = abs_pulse, - message_type = 'osc', - channel = 0, - data = (osc_event.address, osc_event.args) - ) - - self._push_event(osc_midi_event) - - logger.debug(f"Scheduled pattern at {start_pulse}, queue size: {len(self.event_queue)}") - - - async def schedule_pattern_repeating (self, pattern: PatternLike, start_pulse: int) -> None: - - """ - Schedule a pattern and register it for rescheduling each cycle. - """ - - length_pulses, lookahead_pulses = self._get_pattern_timing(pattern) - - # Anchor the first cycle for window-reading rebuilds (kept current on - # every reschedule in _maybe_reschedule_patterns). - pattern._cycle_start_pulse = start_pulse - - await self.schedule_pattern(pattern, start_pulse) - - next_reschedule_pulse = start_pulse + length_pulses - lookahead_pulses - - scheduled_pattern = ScheduledPattern( - pattern = pattern, - cycle_start_pulse = start_pulse, - length_pulses = length_pulses, - lookahead_pulses = lookahead_pulses, - next_reschedule_pulse = next_reschedule_pulse - ) - - async with self.pattern_lock: - counter = next(self._reschedule_counter) - heapq.heappush(self.reschedule_queue, (scheduled_pattern.next_reschedule_pulse, counter, scheduled_pattern)) - - - async def schedule_callback_repeating (self, callback: typing.Callable[[int], typing.Any], interval_beats: float, start_pulse: int = 0, reschedule_lookahead: float = 1) -> None: - - """ - Schedule a repeating callback on a beat interval. - """ - - interval_pulses, lookahead_pulses = self._get_schedule_timing(interval_beats, reschedule_lookahead) - - # "Backshift" initialization: treat start_pulse as the *target* of the first fire, - # not the start of the first cycle. This ensures callbacks fire `lookahead` before - # start_pulse (often ≤ 0, so they fire immediately when playback begins). - # - # Formula: cycle_start = start_pulse - interval - # first_fire = start_pulse - lookahead - # - # After the first fire the loop advances normally: - # next_start = cycle_start + interval = start_pulse - # next_fire = start_pulse + interval - lookahead - # - # Note: if start_pulse = 0, first_fire is negative, so the callback fires - # at pulse 0 (the very start of playback). Pass start_pulse = interval_pulses - # to skip the initial fire and have the first fire at (interval - lookahead). - # The harmonic clock does this because HarmonicState already holds the tonic. - - initial_cycle_start = start_pulse - interval_pulses - initial_fire_pulse = start_pulse - lookahead_pulses - - scheduled_callback = ScheduledCallback( - callback = callback, - cycle_start_pulse = initial_cycle_start, - interval_pulses = interval_pulses, - lookahead_pulses = lookahead_pulses, - next_fire_pulse = initial_fire_pulse - ) - - async with self.callback_lock: - counter = next(self._callback_counter) - heapq.heappush(self.callback_queue, (scheduled_callback.next_fire_pulse, counter, scheduled_callback)) - - - async def schedule_callback_sequence ( - self, - callback: typing.Callable[[int], typing.Union[typing.Optional[float], typing.Coroutine[typing.Any, typing.Any, typing.Optional[float]]]], - start_pulse: int = 0, - reschedule_lookahead: float = 1, - ) -> None: - - """Schedule a self-rescheduling callback with a variable firing interval. - - Where :meth:`schedule_callback_repeating` fires on a fixed beat - interval, this primitive lets the callback decide each hop: it is - called ``lookahead`` before every *boundary* pulse, receives that - boundary pulse, and returns the number of beats to the **next** - boundary — or ``None`` to stop. Built for clocks that walk irregular - spans, e.g. the harmonic clock under a bound progression's - harmonic rhythm. - - The first fire targets *start_pulse* as its boundary and is due - ``lookahead`` before it (immediately, when that is already past — - the same backshift idiom as the repeating scheduler). - - Parameters: - callback: ``fn(boundary_pulse) -> beats_to_next_boundary | None``. - May be sync or async. - start_pulse: The first boundary pulse. - reschedule_lookahead: How many beats before each boundary the - callback fires. - """ - - lookahead_pulses = int(reschedule_lookahead * self.pulses_per_beat) - - if lookahead_pulses < 0: - raise ValueError("Reschedule lookahead cannot be negative") - - scheduled = ScheduledCallbackSequence( - callback = callback, - boundary_pulse = start_pulse, - lookahead_pulses = lookahead_pulses, - next_fire_pulse = start_pulse - lookahead_pulses, - ) - - async with self.callback_lock: - counter = next(self._callback_counter) - heapq.heappush(self.callback_sequence_queue, (scheduled.next_fire_pulse, counter, scheduled)) - - - async def play (self) -> None: - - """ - Convenience method to start playback and wait for completion. - """ - - await self.start() - - try: - if self.task: - await self.task - except asyncio.CancelledError: - pass - finally: - await self.stop() - - - def _send_clock_message (self, message_type: str) -> None: - - """Send a bare MIDI system-realtime message (clock, start, stop, continue). - - These messages carry no channel or data bytes — they are sent directly to - the output port. Used for MIDI clock output when ``clock_output`` is True. - - Parameters: - message_type: One of ``"clock"``, ``"start"``, ``"stop"``, ``"continue"``. - """ - - for port in self._output_devices: - try: - self._locked_send(port, mido.Message(message_type)) - except Exception: - logger.exception(f"Failed to send MIDI {message_type} message") - - - async def start (self) -> None: - - """Start the sequencer playback in a separate asyncio task. - - When an input device is configured, the MIDI input port is opened here - (after the event loop is running) so that call_soon_threadsafe works. - When ``clock_output`` is True, a MIDI Start (0xFA) message is sent before - the first clock tick so connected hardware begins from the top. - """ - - if self.running: - return - - # Set up MIDI input queue before opening the port. - self._open_midi_inputs() - - # Store the event loop for thread-safe scheduling (e.g., trigger() from user threads) - self._event_loop = asyncio.get_running_loop() - - self._waiting_for_start = self.clock_follow - self.running = True - self.task = asyncio.create_task(self._run_loop()) - - if self.clock_output: - self._send_clock_message("start") - - logger.info("Sequencer started") - - await self.events.emit_async("start") - - - async def stop (self) -> None: - - """ - Stop the sequencer playback and cleanup resources. - """ - - if not self.running and not self._output_devices: - return - - logger.info("Stopping sequencer...") - - self.running = False - - if self.task: - try: - await self.task - except asyncio.CancelledError: - # The loop task was cancelled (the Ctrl-C path) — since - # Python 3.8 CancelledError is a BaseException, so a bare - # `except Exception` missed it and the whole cleanup below - # was skipped. - logger.info("Sequencer loop task cancelled - continuing shutdown") - except Exception: - # A crashed loop must not abort shutdown - the cleanup below - # (pending-send cancellation, panic, port close, recording - # save) is exactly what a dying session needs most. - logger.exception("Sequencer loop task ended with an exception - continuing shutdown") - - # Cancel any latency-compensation deferrals still in flight. Must happen - # after the loop has stopped producing (await self.task) and before - # close_all() so a pending call_later can't fire on a closed port. - # panic() below is the silence authority for any note stranded by this. - self._cancel_pending_sends() - - if self.clock_output: - self._send_clock_message("stop") - - await self.panic() - - self._output_devices.close_all() - self._input_devices.close_all() - - # Leave the Ableton Link session cleanly if one was active, rather than - # letting the socket linger in the session until process exit. - if self._link_clock is not None: - try: - self._link_clock.disable() - except Exception: - logger.exception("Failed to disable Ableton Link clock") - - self._midi_input_queue = None - self._input_loop = None - - self.save_recording() - - logger.info("Sequencer stopped") - - async with self.pattern_lock: - self.reschedule_queue = [] - self._reschedule_counter = itertools.count() - - async with self.callback_lock: - self.callback_queue = [] - self.callback_sequence_queue = [] - self._callback_counter = itertools.count() - - # Note: ``_event_counter`` is intentionally NOT reset here. The two - # counters above pair with queues that we do clear, so resetting them - # is symmetric. ``self.event_queue`` is left to drain naturally and - # may carry stale items into a restart; resetting ``_event_counter`` - # alongside an un-cleared queue would let a fresh push (sequence=0) - # sort ahead of a stale push (sequence=N) at the same pulse. Since - # pulse is the primary heap key and the counter never overflows, we - # just let it keep counting forever. - - self.active_notes = set() - - await self.events.emit_async("stop") - - - async def _run_loop (self) -> None: - - """Main playback loop - delegates to internal, external, or Link clock mode.""" - - self.start_time = time.perf_counter() - self.pulse_count = 0 - self.current_bar = -1 - - pulses_per_bar = self.time_signature[0] * self.pulses_per_beat - - if self.clock_follow and self._midi_input_queue is not None: - await self._run_loop_external_clock(pulses_per_bar) - elif self._link_clock is not None: - await self._run_loop_link_clock(self._link_clock, pulses_per_bar) - else: - await self._run_loop_internal_clock(pulses_per_bar) - - - def _check_bar_change (self, pulse: int, pulses_per_bar: int) -> None: - - """Detect bar boundaries and fire bar callbacks + events.""" - - new_bar = pulse // pulses_per_bar - - if new_bar > self.current_bar: - self.current_bar = new_bar - - # In render mode, stop after the requested number of bars. - if self.render_mode and self.render_bars > 0 and self.current_bar >= self.render_bars: - self.running = False - return - - self._spawn(self.events.emit_async("bar", self.current_bar)) - - - def _check_beat_change (self, pulse: int, pulses_per_beat: int) -> None: - - """Detect beat boundaries within the bar and fire beat events.""" - - beat_in_bar = (pulse % (self.time_signature[0] * pulses_per_beat)) // pulses_per_beat - - if beat_in_bar != self.current_beat: - self.current_beat = beat_in_bar - self._spawn(self.events.emit_async("beat", self.current_beat)) - - - async def _advance_pulse (self) -> None: - - """Reschedule patterns, process events, and increment the pulse counter.""" - - if self._bpm_transition is not None: - self._bpm_transition.elapsed_pulses += 1 - - if self._bpm_transition.elapsed_pulses >= self._bpm_transition.total_pulses: - target = self._bpm_transition.target_bpm - self._bpm_transition = None - self.set_bpm(target) - else: - progress = self._bpm_transition.elapsed_pulses / self._bpm_transition.total_pulses - eased = self._bpm_transition.easing_fn(progress) - interpolated = ( - self._bpm_transition.start_bpm + - (self._bpm_transition.target_bpm - self._bpm_transition.start_bpm) * eased - ) - self.current_bpm = interpolated - self.seconds_per_beat = 60.0 / self.current_bpm - self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat - - # Keep the recording's tempo map honest: without these events - # a recorded ramp plays back at the pre-ramp tempo and jumps. - if self.recording: - self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(interpolated))) - - # Accumulate simulated time and enforce the render time cap. - if self.render_mode: - self._render_elapsed_seconds += self.seconds_per_pulse - if ( - self.render_max_seconds is not None - and self._render_elapsed_seconds >= self.render_max_seconds - ): - max_min = self.render_max_seconds / 60.0 - logger.warning( - f"Render stopped at {max_min:.1f}-minute safety limit. " - f"Pass max_minutes=None with an explicit bars= count to remove this limit." - ) - self.running = False - return - - # Drain buffered note input into the held-note tracker BEFORE patterns - # rebuild, so a pattern's held_notes() snapshot reflects this pulse rather - # than the previous one. deque.popleft() is GIL-atomic; the tracker is - # only ever touched on this loop thread (here and during rebuild). - if self._held_notes is not None: - while self._note_input_buffer: - is_on, pitch, velocity, when = self._note_input_buffer.popleft() - if is_on: - self._held_notes.note_on(pitch, velocity, when) - else: - self._held_notes.note_off(pitch, when) - - await self._maybe_reschedule_patterns(self.pulse_count) - await self._process_pulse(self.pulse_count) - self.pulse_count += 1 - - - async def _run_loop_internal_clock (self, pulses_per_bar: int) -> None: - - """Playback loop driven by the internal wall clock. - - In normal mode the loop sleeps between pulses to maintain tempo. - In render mode it runs as fast as possible (simulates time rather than - waiting for the wall clock), stopping after *render_bars* bars. - """ - - next_pulse_time = self.start_time - - while self.running: - - # In render mode, simulate time advancing one pulse at a time so - # the inner loop always fires exactly once without spin-waiting. - current_time = next_pulse_time if self.render_mode else time.perf_counter() - - while current_time >= next_pulse_time: - # Ordering within each pulse: - # 1. _check_bar/beat_change() — update counters and queue "bar"/"beat" - # event tasks (asyncio.create_task; not run yet). - # 2. Send MIDI clock tick (if clock_output) so hardware receives it at - # the same time as note events for tight sync. - # 3. _advance_pulse() — fire callbacks, then send MIDI via _process_pulse(). - # 4. After the await returns, the event loop runs the queued event tasks, - # which update the terminal display. - # - # Consequence: MIDI notes are sent *before* the display updates. The display - # always trails the audio by roughly one pulse-processing cycle plus any - # terminal rendering latency (~10-50 ms). This is expected and acceptable for - # a visual status line — it cannot be tightened without restructuring the loop. - self._check_bar_change(self.pulse_count, pulses_per_bar) - - if not self.running: - # A render bar-limit trips inside _check_bar_change; stop before - # dispatching this pulse so the first beat of the next (unrendered) - # bar never leaks into the recording. - break # type: ignore[unreachable] - - self._check_beat_change(self.pulse_count, self.pulses_per_beat) - if self.clock_output: - self._send_clock_message("clock") - await self._advance_pulse() - next_pulse_time += self.seconds_per_pulse - - if not self.running: - break # type: ignore[unreachable] - - if not self.running: - break # type: ignore[unreachable] - - if not self.render_mode: - # Check if queue is empty and we are past the last event. - # Skipped in benchmark mode (_jitter_log set) so the clock - # runs until explicitly cancelled via asyncio. - if self._jitter_log is None: - async with self.queue_lock: - if (not self.event_queue and not self.active_notes - and not self.reschedule_queue and not self.callback_queue - and not self.callback_sequence_queue): - logger.info("Sequence complete (no more events or active notes).") - self.running = False - break - - sleep_time = next_pulse_time - time.perf_counter() - - if sleep_time > 0: - if self._spin_wait and sleep_time > self._spin_threshold: - # Sleep to within _spin_threshold of the target, then busy-wait - # for the remaining sub-millisecond. Trades ~1ms of CPU spin per - # pulse for significantly tighter timing than asyncio.sleep alone. - await asyncio.sleep(sleep_time - self._spin_threshold) - while time.perf_counter() < next_pulse_time: - pass - else: - await asyncio.sleep(sleep_time) - - if self._jitter_log is not None: - self._jitter_log.append(time.perf_counter() - next_pulse_time) - else: - # Yield to the event loop so queued tasks (pattern rescheduling, - # asyncio.create_task callbacks) can run between pulses. - await asyncio.sleep(0) - - - async def _run_loop_external_clock (self, pulses_per_bar: int) -> None: - - """Playback loop driven by incoming MIDI clock messages. - - Each MIDI ``clock`` tick advances exactly one pulse (24 ppqn = internal ppqn). - Transport messages (``start``, ``stop``, ``continue``) control sequencer state. - The loop waits for a ``start`` or ``continue`` before advancing pulses, - but still uses incoming ticks to estimate BPM for display. - """ - - assert self._midi_input_queue is not None, "MIDI input queue must be initialized for external clock" - - while self.running: - - try: - device_idx, message = await asyncio.wait_for( - self._midi_input_queue.get(), timeout=2.0 - ) - except asyncio.TimeoutError: - continue - - if device_idx != self.clock_device_idx: - continue - - if message.type == "clock": - - # Prime BPM estimation while waiting for start/continue, - # but do not advance pulses or schedule events yet. - if self._waiting_for_start: - self._estimate_bpm(time.perf_counter()) - continue - - self._estimate_bpm(time.perf_counter()) - self._check_bar_change(self.pulse_count, pulses_per_bar) - self._check_beat_change(self.pulse_count, self.pulses_per_beat) - await self._advance_pulse() - - elif message.type == "start": - logger.info("MIDI start received") - self.pulse_count = 0 - self.current_bar = -1 - self.current_beat = -1 - self._waiting_for_start = False - - elif message.type == "stop": - logger.info("MIDI stop received") - self.running = False - - elif message.type == "continue": - logger.info("MIDI continue received") - self._waiting_for_start = False - - - async def _run_loop_link_clock (self, link_clock: typing.Any, pulses_per_bar: int) -> None: - - """Playback loop driven by Ableton Link beat clock. - - Uses ``link_clock.sync(beat)`` as the timing primitive: for each pulse N - we wait until the Link session beat reaches ``beat_origin + N / PPQN``. - This gives accurate, phase-locked timing at 24 PPQN with typical jitter - of ~0.3–0.5 ms (dominated by asyncio/OS scheduling overhead). - - Starts on the next quantum boundary so that bar 0 aligns with all other - Link participants in the session. - """ - - logger.info("Ableton Link clock mode: waiting for bar boundary…") - - # Wait for the next quantum boundary (bar start) for a clean, phase-locked start. - beat_origin = await link_clock.wait_for_bar() - - logger.info(f"Ableton Link: started at beat {beat_origin:.3f} (tempo={link_clock.tempo:.1f} BPM, peers={link_clock.num_peers})") - - # Reset pulse counter here so bar/beat tracking starts from 0 at beat_origin. - self.pulse_count = 0 - self.current_bar = -1 - self.current_beat = -1 - - while self.running: - - # Compute the Link beat corresponding to the current pulse. - target_beat = beat_origin + self.pulse_count / self.pulses_per_beat - - # Wait for Link to reach that beat (this is the timing gate). - await link_clock.sync(target_beat) - - # Update local tempo from the Link session — propagates network BPM changes. - link_bpm = link_clock.tempo - if abs(link_bpm - self.current_bpm) > 0.01: - # Record the change so a recorded session's .mid plays back - # at the Link session tempo (mirrors the external-clock path). - if self.recording: - self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(link_bpm))) - self.current_bpm = link_bpm - self.seconds_per_beat = 60.0 / self.current_bpm - self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat - logger.debug(f"Link tempo update: {link_bpm:.2f} BPM") - - self._check_bar_change(self.pulse_count, pulses_per_bar) - self._check_beat_change(self.pulse_count, self.pulses_per_beat) - - if self.clock_output: - self._send_clock_message("clock") - - await self._advance_pulse() - - # Stop when all events are exhausted (same check as internal clock). - async with self.queue_lock: - if (not self.event_queue and not self.active_notes - and not self.reschedule_queue and not self.callback_queue - and not self.callback_sequence_queue): - logger.info("Sequence complete (no more events or active notes).") - self.running = False - break - - - async def _maybe_reschedule_patterns (self, pulse: int) -> None: - - """ - Reschedule repeating callbacks and patterns when they reach their lookahead threshold. - """ - - to_fire: typing.List[ScheduledCallback] = [] - to_reschedule: typing.List[ScheduledPattern] = [] - - async with self.callback_lock: - - while self.callback_queue and self.callback_queue[0][0] <= pulse: - - _, _, scheduled_callback = heapq.heappop(self.callback_queue) - - next_start_pulse = scheduled_callback.cycle_start_pulse + scheduled_callback.interval_pulses - scheduled_callback.cycle_start_pulse = next_start_pulse - scheduled_callback.next_fire_pulse = next_start_pulse + scheduled_callback.interval_pulses - scheduled_callback.lookahead_pulses - - to_fire.append(scheduled_callback) - - if to_fire: - # Decision path: composition-level callbacks fire before pattern rebuilds. - for scheduled_callback in to_fire: - try: - result = scheduled_callback.callback(pulse) - - if asyncio.iscoroutine(result): - await result - - except Exception: - # Isolate a misbehaving callback so the rest still fire and - # get rescheduled below — one bad callback must not stall the clock. - logger.exception("Scheduled callback failed during reschedule (pulse %d) - continuing", pulse) - - async with self.callback_lock: - for scheduled_callback in to_fire: - counter = next(self._callback_counter) - heapq.heappush(self.callback_queue, (scheduled_callback.next_fire_pulse, counter, scheduled_callback)) - - # Variable-interval sequences fire after the fixed callbacks at the - # same pulse (the form clock is fixed; the harmonic span clock is a - # sequence — form-before-harmony ordering is preserved) and before - # pattern rebuilds below. - sequences_to_fire: typing.List[ScheduledCallbackSequence] = [] - - async with self.callback_lock: - - while self.callback_sequence_queue and self.callback_sequence_queue[0][0] <= pulse: - _, _, scheduled_sequence = heapq.heappop(self.callback_sequence_queue) - sequences_to_fire.append(scheduled_sequence) - - requeue: typing.List[ScheduledCallbackSequence] = [] - - for scheduled_sequence in sequences_to_fire: - - try: - result = scheduled_sequence.callback(scheduled_sequence.boundary_pulse) - - if asyncio.iscoroutine(result): - result = await result - - except Exception: - # Isolate a misbehaving callback so the clock survives; the - # sequence is dropped — with no interval there is no next hop. - logger.exception("Callback sequence failed (pulse %d) - sequence stopped", pulse) - continue - - if result is None: - continue # the sequence chose to stop - - interval_pulses = max(1, int(float(result) * self.pulses_per_beat)) - scheduled_sequence.boundary_pulse += interval_pulses - scheduled_sequence.next_fire_pulse = max( - pulse + 1, - scheduled_sequence.boundary_pulse - scheduled_sequence.lookahead_pulses, - ) - requeue.append(scheduled_sequence) - - if requeue: - async with self.callback_lock: - for scheduled_sequence in requeue: - counter = next(self._callback_counter) - heapq.heappush(self.callback_sequence_queue, (scheduled_sequence.next_fire_pulse, counter, scheduled_sequence)) - - async with self.pattern_lock: - - while self.reschedule_queue and self.reschedule_queue[0][0] <= pulse: - - _, _, scheduled_pattern = heapq.heappop(self.reschedule_queue) - - # Lazy pattern removal: if Composition.unregister() set the - # ``_removed`` flag on this pattern, skip both the rebuild and - # the re-push so it disappears from rotation. Already-queued - # events in event_queue play out; sustaining notes were stopped - # by unregister() via _stop_pattern_notes(). - if getattr(scheduled_pattern.pattern, '_removed', False): - continue - - next_start_pulse = scheduled_pattern.cycle_start_pulse + scheduled_pattern.length_pulses - scheduled_pattern.cycle_start_pulse = next_start_pulse - - # Anchor the upcoming cycle on the pattern itself, BEFORE - # on_reschedule() below — rebuilds read it to place the cycle - # on the absolute beat axis (the harmony window's axis). - scheduled_pattern.pattern._cycle_start_pulse = next_start_pulse - - to_reschedule.append(scheduled_pattern) - - if to_reschedule: - # Decision path: update shared composition state before pattern rebuilds. - patterns = [scheduled_pattern.pattern for scheduled_pattern in to_reschedule] - - try: - await self.events.emit_async("reschedule_pulse", pulse, patterns) - except Exception: - logger.exception("reschedule_pulse listener failed (pulse %d) - continuing", pulse) - - for scheduled_pattern in to_reschedule: - - # Containment: a failing rebuild — or an invalid set_length() that - # makes _get_pattern_timing raise — must cost this pattern its - # cycle, never the clock. On failure the pattern keeps its - # previous timing and is re-queued below for another try. - try: - scheduled_pattern.pattern.on_reschedule() - - # Re-read length in case on_reschedule() changed it (e.g. via set_length). - new_length_pulses, new_lookahead_pulses = self._get_pattern_timing(scheduled_pattern.pattern) - scheduled_pattern.length_pulses = new_length_pulses - scheduled_pattern.lookahead_pulses = new_lookahead_pulses - scheduled_pattern.next_reschedule_pulse = scheduled_pattern.cycle_start_pulse + new_length_pulses - new_lookahead_pulses - - await self.schedule_pattern(scheduled_pattern.pattern, scheduled_pattern.cycle_start_pulse) - - except Exception: - logger.exception("Pattern reschedule failed - pattern is silent this cycle and keeps its previous timing") - scheduled_pattern.next_reschedule_pulse = scheduled_pattern.cycle_start_pulse + scheduled_pattern.length_pulses - scheduled_pattern.lookahead_pulses - - self._spawn(self.events.emit_async("pattern_reschedule", scheduled_pattern.pattern, scheduled_pattern.cycle_start_pulse)) - - async with self.pattern_lock: - for scheduled_pattern in to_reschedule: - counter = next(self._reschedule_counter) - heapq.heappush(self.reschedule_queue, (scheduled_pattern.next_reschedule_pulse, counter, scheduled_pattern)) - - - async def _process_pulse (self, pulse: int) -> None: - - """ - Process and execute all events for a specific pulse. - """ - - async with self.queue_lock: - - # Drain queued CC forwards into the event heap. - # deque.popleft() is GIL-atomic; safe to call from the event loop thread - # while the callback thread calls append(). - while self._forward_buffer: - fwd_pulse, fwd_msg, fwd_device = self._forward_buffer.popleft() - self._push_event(MidiEvent.from_mido(fwd_pulse, fwd_msg, device=fwd_device)) - - while self.event_queue and self.event_queue[0].pulse <= pulse: - - event = heapq.heappop(self.event_queue) - - # Defensive: dispatching a single malformed event must not - # crash the sequencer loop and stop the whole composition. - # A bad event (e.g. a tuple stored on Note.velocity by a - # misuse of a builder method) is logged with full context - # and skipped; subsequent events continue normally. - try: - - # Track active notes (keyed by device, channel, note) - if event.message_type == 'note_on' and event.velocity > 0: - self.active_notes.add((event.device, event.channel, event.note)) - elif event.message_type == 'note_off' or (event.message_type == 'note_on' and event.velocity == 0): - if (event.device, event.channel, event.note) in self.active_notes: - self.active_notes.remove((event.device, event.channel, event.note)) - - # Send events at or before the current pulse (late events are sent - # immediately). Latency compensation may defer the actual send by - # the device's offset, but recording below always uses the LOGICAL - # pulse — the .mid is the uncompensated score. - self._dispatch_with_compensation(event) - - if self.recording and event.message_type != 'osc': - - mido_msg = event.to_mido() - if mido_msg is not None: - self._record_event(event.pulse, mido_msg) - - except Exception: - - logger.exception( - "Failed to dispatch %s event at pulse %d (device=%s, channel=%s) — skipping", - event.message_type, event.pulse, event.device, event.channel - ) - - - async def _stop_all_active_notes (self) -> None: - - """ - Send note_off for all currently tracked active notes. - """ - - async with self.queue_lock: - for dev, channel, note in list(self.active_notes): - port = self._output_devices.get(dev) - if port is not None: - try: - self._locked_send(port, mido.Message('note_off', channel=channel, note=note, velocity=0)) - except Exception: - logger.exception("Failed to send note_off during stop") - self.active_notes.clear() - - - async def _stop_pattern_notes (self, pattern: PatternLike) -> None: - - """Send note_off for active notes belonging to a single pattern. - - Targets the pattern's primary ``(device, channel)`` plus every entry - in ``pattern.mirrors``, so patterns with mirrors have their notes - stopped on every output port they fan out to. Used by - ``Composition.unregister()`` to flush drones and any sustaining - notes when a pattern is being torn down. - """ - - mirrors = getattr(pattern, 'mirrors', []) - # Build the target set from each entry's (device, channel) prefix — a - # 3-tuple mirror carries a dict (drum_note_map) and would be unhashable. - targets: typing.Set[typing.Tuple[int, int]] = {(pattern.device, pattern.channel)} | {(e[0], e[1]) for e in mirrors} - - async with self.queue_lock: - - stranded = [t for t in self.active_notes if (t[0], t[1]) in targets] - - for dev, channel, note in stranded: - # Route through latency compensation, NOT straight to the - # port: a note_on for this device may still be deferred in - # _pending_sends, and an immediate note_off would overtake it, - # leaving the note stuck ringing (drones have no later - # note_off to rescue them). The shared per-device offset - # preserves on→off order. - try: - self._dispatch_with_compensation(MidiEvent( - pulse = self.pulse_count, - message_type = 'note_off', - channel = channel, - note = note, - velocity = 0, - device = dev, - )) - except Exception: - logger.exception(f"Failed to send note_off during unregister (dev={dev}, ch={channel}, note={note})") - self.active_notes.discard((dev, channel, note)) - - - def _send_offset_seconds (self, device: int) -> float: - - """Return the latency-compensation send offset (seconds) for *device*. - - ``(max_latency − device_latency) / 1000``, clamped ≥ 0. The slowest - device returns 0 (sent at logical time); faster devices return a - positive delay so they sound together. - - Invariant: the offset is **per-device**, so every event for one device - shares it. That is what preserves same-pulse FIFO order through - deferral — an NRPN burst (CC 99 → 98 → 6 → 38) on one device stays in - order because all four are deferred by the same amount. A future - per-channel/per-message latency would break that and must not be added - without re-thinking burst ordering. - """ - - offset_ms = self._max_device_latency_ms - self._output_devices.latency_of(device) - if offset_ms <= 0.0: - return 0.0 - return offset_ms / 1000.0 - - def _dispatch_with_compensation (self, event: MidiEvent) -> None: - - """Send *event* now, or defer it by its device's latency offset. - - Deferral is a wall-clock ``call_later`` so it is correct regardless of - tempo or clock source. Skipped entirely in render mode (no real clock - — deferring would drop events from the rendered file) and when no event - loop is running (the synchronous test path). - """ - - if self.render_mode or self._event_loop is None: - self._send_midi(event) - return - - offset_s = self._send_offset_seconds(event.device) - if offset_s <= 0.0: - self._send_midi(event) - return - - # Defer the physical send. The one-element ``cell`` lets the callback - # discard exactly its own handle from _pending_sends (the handle isn't - # known until call_later returns, but _fire only runs after we append). - cell: typing.List[asyncio.TimerHandle] = [] - - def _fire () -> None: - self._pending_sends.discard(cell[0]) - self._send_midi(event) - - handle = self._event_loop.call_later(offset_s, _fire) - cell.append(handle) - self._pending_sends.add(handle) - - def _cancel_pending_sends (self) -> None: - - """Cancel and forget all in-flight deferred sends. Idempotent. - - Called during ``stop()`` before ports close so a pending ``call_later`` - can never fire ``port.send()`` on a closed port. Stranded notes are not - a concern here: ``panic()`` runs after this and is the silence - authority (``active_notes`` reflects logical time and can diverge from - what physically fired, so it is not relied upon). - """ - - for handle in self._pending_sends: - handle.cancel() - self._pending_sends.clear() - - def _locked_send (self, port: typing.Any, message: typing.Any) -> None: - - """Write one message to a port under the send lock. - - The lock keeps the loop thread and the instant cc_forward callback - thread from interleaving bytes on the same port. - """ - - with self._port_send_lock: - port.send(message) - - def _send_midi (self, event: MidiEvent) -> None: - - """ - Send a MIDI message to the appropriate output device. - """ - - port = self._output_devices.get(event.device) - if port is not None: - - try: - - if event.message_type == 'osc': - if self.osc_server is not None: - address, args = event.data - self.osc_server.send(address, *args) - return - - msg = event.to_mido() - if msg is None: - return - - self._locked_send(port, msg) - - except Exception: - logger.exception("MIDI send failed (device may be disconnected)") - - - async def panic (self) -> None: - - """ - Send a MIDI panic message to all channels. - """ - - logger.info("Panic: sending all notes off.") - - # 1. Stop all tracked active notes manually - await self._stop_all_active_notes() - - for port in self._output_devices: - - try: - - # Hold the send lock so an instant cc_forward on the callback - # thread cannot interleave with the panic sweep. - with self._port_send_lock: - - # 2. Send "All Notes Off" (CC 123) and "All Sound Off" (CC 120) to all 16 channels - for channel in range(16): - port.send(mido.Message('control_change', channel=channel, control=123, value=0)) - port.send(mido.Message('control_change', channel=channel, control=120, value=0)) - - # 3. Use built-in panic and reset - port.panic() - - # Note: reset() might close/reopen ports or clear internal buffers depending on backend, - # but mido docs say it sends "All Notes Off" and "Reset All Controllers". - port.reset() - - except Exception: - logger.exception("MIDI panic failed (device may be disconnected)") + """ + The engine that drives Subsequence timing and MIDI output. + + The ``Sequencer`` maintains a stable clock (internal or external), + handles the scheduling of MIDI events, and triggers pattern rebuilds. + """ + + def __init__( + self, + output_device_name: typing.Optional[str] = None, + initial_bpm: float = 125, + time_signature: typing.Tuple[int, int] = (4, 4), + input_device_name: typing.Optional[str] = None, + clock_follow: bool = False, + clock_output: bool = False, + record: bool = False, + record_filename: typing.Optional[str] = None, + spin_wait: bool = True, + _jitter_log: typing.Optional[typing.List[float]] = None, + ) -> None: + """Initialize the sequencer with MIDI devices and initial BPM. + + Parameters: + output_device_name: MIDI output device name. When omitted, auto-discovers + available devices - uses the only device if one is found, or prompts + the user to choose if multiple are available. + initial_bpm: Tempo in BPM (ignored when clock_follow is True) + input_device_name: Optional MIDI input device name for clock/transport + clock_follow: When True, follow external MIDI clock instead of internal clock + clock_output: When True, send MIDI timing clock (0xF8), start (0xFA), and + stop (0xFC) messages so connected hardware can sync to Subsequence's + tempo. Mutually exclusive with ``clock_follow`` (ignored when both + are set, to prevent feedback loops). + record: When True, record all MIDI events to a file. + record_filename: Optional filename for the recording (defaults to timestamp). + spin_wait: When True (default), use a hybrid sleep+spin strategy for the + final sub-millisecond of each pulse interval. This significantly + reduces clock jitter at the cost of ~1–5% extra CPU. Set to False + to use pure ``asyncio.sleep()`` (lower CPU, higher jitter). + _jitter_log: Optional list to append per-pulse jitter values (seconds) + to during playback. Intended for the clock jitter benchmark — not + for general use. + """ + + if clock_follow and input_device_name is None: + raise ValueError("clock_follow=True requires an input_device_name") + + self.output_device_name = output_device_name + self.input_device_name = input_device_name + self.time_signature = time_signature + self.clock_follow = clock_follow + self.clock_device_idx: int = 0 + self.clock_output = clock_output and not clock_follow + self.pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE + + # Recording state + self.recording = record + self.record_filename = record_filename + self.recorded_events: typing.List[ + typing.Tuple[float, typing.Union[mido.Message, mido.MetaMessage]] + ] = [] + + # Render mode: run as fast as possible and stop after render_bars or render_max_seconds. + # Both limits are optional — at least one must be set (enforced in Composition.render). + self.render_mode: bool = False + self.render_bars: int = 0 # 0 = no bar limit + self.render_max_seconds: typing.Optional[float] = None # None = no time limit + self._render_elapsed_seconds: float = 0.0 + + # CC input mapping — populated from Composition.cc_map() + self.cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] + # CC forwarding — populated from Composition.cc_forward() + self.cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] + # Buffer for queued CC forwards: deque of (pulse, mido.Message) tuples. + # Appended on the mido callback thread; drained on the event loop thread. + self._forward_buffer: collections.deque = collections.deque() + # Shared reference to composition.data so CC mappings can update it + self._composition_data: typing.Dict[str, typing.Any] = {} + + # Held-note input — populated from Composition.note_input(). + # The tracker (created in _run when note_input was declared) and a buffer + # of raw (is_on, pitch, velocity, perf_counter) note events. The buffer is + # appended on the mido callback thread and drained on the loop thread in + # _advance_pulse, so all tracker state stays single-threaded — no lock. + self._held_notes: typing.Optional[subsequence.held_notes.HeldNotes] = None + self._note_input_buffer: collections.deque = collections.deque() + self._note_input_channel: typing.Optional[int] = None # None = any channel + self._note_input_device: typing.Optional[int] = None # None = any input device + + # Ableton Link clock — set by Composition._run() when comp.link() was called. + # Must be initialized before set_bpm() is called below. + self._link_clock: typing.Optional[typing.Any] = None + + # Multi-device MIDI port registries. + # Output device 0 is always the primary/default device. + self._output_devices: subsequence.midi_utils.MidiDeviceRegistry = ( + subsequence.midi_utils.MidiDeviceRegistry() + ) + self._input_devices: subsequence.midi_utils.MidiDeviceRegistry = ( + subsequence.midi_utils.MidiDeviceRegistry() + ) + + # Internal state initialization (needed before set_bpm) + self._midi_input_queue: typing.Optional[asyncio.Queue] = None + self._input_loop: typing.Optional[asyncio.AbstractEventLoop] = None + self._event_loop: typing.Optional[asyncio.AbstractEventLoop] = None + self._clock_tick_times: typing.List[float] = [] + self._waiting_for_start: bool = False + + self.event_queue: typing.List[MidiEvent] = [] + self.task: typing.Optional[asyncio.Task] = None + self.start_time = 0.0 + self.pulse_count = 0 + self.current_bar: int = -1 + self.current_beat: int = -1 + self.active_notes: typing.Set[typing.Tuple[int, int, int]] = ( + set() + ) # (device, channel, note) + + # Device latency compensation: cached max across all output devices, and + # the set of in-flight deferred sends (call_later handles) awaiting their + # per-device offset. See _dispatch_with_compensation() and stop(). + self._max_device_latency_ms: float = 0.0 + self._pending_sends: typing.Set[asyncio.TimerHandle] = set() + + # Strong references to fire-and-forget bar/beat/event tasks. Without + # retaining them asyncio holds only a weak reference and the task can be + # garbage-collected mid-flight; the done-callback also surfaces exceptions + # that a bare create_task would otherwise swallow until GC. + self._background_tasks: typing.Set[asyncio.Task] = set() + + self.queue_lock = asyncio.Lock() + self.pattern_lock = asyncio.Lock() + self.reschedule_queue: typing.List[ + typing.Tuple[int, int, ScheduledPattern] + ] = [] + self._reschedule_counter = itertools.count() + self.events = subsequence.event_emitter.EventEmitter() + self.callback_lock = asyncio.Lock() + self.callback_queue: typing.List[typing.Tuple[int, int, ScheduledCallback]] = [] + self._callback_counter = itertools.count() + + # Variable-interval callback sequences share callback_lock; they fire + # after the fixed callbacks at the same pulse (see + # _maybe_reschedule_patterns), preserving form-before-harmony ordering. + self.callback_sequence_queue: typing.List[ + typing.Tuple[int, int, ScheduledCallbackSequence] + ] = [] + + # FIFO tie-breaker for same-pulse MidiEvents in event_queue. Without + # it, ``heapq`` ordering of equal-pulse events is undefined, which + # breaks NRPN/RPN bursts (CC 99 → 98 → 6 → 38 must stay in order). + self._event_counter = itertools.count() + + # Serialises actual port writes. Every send runs on the event-loop + # thread EXCEPT instant-mode cc_forward, which fires on the mido input + # callback thread — without this lock the two threads could interleave + # bytes on the same port and corrupt a multi-byte message. + self._port_send_lock = threading.Lock() + + self.data: typing.Dict[str, typing.Any] = {} + + # Timing variables + self.current_bpm: float = 0 + self.seconds_per_beat = 0.0 + self.seconds_per_pulse = 0.0 + self.running = False + self._bpm_transition: typing.Optional[BpmTransition] = None + self._spin_wait: bool = spin_wait + # Spin threshold: sleep all the way to this many seconds before the target, + # then busy-wait for the remainder. 1ms is enough to absorb OS wakeup latency + # while keeping spin time short enough not to starve the event loop. + self._spin_threshold: float = 0.001 + self._jitter_log: typing.Optional[typing.List[float]] = _jitter_log + + self.set_bpm(initial_bpm) + + self._init_midi_output() + + # OSC server reference — set by Composition after osc_server.start() + self.osc_server: typing.Optional[typing.Any] = None + + # ------------------------------------------------------------------ + # Backward-compatible properties: midi_out / midi_in + # External code and tests may reference these directly. They always + # resolve to device 0 of the respective registry. + # ------------------------------------------------------------------ + + @property + def midi_out(self) -> typing.Optional[typing.Any]: + """Return the primary output port (device 0), or None.""" + return self._output_devices.get(0) + + @midi_out.setter + def midi_out(self, value: typing.Optional[typing.Any]) -> None: + """Allow test code to inject a fake output port as device 0.""" + if value is None: + return + if len(self._output_devices) == 0: + self._output_devices.add("default", value) + else: + self._output_devices.replace(0, value) + + @property + def midi_in(self) -> typing.Optional[typing.Any]: + """Return the primary input port (device 0), or None.""" + return self._input_devices.get(0) + + @midi_in.setter + def midi_in(self, value: typing.Optional[typing.Any]) -> None: + """Allow test code to inject a fake input port as device 0.""" + if value is None: + return + if len(self._input_devices) == 0: + self._input_devices.add("default", value) + else: + self._input_devices.replace(0, value) + + def add_output_device( + self, name: str, port: typing.Any, latency_ms: float = 0.0 + ) -> int: + """Register an additional output device. Returns the device index. + + *latency_ms* is the device's physical output latency for compensation + (see :meth:`set_device_latency`). + """ + idx = self._output_devices.add(name, port, latency_ms) + self._max_device_latency_ms = self._output_devices.max_latency() + return idx + + def add_input_device(self, name: str, port: typing.Any) -> int: + """Register an additional input device. Returns the device index.""" + return self._input_devices.add(name, port) + + def set_device_latency( + self, device: subsequence.midi_utils.DeviceId, latency_ms: float + ) -> None: + """Set an output device's physical latency (ms) for delay compensation. + + Latency is normalised engine-wide: the slowest device plays at its + logical time and every faster device's output is deferred by + ``max_latency − its_latency`` so all devices sound together. See + :meth:`_dispatch_with_compensation`. + + Parameters: + device: Output device id (int index, name str, or None for device 0). + latency_ms: Non-negative physical output latency in milliseconds. + Raises ``ValueError`` if negative or the device is unknown. + """ + + self._output_devices.set_latency(device, latency_ms) + self._max_device_latency_ms = self._output_devices.max_latency() + + def _record_event( + self, pulse: int, message: typing.Union[mido.Message, mido.MetaMessage] + ) -> None: + """Record a MIDI message with an absolute pulse timestamp for later export.""" + + if not self.recording: + return + + self.recorded_events.append((float(pulse), message)) + + def save_recording(self) -> None: + """Save the recorded session to a MIDI file.""" + + if not self.recording or not self.recorded_events: + return + + if self.record_filename: + filename = self.record_filename + else: + now = datetime.datetime.now() + filename = now.strftime("session_%Y%m%d_%H%M%S.mid") + + logger.info( + f"Saving MIDI recording ({len(self.recorded_events)} events) to {filename}..." + ) + + mid = mido.MidiFile( + type=1 + ) # Type 1 = multiple tracks (though we might just use one) + track = mido.MidiTrack() + mid.tracks.append(track) + + # Resolution (ticks per beat). Standard is 480. + # Subsequence uses 24 PPQN internal. + # To get 480 PPQN output without losing precision, we scale up by 20. + ticks_per_pulse = 20 + mid.ticks_per_beat = 480 + + # Sort events by pulse just in case + self.recorded_events.sort(key=lambda x: x[0]) + + last_pulse = 0.0 + + for pulse, message in self.recorded_events: + delta_pulses = pulse - last_pulse + delta_ticks = int(delta_pulses * ticks_per_pulse) + + # Ensure delta is non-negative (floating point jitter?) + if delta_ticks < 0: + delta_ticks = 0 + + message.time = delta_ticks + track.append(message) + + last_pulse = pulse + + try: + mid.save(filename) + logger.info(f"Saved {filename}") + except Exception as e: + logger.error(f"Failed to save MIDI recording: {e}") + + def disable_spin_wait(self) -> None: + """Disable the hybrid sleep+spin timing strategy. + + By default the sequencer busy-waits for the final sub-millisecond of each + pulse interval to minimise clock jitter. Call this to revert to pure + ``asyncio.sleep()`` — lower CPU usage at the cost of higher jitter (typically + ±0.5–2 ms on Linux vs ~3–4 μs with spin-wait enabled (see the README clock-accuracy benchmark)). + + Can also be set at construction time: ``Sequencer(spin_wait=False)``. + """ + + self._spin_wait = False + + def set_bpm(self, bpm: float) -> None: + """ + Instantly change the tempo. + + Note: If ``clock_follow`` is enabled and the sequencer is running, + this method will be ignored as the tempo is slaved to the external source. + When Ableton Link is active, the new BPM is proposed to the Link network + instead of being applied locally — the network-authoritative tempo is + then picked up on the next pulse. + """ + + # Validate BEFORE the Link branch — a zero/negative tempo must never + # be proposed to the whole Link session. + if bpm <= 0: + raise ValueError("BPM must be positive") + + if self.clock_follow and self.running: + logger.info("BPM is controlled by external clock - set_bpm() ignored") + return + + if self._link_clock is not None and self.running: + self._link_clock.request_tempo(bpm) + logger.info(f"BPM {bpm:.2f} proposed to Ableton Link session") + return + + self._bpm_transition = None + self.current_bpm = bpm + self.seconds_per_beat = 60.0 / self.current_bpm + self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat + + logger.info(f"BPM set to {self.current_bpm:.2f}") + + if self.recording: + tempo = mido.bpm2tempo(self.current_bpm) + self._record_event( + self.pulse_count, mido.MetaMessage("set_tempo", tempo=tempo) + ) + + def set_target_bpm( + self, + target_bpm: float, + bars: int, + shape: typing.Union[str, subsequence.easing.EasingFn] = "linear", + ) -> None: + """ + Smoothly transition to a new tempo over a fixed number of bars. + + Parameters: + target_bpm: The BPM to ramp toward. + bars: Duration of the transition in bars. + shape: Easing curve — a name string (e.g. ``"ease_in_out"``) or any + callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. + ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- + sounding tempo changes. See :mod:`subsequence.easing`. + + Note: + When Ableton Link is active the shared network tempo is authoritative, + so a local ramp cannot be honoured — this call is ignored. Use + ``set_bpm()`` to propose a new tempo to the Link session instead. + """ + + if self.clock_follow and self.running: + logger.info( + "BPM is controlled by external clock - set_target_bpm() ignored" + ) + return + + if self._link_clock is not None and self.running: + logger.info( + "Tempo is controlled by the Ableton Link session - set_target_bpm() ramp ignored; use set_bpm() to propose a new Link tempo" + ) + return + + if target_bpm <= 0: + raise ValueError("Target BPM must be positive") + + if bars <= 0: + raise ValueError("Transition bars must be positive") + + total_pulses = bars * self.pulses_per_beat * self.time_signature[0] + + self._bpm_transition = BpmTransition( + start_bpm=self.current_bpm, + target_bpm=target_bpm, + total_pulses=total_pulses, + easing_fn=subsequence.easing.get_easing(shape), + ) + + logger.info( + f"BPM transition: {self.current_bpm:.2f} → {target_bpm:.2f} over {bars} bars ({shape!r})" + ) + + def on_event( + self, event_name: str, callback: typing.Callable[..., typing.Any] + ) -> None: + """ + Register a callback for a named event. + """ + + self.events.on(event_name, callback) + + def _init_midi_output(self) -> None: + """Initialize the primary MIDI output port (device 0). + + When ``output_device_name`` was provided, opens that device directly. + When omitted, auto-discovers available devices: uses the only one if + exactly one is found, or prompts the user to choose if several exist. + """ + + device_name, midi_out = subsequence.midi_utils.select_output_device( + self.output_device_name + ) + + if device_name and midi_out is not None: + self.output_device_name = device_name + self._output_devices.add(device_name, midi_out) + + def _open_midi_inputs(self) -> None: + """ + Set up the internal event loops and MIDI input ports. + + This is called automatically by start(), but may be called manually + by Composition._run() earlier in the startup sequence to ensure + MIDI CC configuration is shared before ports begin background draining. + """ + + if self.input_device_name is not None and self._midi_input_queue is None: + self._input_loop = asyncio.get_running_loop() + self._midi_input_queue = asyncio.Queue() + self._init_midi_input() + + def _init_midi_input(self) -> None: + """Initialize the primary MIDI input port (device 0) with a callback.""" + + if self.input_device_name is None: + return + + callback = self._make_input_callback(0) + device_name, midi_in = subsequence.midi_utils.select_input_device( + self.input_device_name, callback + ) + + if device_name and midi_in is not None: + self.input_device_name = device_name + self._input_devices.add(device_name, midi_in) + + def _make_input_callback(self, device_idx: int) -> typing.Callable: + """Return a mido callback closure that tags messages with *device_idx*.""" + + def _callback(message: typing.Any) -> None: + self._on_midi_input(message, device_idx) + + return _callback + + def _on_midi_input(self, message: typing.Any, device_idx: int = 0) -> None: + """Handle incoming MIDI messages from the input port callback thread. + + This runs in mido's callback thread. Clock/transport messages are + forwarded to the asyncio event loop via call_soon_threadsafe. + + CC input mappings are applied immediately here. Single dict writes + are safe from a non-asyncio thread under CPython's GIL. + + Parameters: + message: The incoming mido.Message. + device_idx: Index of the input device this message arrived on (0 = primary). + """ + + if self._midi_input_queue is None or self._input_loop is None: + return + + # The queue feeds only the external-clock loop, so enqueue only when + # following: with the internal clock nothing ever drains it, and a + # synced device's 24-ticks-per-beat clock would grow it forever. + if self.clock_follow: + self._input_loop.call_soon_threadsafe( + self._midi_input_queue.put_nowait, (device_idx, message) + ) + + # Apply CC input mappings: map incoming CC values to composition.data. + if message.type == "control_change" and self.cc_mappings: + for mapping in self.cc_mappings: + if message.control != mapping["cc"]: + continue + ch = mapping.get("channel") + if ch is not None and message.channel != ch: + continue + # Filter by input device if specified (None = any device). + in_dev = mapping.get("input_device") + if in_dev is not None and device_idx != in_dev: + continue + scaled = mapping["min_val"] + (message.value / 127.0) * ( + mapping["max_val"] - mapping["min_val"] + ) + self._composition_data[mapping["data_key"]] = scaled + + # Apply CC forwards: route incoming CC to MIDI output in real-time. + if message.type == "control_change" and self.cc_forwards: + for fwd in self.cc_forwards: + if message.control != fwd["cc"]: + continue + ch = fwd.get("channel") + if ch is not None and message.channel != ch: + continue + # Filter by input device if specified (None = any device). + in_dev = fwd.get("input_device") + if in_dev is not None and device_idx != in_dev: + continue + try: + out_msg = fwd["transform"](message.value, message.channel) + except Exception: + logger.exception("CC forward transform failed") + continue + if out_msg is None: + continue + if fwd["mode"] == "instant": + # Route to the specified output device (default: device 0). + out_dev = fwd.get("output_device", 0) + port = self._output_devices.get(out_dev) + if port is not None: + try: + # Instant mode fires on the mido callback thread, so the + # send must take the lock the loop thread also holds. + self._locked_send(port, out_msg) + except Exception: + logger.exception("CC forward send failed") + else: + # Queued: buffer for drain in _process_pulse on the event loop + # thread. The output device travels with the message so the + # drain can route it (dropping it sent everything to device 0). + out_dev = fwd.get("output_device") + self._forward_buffer.append( + (self.pulse_count, out_msg, 0 if out_dev is None else out_dev) + ) + + # Buffer incoming note on/off for the held-note tracker. Only the + # GIL-atomic deque.append happens here; the tracker is updated when the + # loop thread drains the buffer in _advance_pulse. + if self._held_notes is not None and message.type in ("note_on", "note_off"): + if ( + self._note_input_channel is not None + and message.channel != self._note_input_channel + ): + return + if ( + self._note_input_device is not None + and device_idx != self._note_input_device + ): + return + # A note_on with velocity 0 is the running-status form of note-off. + if message.type == "note_on" and message.velocity > 0: + self._note_input_buffer.append( + (True, message.note, message.velocity, time.perf_counter()) + ) + else: + self._note_input_buffer.append( + (False, message.note, 0, time.perf_counter()) + ) + + def _estimate_bpm(self, tick_time: float) -> None: + """Estimate BPM from recent MIDI clock tick timestamps for display and recording.""" + + self._clock_tick_times.append(tick_time) + + # Keep last 48 ticks (2 beats) for averaging. + if len(self._clock_tick_times) > 48: + self._clock_tick_times = self._clock_tick_times[-48:] + + if len(self._clock_tick_times) >= 24: + # Average interval over last 24 ticks (1 beat). + recent = self._clock_tick_times[-24:] + interval = (recent[-1] - recent[0]) / (len(recent) - 1) + + if interval > 0: + new_bpm = int(round(60.0 / (interval * self.pulses_per_beat))) + + # Record tempo changes so a clock-following session's .mid + # plays back at the external tempo, not the constructor BPM. + # Integer rounding above already filters tick jitter. + if self.recording and new_bpm != self.current_bpm and new_bpm > 0: + self._record_event( + self.pulse_count, + mido.MetaMessage("set_tempo", tempo=mido.bpm2tempo(new_bpm)), + ) + + self.current_bpm = new_bpm + + def _get_schedule_timing( + self, length_beats: float, lookahead_beats: float + ) -> typing.Tuple[int, int]: + """ + Convert schedule length and reschedule lookahead from beats to pulses. + """ + + if length_beats <= 0: + raise ValueError("Schedule length must be positive") + + if lookahead_beats < 0: + raise ValueError("Reschedule lookahead cannot be negative") + + if lookahead_beats > length_beats: + raise ValueError("Reschedule lookahead cannot exceed schedule length") + + length_pulses = int(length_beats * self.pulses_per_beat) + lookahead_pulses = int(lookahead_beats * self.pulses_per_beat) + + if length_pulses <= 0: + raise ValueError("Schedule length must be at least one pulse") + + return length_pulses, lookahead_pulses + + def _get_pattern_timing(self, pattern: PatternLike) -> typing.Tuple[int, int]: + """ + Convert pattern length and reschedule lookahead from beats to pulses. + """ + + return self._get_schedule_timing(pattern.length, pattern.reschedule_lookahead) + + def _push_event(self, event: MidiEvent) -> None: + """Push a MidiEvent onto the queue, stamping a FIFO tie-breaker. + + The ``sequence`` field guarantees that events sharing a ``pulse`` + dispatch in insertion order — required for NRPN/RPN bursts and any + future protocol that emits multiple co-scheduled CCs (e.g. Bank + Select before Program Change). + """ + + event.sequence = next(self._event_counter) + heapq.heappush(self.event_queue, event) + + def _spawn(self, coro: typing.Coroutine) -> None: + """Fire-and-forget *coro* on the event loop, tracked and exception-safe. + + Retains a strong reference until the task completes (so it cannot be + collected mid-flight) and surfaces any exception via the done-callback — + a bare ``asyncio.create_task`` drops both, silently losing a raising bar + or beat callback. + """ + + task = asyncio.create_task(coro) + self._background_tasks.add(task) + task.add_done_callback(self._reap_background_task) + + def _reap_background_task(self, task: "asyncio.Task") -> None: + """Done-callback: drop the reference and log any exception.""" + + self._background_tasks.discard(task) + + if not task.cancelled() and task.exception() is not None: + logger.error("Background task failed", exc_info=task.exception()) + + async def schedule_pattern(self, pattern: PatternLike, start_pulse: int) -> None: + """ + Schedules a pattern's notes and CC events into the sequencer's event queue. + + If ``pattern.mirrors`` is non-empty, every note, CC, pitch bend, program + change, SysEx, NRPN/RPN burst, and drone event is duplicated onto each + mirror destination — see the "MIDI mirroring" section of the README. + + **Bandwidth note**: each mirror destination multiplies the per-pattern + event count. A dense pattern with two mirrors emits 3× the original. + For DIN-MIDI hardware on a saturated bus this can matter; over USB or + IAC it is rarely a concern. + + **Tuning + mirrors**: ``CcEvent.channel`` and ``CcEvent.device`` overrides + (used by polyphonic microtonal tuning to rotate notes onto separate + channels) apply to the *primary* destination only. Mirror destinations + always use their own pinned ``(device, channel)`` — i.e. a polyphonic- + tuning pattern mirrored to another synth will collapse all channel + rotations onto the mirror's single channel, losing per-note bend + isolation on that destination. Apply tuning per-pattern if both ends + need it. + """ + + # Primary destination first; mirrors follow. Iteration order matters + # only for human readability when inspecting the queue — FIFO ordering + # at equal pulses is enforced by ``_push_event`` (the ``sequence`` + # tie-breaker on ``MidiEvent``). + mirrors = getattr(pattern, "mirrors", []) + destinations: typing.List[_MirrorTarget] = [ + _MirrorTarget(pattern.device, pattern.channel, None) + ] + [_to_target(entry) for entry in mirrors] + + async with self.queue_lock: + for position, step in pattern.steps.items(): + abs_pulse = start_pulse + position + + for note in step.notes: + for i, target in enumerate(destinations): + # Resolve the drum name for this destination; None means the + # destination has no voice for it, so it stays silent (no + # note_on AND no note_off — nothing can hang). + note_value = _destination_pitch(note, target, primary=(i == 0)) + if note_value is None: + # A mirror carrying its own map that lacks this named + # voice drops it here by design (faithful-core: silence, + # never a wrong note). Surface it at debug level so + # "why is the clap missing on the sampler?" is answerable + # without guessing — the build-time warning only covers a + # name absent from *every* destination. + if ( + i != 0 + and note.origin is not None + and target.drum_note_map is not None + ): + logger.debug( + "Mirror device %d channel %d has no voice for drum '%s' — dropped for this destination", + target.device, + target.channel, + note.origin, + ) + continue + + # Primary preserves the Note's own channel (so polyphonic + # tuning's per-voice channel rotation lands correctly). + # Mirrors collapse onto the mirror's pinned channel and + # re-resolve the drum name through their own map. + note_channel = note.channel if i == 0 else target.channel + note_device = pattern.device if i == 0 else target.device + + on_event = MidiEvent( + pulse=abs_pulse, + message_type="note_on", + channel=note_channel, + note=note_value, + velocity=note.velocity, + device=note_device, + ) + self._push_event(on_event) + + off_event = MidiEvent( + pulse=abs_pulse + note.duration, + message_type="note_off", + channel=note_channel, + note=note_value, + velocity=0, + device=note_device, + ) + self._push_event(off_event) + + # CC / pitch bend / program change / SysEx events + for cc_event in getattr(pattern, "cc_events", []): + abs_pulse = start_pulse + cc_event.pulse + + for i, target in enumerate(destinations): + if i == 0: + # Primary: respect per-event channel/device override if set + # (e.g. tuning pitch bends targeting specific channels). + event_channel = ( + cc_event.channel + if cc_event.channel is not None + else target.channel + ) + event_device = ( + cc_event.device + if cc_event.device is not None + else target.device + ) + else: + # Mirror: always use the mirror's pinned (device, channel). + # See class docstring for the tuning interaction note. + event_channel = target.channel + event_device = target.device + + midi_event = MidiEvent( + pulse=abs_pulse, + message_type=cc_event.message_type, + channel=event_channel, + control=cc_event.control, + value=cc_event.value, + data=cc_event.data, + device=event_device, + priority=getattr(cc_event, "priority", 0), + ) + self._push_event(midi_event) + + # Raw Note On/Off events (drones) + for note_ev in getattr(pattern, "raw_note_events", []): + abs_pulse = start_pulse + note_ev.pulse + + for i, target in enumerate(destinations): + # Same faithful-core rule as step notes: a named drone is + # re-resolved through each mirror's own drum_note_map, and + # a destination lacking the voice stays silent rather than + # sounding the primary device's note number. + note_value = _destination_pitch(note_ev, target, primary=(i == 0)) + if note_value is None: + if ( + i != 0 + and note_ev.origin is not None + and target.drum_note_map is not None + ): + logger.debug( + "Mirror device %d channel %d has no voice for drum '%s' — dropped for this destination", + target.device, + target.channel, + note_ev.origin, + ) + continue + + midi_event = MidiEvent( + pulse=abs_pulse, + message_type=note_ev.message_type, + channel=target.channel, + note=note_value, + velocity=note_ev.velocity, + device=target.device, + ) + self._push_event(midi_event) + + # OSC events — never mirrored. OSC isn't bound to a MIDI port and + # mirroring it would require a different abstraction (multiple OSC + # servers / addresses). If users want to broadcast OSC to several + # endpoints, that belongs in the OSC server config, not here. + for osc_event in getattr(pattern, "osc_events", []): + abs_pulse = start_pulse + osc_event.pulse + + osc_midi_event = MidiEvent( + pulse=abs_pulse, + message_type="osc", + channel=0, + data=(osc_event.address, osc_event.args), + ) + + self._push_event(osc_midi_event) + + logger.debug( + f"Scheduled pattern at {start_pulse}, queue size: {len(self.event_queue)}" + ) + + async def schedule_pattern_repeating( + self, pattern: PatternLike, start_pulse: int + ) -> None: + """ + Schedule a pattern and register it for rescheduling each cycle. + """ + + length_pulses, lookahead_pulses = self._get_pattern_timing(pattern) + + # Anchor the first cycle for window-reading rebuilds (kept current on + # every reschedule in _maybe_reschedule_patterns). + pattern._cycle_start_pulse = start_pulse + + await self.schedule_pattern(pattern, start_pulse) + + next_reschedule_pulse = start_pulse + length_pulses - lookahead_pulses + + scheduled_pattern = ScheduledPattern( + pattern=pattern, + cycle_start_pulse=start_pulse, + length_pulses=length_pulses, + lookahead_pulses=lookahead_pulses, + next_reschedule_pulse=next_reschedule_pulse, + ) + + async with self.pattern_lock: + counter = next(self._reschedule_counter) + heapq.heappush( + self.reschedule_queue, + (scheduled_pattern.next_reschedule_pulse, counter, scheduled_pattern), + ) + + async def schedule_callback_repeating( + self, + callback: typing.Callable[[int], typing.Any], + interval_beats: float, + start_pulse: int = 0, + reschedule_lookahead: float = 1, + ) -> None: + """ + Schedule a repeating callback on a beat interval. + """ + + interval_pulses, lookahead_pulses = self._get_schedule_timing( + interval_beats, reschedule_lookahead + ) + + # "Backshift" initialization: treat start_pulse as the *target* of the first fire, + # not the start of the first cycle. This ensures callbacks fire `lookahead` before + # start_pulse (often ≤ 0, so they fire immediately when playback begins). + # + # Formula: cycle_start = start_pulse - interval + # first_fire = start_pulse - lookahead + # + # After the first fire the loop advances normally: + # next_start = cycle_start + interval = start_pulse + # next_fire = start_pulse + interval - lookahead + # + # Note: if start_pulse = 0, first_fire is negative, so the callback fires + # at pulse 0 (the very start of playback). Pass start_pulse = interval_pulses + # to skip the initial fire and have the first fire at (interval - lookahead). + # The harmonic clock does this because HarmonicState already holds the tonic. + + initial_cycle_start = start_pulse - interval_pulses + initial_fire_pulse = start_pulse - lookahead_pulses + + scheduled_callback = ScheduledCallback( + callback=callback, + cycle_start_pulse=initial_cycle_start, + interval_pulses=interval_pulses, + lookahead_pulses=lookahead_pulses, + next_fire_pulse=initial_fire_pulse, + ) + + async with self.callback_lock: + counter = next(self._callback_counter) + heapq.heappush( + self.callback_queue, + (scheduled_callback.next_fire_pulse, counter, scheduled_callback), + ) + + async def schedule_callback_sequence( + self, + callback: typing.Callable[ + [int], + typing.Union[ + typing.Optional[float], + typing.Coroutine[typing.Any, typing.Any, typing.Optional[float]], + ], + ], + start_pulse: int = 0, + reschedule_lookahead: float = 1, + ) -> None: + """Schedule a self-rescheduling callback with a variable firing interval. + + Where :meth:`schedule_callback_repeating` fires on a fixed beat + interval, this primitive lets the callback decide each hop: it is + called ``lookahead`` before every *boundary* pulse, receives that + boundary pulse, and returns the number of beats to the **next** + boundary — or ``None`` to stop. Built for clocks that walk irregular + spans, e.g. the harmonic clock under a bound progression's + harmonic rhythm. + + The first fire targets *start_pulse* as its boundary and is due + ``lookahead`` before it (immediately, when that is already past — + the same backshift idiom as the repeating scheduler). + + Parameters: + callback: ``fn(boundary_pulse) -> beats_to_next_boundary | None``. + May be sync or async. + start_pulse: The first boundary pulse. + reschedule_lookahead: How many beats before each boundary the + callback fires. + """ + + lookahead_pulses = int(reschedule_lookahead * self.pulses_per_beat) + + if lookahead_pulses < 0: + raise ValueError("Reschedule lookahead cannot be negative") + + scheduled = ScheduledCallbackSequence( + callback=callback, + boundary_pulse=start_pulse, + lookahead_pulses=lookahead_pulses, + next_fire_pulse=start_pulse - lookahead_pulses, + ) + + async with self.callback_lock: + counter = next(self._callback_counter) + heapq.heappush( + self.callback_sequence_queue, + (scheduled.next_fire_pulse, counter, scheduled), + ) + + async def play(self) -> None: + """ + Convenience method to start playback and wait for completion. + """ + + await self.start() + + try: + if self.task: + await self.task + except asyncio.CancelledError: + pass + finally: + await self.stop() + + def _send_clock_message(self, message_type: str) -> None: + """Send a bare MIDI system-realtime message (clock, start, stop, continue). + + These messages carry no channel or data bytes — they are sent directly to + the output port. Used for MIDI clock output when ``clock_output`` is True. + + Parameters: + message_type: One of ``"clock"``, ``"start"``, ``"stop"``, ``"continue"``. + """ + + for port in self._output_devices: + try: + self._locked_send(port, mido.Message(message_type)) + except Exception: + logger.exception(f"Failed to send MIDI {message_type} message") + + async def start(self) -> None: + """Start the sequencer playback in a separate asyncio task. + + When an input device is configured, the MIDI input port is opened here + (after the event loop is running) so that call_soon_threadsafe works. + When ``clock_output`` is True, a MIDI Start (0xFA) message is sent before + the first clock tick so connected hardware begins from the top. + """ + + if self.running: + return + + # Set up MIDI input queue before opening the port. + self._open_midi_inputs() + + # Store the event loop for thread-safe scheduling (e.g., trigger() from user threads) + self._event_loop = asyncio.get_running_loop() + + self._waiting_for_start = self.clock_follow + self.running = True + self.task = asyncio.create_task(self._run_loop()) + + if self.clock_output: + self._send_clock_message("start") + + logger.info("Sequencer started") + + await self.events.emit_async("start") + + async def stop(self) -> None: + """ + Stop the sequencer playback and cleanup resources. + """ + + if not self.running and not self._output_devices: + return + + logger.info("Stopping sequencer...") + + self.running = False + + if self.task: + try: + await self.task + except asyncio.CancelledError: + # The loop task was cancelled (the Ctrl-C path) — since + # Python 3.8 CancelledError is a BaseException, so a bare + # `except Exception` missed it and the whole cleanup below + # was skipped. + logger.info("Sequencer loop task cancelled - continuing shutdown") + except Exception: + # A crashed loop must not abort shutdown - the cleanup below + # (pending-send cancellation, panic, port close, recording + # save) is exactly what a dying session needs most. + logger.exception( + "Sequencer loop task ended with an exception - continuing shutdown" + ) + + # Cancel any latency-compensation deferrals still in flight. Must happen + # after the loop has stopped producing (await self.task) and before + # close_all() so a pending call_later can't fire on a closed port. + # panic() below is the silence authority for any note stranded by this. + self._cancel_pending_sends() + + if self.clock_output: + self._send_clock_message("stop") + + await self.panic() + + self._output_devices.close_all() + self._input_devices.close_all() + + # Leave the Ableton Link session cleanly if one was active, rather than + # letting the socket linger in the session until process exit. + if self._link_clock is not None: + try: + self._link_clock.disable() + except Exception: + logger.exception("Failed to disable Ableton Link clock") + + self._midi_input_queue = None + self._input_loop = None + + self.save_recording() + + logger.info("Sequencer stopped") + + async with self.pattern_lock: + self.reschedule_queue = [] + self._reschedule_counter = itertools.count() + + async with self.callback_lock: + self.callback_queue = [] + self.callback_sequence_queue = [] + self._callback_counter = itertools.count() + + # Note: ``_event_counter`` is intentionally NOT reset here. The two + # counters above pair with queues that we do clear, so resetting them + # is symmetric. ``self.event_queue`` is left to drain naturally and + # may carry stale items into a restart; resetting ``_event_counter`` + # alongside an un-cleared queue would let a fresh push (sequence=0) + # sort ahead of a stale push (sequence=N) at the same pulse. Since + # pulse is the primary heap key and the counter never overflows, we + # just let it keep counting forever. + + self.active_notes = set() + + await self.events.emit_async("stop") + + async def _run_loop(self) -> None: + """Main playback loop - delegates to internal, external, or Link clock mode.""" + + self.start_time = time.perf_counter() + self.pulse_count = 0 + self.current_bar = -1 + + pulses_per_bar = self.time_signature[0] * self.pulses_per_beat + + if self.clock_follow and self._midi_input_queue is not None: + await self._run_loop_external_clock(pulses_per_bar) + elif self._link_clock is not None: + await self._run_loop_link_clock(self._link_clock, pulses_per_bar) + else: + await self._run_loop_internal_clock(pulses_per_bar) + + def _check_bar_change(self, pulse: int, pulses_per_bar: int) -> None: + """Detect bar boundaries and fire bar callbacks + events.""" + + new_bar = pulse // pulses_per_bar + + if new_bar > self.current_bar: + self.current_bar = new_bar + + # In render mode, stop after the requested number of bars. + if ( + self.render_mode + and self.render_bars > 0 + and self.current_bar >= self.render_bars + ): + self.running = False + return + + self._spawn(self.events.emit_async("bar", self.current_bar)) + + def _check_beat_change(self, pulse: int, pulses_per_beat: int) -> None: + """Detect beat boundaries within the bar and fire beat events.""" + + beat_in_bar = ( + pulse % (self.time_signature[0] * pulses_per_beat) + ) // pulses_per_beat + + if beat_in_bar != self.current_beat: + self.current_beat = beat_in_bar + self._spawn(self.events.emit_async("beat", self.current_beat)) + + async def _advance_pulse(self) -> None: + """Reschedule patterns, process events, and increment the pulse counter.""" + + if self._bpm_transition is not None: + self._bpm_transition.elapsed_pulses += 1 + + if self._bpm_transition.elapsed_pulses >= self._bpm_transition.total_pulses: + target = self._bpm_transition.target_bpm + self._bpm_transition = None + self.set_bpm(target) + else: + progress = ( + self._bpm_transition.elapsed_pulses + / self._bpm_transition.total_pulses + ) + eased = self._bpm_transition.easing_fn(progress) + interpolated = ( + self._bpm_transition.start_bpm + + (self._bpm_transition.target_bpm - self._bpm_transition.start_bpm) + * eased + ) + self.current_bpm = interpolated + self.seconds_per_beat = 60.0 / self.current_bpm + self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat + + # Keep the recording's tempo map honest: without these events + # a recorded ramp plays back at the pre-ramp tempo and jumps. + if self.recording: + self._record_event( + self.pulse_count, + mido.MetaMessage( + "set_tempo", tempo=mido.bpm2tempo(interpolated) + ), + ) + + # Accumulate simulated time and enforce the render time cap. + if self.render_mode: + self._render_elapsed_seconds += self.seconds_per_pulse + if ( + self.render_max_seconds is not None + and self._render_elapsed_seconds >= self.render_max_seconds + ): + max_min = self.render_max_seconds / 60.0 + logger.warning( + f"Render stopped at {max_min:.1f}-minute safety limit. " + f"Pass max_minutes=None with an explicit bars= count to remove this limit." + ) + self.running = False + return + + # Drain buffered note input into the held-note tracker BEFORE patterns + # rebuild, so a pattern's held_notes() snapshot reflects this pulse rather + # than the previous one. deque.popleft() is GIL-atomic; the tracker is + # only ever touched on this loop thread (here and during rebuild). + if self._held_notes is not None: + while self._note_input_buffer: + is_on, pitch, velocity, when = self._note_input_buffer.popleft() + if is_on: + self._held_notes.note_on(pitch, velocity, when) + else: + self._held_notes.note_off(pitch, when) + + await self._maybe_reschedule_patterns(self.pulse_count) + await self._process_pulse(self.pulse_count) + self.pulse_count += 1 + + async def _run_loop_internal_clock(self, pulses_per_bar: int) -> None: + """Playback loop driven by the internal wall clock. + + In normal mode the loop sleeps between pulses to maintain tempo. + In render mode it runs as fast as possible (simulates time rather than + waiting for the wall clock), stopping after *render_bars* bars. + """ + + next_pulse_time = self.start_time + + while self.running: + # In render mode, simulate time advancing one pulse at a time so + # the inner loop always fires exactly once without spin-waiting. + current_time = next_pulse_time if self.render_mode else time.perf_counter() + + while current_time >= next_pulse_time: + # Ordering within each pulse: + # 1. _check_bar/beat_change() — update counters and queue "bar"/"beat" + # event tasks (asyncio.create_task; not run yet). + # 2. Send MIDI clock tick (if clock_output) so hardware receives it at + # the same time as note events for tight sync. + # 3. _advance_pulse() — fire callbacks, then send MIDI via _process_pulse(). + # 4. After the await returns, the event loop runs the queued event tasks, + # which update the terminal display. + # + # Consequence: MIDI notes are sent *before* the display updates. The display + # always trails the audio by roughly one pulse-processing cycle plus any + # terminal rendering latency (~10-50 ms). This is expected and acceptable for + # a visual status line — it cannot be tightened without restructuring the loop. + self._check_bar_change(self.pulse_count, pulses_per_bar) + + if not self.running: + # A render bar-limit trips inside _check_bar_change; stop before + # dispatching this pulse so the first beat of the next (unrendered) + # bar never leaks into the recording. + break # type: ignore[unreachable] + + self._check_beat_change(self.pulse_count, self.pulses_per_beat) + if self.clock_output: + self._send_clock_message("clock") + await self._advance_pulse() + next_pulse_time += self.seconds_per_pulse + + if not self.running: + break # type: ignore[unreachable] + + if not self.running: + break # type: ignore[unreachable] + + if not self.render_mode: + # Check if queue is empty and we are past the last event. + # Skipped in benchmark mode (_jitter_log set) so the clock + # runs until explicitly cancelled via asyncio. + if self._jitter_log is None: + async with self.queue_lock: + if ( + not self.event_queue + and not self.active_notes + and not self.reschedule_queue + and not self.callback_queue + and not self.callback_sequence_queue + ): + logger.info( + "Sequence complete (no more events or active notes)." + ) + self.running = False + break + + sleep_time = next_pulse_time - time.perf_counter() + + if sleep_time > 0: + if self._spin_wait and sleep_time > self._spin_threshold: + # Sleep to within _spin_threshold of the target, then busy-wait + # for the remaining sub-millisecond. Trades ~1ms of CPU spin per + # pulse for significantly tighter timing than asyncio.sleep alone. + await asyncio.sleep(sleep_time - self._spin_threshold) + while time.perf_counter() < next_pulse_time: + pass + else: + await asyncio.sleep(sleep_time) + + if self._jitter_log is not None: + self._jitter_log.append(time.perf_counter() - next_pulse_time) + else: + # Yield to the event loop so queued tasks (pattern rescheduling, + # asyncio.create_task callbacks) can run between pulses. + await asyncio.sleep(0) + + async def _run_loop_external_clock(self, pulses_per_bar: int) -> None: + """Playback loop driven by incoming MIDI clock messages. + + Each MIDI ``clock`` tick advances exactly one pulse (24 ppqn = internal ppqn). + Transport messages (``start``, ``stop``, ``continue``) control sequencer state. + The loop waits for a ``start`` or ``continue`` before advancing pulses, + but still uses incoming ticks to estimate BPM for display. + """ + + assert self._midi_input_queue is not None, ( + "MIDI input queue must be initialized for external clock" + ) + + while self.running: + try: + device_idx, message = await asyncio.wait_for( + self._midi_input_queue.get(), timeout=2.0 + ) + except asyncio.TimeoutError: + continue + + if device_idx != self.clock_device_idx: + continue + + if message.type == "clock": + # Prime BPM estimation while waiting for start/continue, + # but do not advance pulses or schedule events yet. + if self._waiting_for_start: + self._estimate_bpm(time.perf_counter()) + continue + + self._estimate_bpm(time.perf_counter()) + self._check_bar_change(self.pulse_count, pulses_per_bar) + self._check_beat_change(self.pulse_count, self.pulses_per_beat) + await self._advance_pulse() + + elif message.type == "start": + logger.info("MIDI start received") + self.pulse_count = 0 + self.current_bar = -1 + self.current_beat = -1 + self._waiting_for_start = False + + elif message.type == "stop": + logger.info("MIDI stop received") + self.running = False + + elif message.type == "continue": + logger.info("MIDI continue received") + self._waiting_for_start = False + + async def _run_loop_link_clock( + self, link_clock: typing.Any, pulses_per_bar: int + ) -> None: + """Playback loop driven by Ableton Link beat clock. + + Uses ``link_clock.sync(beat)`` as the timing primitive: for each pulse N + we wait until the Link session beat reaches ``beat_origin + N / PPQN``. + This gives accurate, phase-locked timing at 24 PPQN with typical jitter + of ~0.3–0.5 ms (dominated by asyncio/OS scheduling overhead). + + Starts on the next quantum boundary so that bar 0 aligns with all other + Link participants in the session. + """ + + logger.info("Ableton Link clock mode: waiting for bar boundary…") + + # Wait for the next quantum boundary (bar start) for a clean, phase-locked start. + beat_origin = await link_clock.wait_for_bar() + + logger.info( + f"Ableton Link: started at beat {beat_origin:.3f} (tempo={link_clock.tempo:.1f} BPM, peers={link_clock.num_peers})" + ) + + # Reset pulse counter here so bar/beat tracking starts from 0 at beat_origin. + self.pulse_count = 0 + self.current_bar = -1 + self.current_beat = -1 + + while self.running: + # Compute the Link beat corresponding to the current pulse. + target_beat = beat_origin + self.pulse_count / self.pulses_per_beat + + # Wait for Link to reach that beat (this is the timing gate). + await link_clock.sync(target_beat) + + # Update local tempo from the Link session — propagates network BPM changes. + link_bpm = link_clock.tempo + if abs(link_bpm - self.current_bpm) > 0.01: + # Record the change so a recorded session's .mid plays back + # at the Link session tempo (mirrors the external-clock path). + if self.recording: + self._record_event( + self.pulse_count, + mido.MetaMessage("set_tempo", tempo=mido.bpm2tempo(link_bpm)), + ) + self.current_bpm = link_bpm + self.seconds_per_beat = 60.0 / self.current_bpm + self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat + logger.debug(f"Link tempo update: {link_bpm:.2f} BPM") + + self._check_bar_change(self.pulse_count, pulses_per_bar) + self._check_beat_change(self.pulse_count, self.pulses_per_beat) + + if self.clock_output: + self._send_clock_message("clock") + + await self._advance_pulse() + + # Stop when all events are exhausted (same check as internal clock). + async with self.queue_lock: + if ( + not self.event_queue + and not self.active_notes + and not self.reschedule_queue + and not self.callback_queue + and not self.callback_sequence_queue + ): + logger.info("Sequence complete (no more events or active notes).") + self.running = False + break + + async def _maybe_reschedule_patterns(self, pulse: int) -> None: + """ + Reschedule repeating callbacks and patterns when they reach their lookahead threshold. + """ + + to_fire: typing.List[ScheduledCallback] = [] + to_reschedule: typing.List[ScheduledPattern] = [] + + async with self.callback_lock: + while self.callback_queue and self.callback_queue[0][0] <= pulse: + _, _, scheduled_callback = heapq.heappop(self.callback_queue) + + next_start_pulse = ( + scheduled_callback.cycle_start_pulse + + scheduled_callback.interval_pulses + ) + scheduled_callback.cycle_start_pulse = next_start_pulse + scheduled_callback.next_fire_pulse = ( + next_start_pulse + + scheduled_callback.interval_pulses + - scheduled_callback.lookahead_pulses + ) + + to_fire.append(scheduled_callback) + + if to_fire: + # Decision path: composition-level callbacks fire before pattern rebuilds. + for scheduled_callback in to_fire: + try: + result = scheduled_callback.callback(pulse) + + if asyncio.iscoroutine(result): + await result + + except Exception: + # Isolate a misbehaving callback so the rest still fire and + # get rescheduled below — one bad callback must not stall the clock. + logger.exception( + "Scheduled callback failed during reschedule (pulse %d) - continuing", + pulse, + ) + + async with self.callback_lock: + for scheduled_callback in to_fire: + counter = next(self._callback_counter) + heapq.heappush( + self.callback_queue, + (scheduled_callback.next_fire_pulse, counter, scheduled_callback), + ) + + # Variable-interval sequences fire after the fixed callbacks at the + # same pulse (the form clock is fixed; the harmonic span clock is a + # sequence — form-before-harmony ordering is preserved) and before + # pattern rebuilds below. + sequences_to_fire: typing.List[ScheduledCallbackSequence] = [] + + async with self.callback_lock: + while ( + self.callback_sequence_queue + and self.callback_sequence_queue[0][0] <= pulse + ): + _, _, scheduled_sequence = heapq.heappop(self.callback_sequence_queue) + sequences_to_fire.append(scheduled_sequence) + + requeue: typing.List[ScheduledCallbackSequence] = [] + + for scheduled_sequence in sequences_to_fire: + try: + result = scheduled_sequence.callback(scheduled_sequence.boundary_pulse) + + if asyncio.iscoroutine(result): + result = await result + + except Exception: + # Isolate a misbehaving callback so the clock survives; the + # sequence is dropped — with no interval there is no next hop. + logger.exception( + "Callback sequence failed (pulse %d) - sequence stopped", pulse + ) + continue + + if result is None: + continue # the sequence chose to stop + + interval_pulses = max(1, int(float(result) * self.pulses_per_beat)) + scheduled_sequence.boundary_pulse += interval_pulses + scheduled_sequence.next_fire_pulse = max( + pulse + 1, + scheduled_sequence.boundary_pulse - scheduled_sequence.lookahead_pulses, + ) + requeue.append(scheduled_sequence) + + if requeue: + async with self.callback_lock: + for scheduled_sequence in requeue: + counter = next(self._callback_counter) + heapq.heappush( + self.callback_sequence_queue, + ( + scheduled_sequence.next_fire_pulse, + counter, + scheduled_sequence, + ), + ) + + async with self.pattern_lock: + while self.reschedule_queue and self.reschedule_queue[0][0] <= pulse: + _, _, scheduled_pattern = heapq.heappop(self.reschedule_queue) + + # Lazy pattern removal: if Composition.unregister() set the + # ``_removed`` flag on this pattern, skip both the rebuild and + # the re-push so it disappears from rotation. Already-queued + # events in event_queue play out; sustaining notes were stopped + # by unregister() via _stop_pattern_notes(). + if getattr(scheduled_pattern.pattern, "_removed", False): + continue + + next_start_pulse = ( + scheduled_pattern.cycle_start_pulse + + scheduled_pattern.length_pulses + ) + scheduled_pattern.cycle_start_pulse = next_start_pulse + + # Anchor the upcoming cycle on the pattern itself, BEFORE + # on_reschedule() below — rebuilds read it to place the cycle + # on the absolute beat axis (the harmony window's axis). + scheduled_pattern.pattern._cycle_start_pulse = next_start_pulse + + to_reschedule.append(scheduled_pattern) + + if to_reschedule: + # Decision path: update shared composition state before pattern rebuilds. + patterns = [ + scheduled_pattern.pattern for scheduled_pattern in to_reschedule + ] + + try: + await self.events.emit_async("reschedule_pulse", pulse, patterns) + except Exception: + logger.exception( + "reschedule_pulse listener failed (pulse %d) - continuing", pulse + ) + + for scheduled_pattern in to_reschedule: + # Containment: a failing rebuild — or an invalid set_length() that + # makes _get_pattern_timing raise — must cost this pattern its + # cycle, never the clock. On failure the pattern keeps its + # previous timing and is re-queued below for another try. + try: + scheduled_pattern.pattern.on_reschedule() + + # Re-read length in case on_reschedule() changed it (e.g. via set_length). + new_length_pulses, new_lookahead_pulses = self._get_pattern_timing( + scheduled_pattern.pattern + ) + scheduled_pattern.length_pulses = new_length_pulses + scheduled_pattern.lookahead_pulses = new_lookahead_pulses + scheduled_pattern.next_reschedule_pulse = ( + scheduled_pattern.cycle_start_pulse + + new_length_pulses + - new_lookahead_pulses + ) + + await self.schedule_pattern( + scheduled_pattern.pattern, scheduled_pattern.cycle_start_pulse + ) + + except Exception: + logger.exception( + "Pattern reschedule failed - pattern is silent this cycle and keeps its previous timing" + ) + scheduled_pattern.next_reschedule_pulse = ( + scheduled_pattern.cycle_start_pulse + + scheduled_pattern.length_pulses + - scheduled_pattern.lookahead_pulses + ) + + self._spawn( + self.events.emit_async( + "pattern_reschedule", + scheduled_pattern.pattern, + scheduled_pattern.cycle_start_pulse, + ) + ) + + async with self.pattern_lock: + for scheduled_pattern in to_reschedule: + counter = next(self._reschedule_counter) + heapq.heappush( + self.reschedule_queue, + ( + scheduled_pattern.next_reschedule_pulse, + counter, + scheduled_pattern, + ), + ) + + async def _process_pulse(self, pulse: int) -> None: + """ + Process and execute all events for a specific pulse. + """ + + async with self.queue_lock: + # Drain queued CC forwards into the event heap. + # deque.popleft() is GIL-atomic; safe to call from the event loop thread + # while the callback thread calls append(). + while self._forward_buffer: + fwd_pulse, fwd_msg, fwd_device = self._forward_buffer.popleft() + self._push_event( + MidiEvent.from_mido(fwd_pulse, fwd_msg, device=fwd_device) + ) + + while self.event_queue and self.event_queue[0].pulse <= pulse: + event = heapq.heappop(self.event_queue) + + # Defensive: dispatching a single malformed event must not + # crash the sequencer loop and stop the whole composition. + # A bad event (e.g. a tuple stored on Note.velocity by a + # misuse of a builder method) is logged with full context + # and skipped; subsequent events continue normally. + try: + # Track active notes (keyed by device, channel, note) + if event.message_type == "note_on" and event.velocity > 0: + self.active_notes.add((event.device, event.channel, event.note)) + elif event.message_type == "note_off" or ( + event.message_type == "note_on" and event.velocity == 0 + ): + if ( + event.device, + event.channel, + event.note, + ) in self.active_notes: + self.active_notes.remove( + (event.device, event.channel, event.note) + ) + + # Send events at or before the current pulse (late events are sent + # immediately). Latency compensation may defer the actual send by + # the device's offset, but recording below always uses the LOGICAL + # pulse — the .mid is the uncompensated score. + self._dispatch_with_compensation(event) + + if self.recording and event.message_type != "osc": + mido_msg = event.to_mido() + if mido_msg is not None: + self._record_event(event.pulse, mido_msg) + + except Exception: + logger.exception( + "Failed to dispatch %s event at pulse %d (device=%s, channel=%s) — skipping", + event.message_type, + event.pulse, + event.device, + event.channel, + ) + + async def _stop_all_active_notes(self) -> None: + """ + Send note_off for all currently tracked active notes. + """ + + async with self.queue_lock: + for dev, channel, note in list(self.active_notes): + port = self._output_devices.get(dev) + if port is not None: + try: + self._locked_send( + port, + mido.Message( + "note_off", channel=channel, note=note, velocity=0 + ), + ) + except Exception: + logger.exception("Failed to send note_off during stop") + self.active_notes.clear() + + async def _stop_pattern_notes(self, pattern: PatternLike) -> None: + """Send note_off for active notes belonging to a single pattern. + + Targets the pattern's primary ``(device, channel)`` plus every entry + in ``pattern.mirrors``, so patterns with mirrors have their notes + stopped on every output port they fan out to. Used by + ``Composition.unregister()`` to flush drones and any sustaining + notes when a pattern is being torn down. + """ + + mirrors = getattr(pattern, "mirrors", []) + # Build the target set from each entry's (device, channel) prefix — a + # 3-tuple mirror carries a dict (drum_note_map) and would be unhashable. + targets: typing.Set[typing.Tuple[int, int]] = { + (pattern.device, pattern.channel) + } | {(e[0], e[1]) for e in mirrors} + + async with self.queue_lock: + stranded = [t for t in self.active_notes if (t[0], t[1]) in targets] + + for dev, channel, note in stranded: + # Route through latency compensation, NOT straight to the + # port: a note_on for this device may still be deferred in + # _pending_sends, and an immediate note_off would overtake it, + # leaving the note stuck ringing (drones have no later + # note_off to rescue them). The shared per-device offset + # preserves on→off order. + try: + self._dispatch_with_compensation( + MidiEvent( + pulse=self.pulse_count, + message_type="note_off", + channel=channel, + note=note, + velocity=0, + device=dev, + ) + ) + except Exception: + logger.exception( + f"Failed to send note_off during unregister (dev={dev}, ch={channel}, note={note})" + ) + self.active_notes.discard((dev, channel, note)) + + def _send_offset_seconds(self, device: int) -> float: + """Return the latency-compensation send offset (seconds) for *device*. + + ``(max_latency − device_latency) / 1000``, clamped ≥ 0. The slowest + device returns 0 (sent at logical time); faster devices return a + positive delay so they sound together. + + Invariant: the offset is **per-device**, so every event for one device + shares it. That is what preserves same-pulse FIFO order through + deferral — an NRPN burst (CC 99 → 98 → 6 → 38) on one device stays in + order because all four are deferred by the same amount. A future + per-channel/per-message latency would break that and must not be added + without re-thinking burst ordering. + """ + + offset_ms = self._max_device_latency_ms - self._output_devices.latency_of( + device + ) + if offset_ms <= 0.0: + return 0.0 + return offset_ms / 1000.0 + + def _dispatch_with_compensation(self, event: MidiEvent) -> None: + """Send *event* now, or defer it by its device's latency offset. + + Deferral is a wall-clock ``call_later`` so it is correct regardless of + tempo or clock source. Skipped entirely in render mode (no real clock + — deferring would drop events from the rendered file) and when no event + loop is running (the synchronous test path). + """ + + if self.render_mode or self._event_loop is None: + self._send_midi(event) + return + + offset_s = self._send_offset_seconds(event.device) + if offset_s <= 0.0: + self._send_midi(event) + return + + # Defer the physical send. The one-element ``cell`` lets the callback + # discard exactly its own handle from _pending_sends (the handle isn't + # known until call_later returns, but _fire only runs after we append). + cell: typing.List[asyncio.TimerHandle] = [] + + def _fire() -> None: + self._pending_sends.discard(cell[0]) + self._send_midi(event) + + handle = self._event_loop.call_later(offset_s, _fire) + cell.append(handle) + self._pending_sends.add(handle) + + def _cancel_pending_sends(self) -> None: + """Cancel and forget all in-flight deferred sends. Idempotent. + + Called during ``stop()`` before ports close so a pending ``call_later`` + can never fire ``port.send()`` on a closed port. Stranded notes are not + a concern here: ``panic()`` runs after this and is the silence + authority (``active_notes`` reflects logical time and can diverge from + what physically fired, so it is not relied upon). + """ + + for handle in self._pending_sends: + handle.cancel() + self._pending_sends.clear() + + def _locked_send(self, port: typing.Any, message: typing.Any) -> None: + """Write one message to a port under the send lock. + + The lock keeps the loop thread and the instant cc_forward callback + thread from interleaving bytes on the same port. + """ + + with self._port_send_lock: + port.send(message) + + def _send_midi(self, event: MidiEvent) -> None: + """ + Send a MIDI message to the appropriate output device. + """ + + port = self._output_devices.get(event.device) + if port is not None: + try: + if event.message_type == "osc": + if self.osc_server is not None: + address, args = event.data + self.osc_server.send(address, *args) + return + + msg = event.to_mido() + if msg is None: + return + + self._locked_send(port, msg) + + except Exception: + logger.exception("MIDI send failed (device may be disconnected)") + + async def panic(self) -> None: + """ + Send a MIDI panic message to all channels. + """ + + logger.info("Panic: sending all notes off.") + + # 1. Stop all tracked active notes manually + await self._stop_all_active_notes() + + for port in self._output_devices: + try: + # Hold the send lock so an instant cc_forward on the callback + # thread cannot interleave with the panic sweep. + with self._port_send_lock: + # 2. Send "All Notes Off" (CC 123) and "All Sound Off" (CC 120) to all 16 channels + for channel in range(16): + port.send( + mido.Message( + "control_change", channel=channel, control=123, value=0 + ) + ) + port.send( + mido.Message( + "control_change", channel=channel, control=120, value=0 + ) + ) + + # 3. Use built-in panic and reset + port.panic() + + # Note: reset() might close/reopen ports or clear internal buffers depending on backend, + # but mido docs say it sends "All Notes Off" and "Reset All Controllers". + port.reset() + + except Exception: + logger.exception("MIDI panic failed (device may be disconnected)") diff --git a/subsequence/tuning.py b/subsequence/tuning.py index b308b7b..4de50d6 100644 --- a/subsequence/tuning.py +++ b/subsequence/tuning.py @@ -37,417 +37,423 @@ # ── Tuning class ───────────────────────────────────────────────────────────── + @dataclasses.dataclass 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 - def size (self) -> int: - """Number of scale degrees per period (the .scl ``count`` line).""" - return len(self.cents) - - @property - 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 - 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 - 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 - 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 - 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 - 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 ────────────────────────────────────────────────────── - - 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 + """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 + def size(self) -> int: + """Number of scale degrees per period (the .scl ``count`` line).""" + return len(self.cents) + + @property + 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 + 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 + 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 + 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 + 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 + 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 ────────────────────────────────────────────────────── + + 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 ───────────────────────────────────────────────────────── -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 - - 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 +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 + + 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 ───────────────────────────────────────────────────────── -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, + +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 ─────────────────────────────── - # priority=-1 makes the bend dispatch BEFORE the note_on that shares its - # pulse — the scheduler pushes all notes before cc events, so list order - # alone cannot deliver the bend-first requirement; the priority field on - # MidiEvent outranks the push-order tie-breaker. - 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, - priority=-1, - ) - ) - - # Prepend onset bends (kept first in the list for readability; the - # dispatch order guarantee comes from priority above) - 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)))) + """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 ─────────────────────────────── + # priority=-1 makes the bend dispatch BEFORE the note_on that shares its + # pulse — the scheduler pushes all notes before cc events, so list order + # alone cannot deliver the bend-first requirement; the priority field on + # MidiEvent outranks the push-order tie-breaker. + 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, + priority=-1, + ) + ) + + # Prepend onset bends (kept first in the list for readability; the + # dispatch order guarantee comes from priority above) + 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)))) diff --git a/subsequence/voicings.py b/subsequence/voicings.py index e1e0608..8b5e7d9 100644 --- a/subsequence/voicings.py +++ b/subsequence/voicings.py @@ -6,146 +6,145 @@ rather than jumping around the keyboard. Example: - ```python - # Manual inversion - first_inv = subsequence.voicings.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) - ``` + ```python + # Manual inversion + first_inv = subsequence.voicings.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 -def invert_chord (intervals: typing.List[int], inversion: int) -> typing.List[int]: +def invert_chord(intervals: typing.List[int], inversion: int) -> typing.List[int]: + """Rotate chord intervals to produce an inversion. - """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. - 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) - 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. - 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) + ``` + """ - 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) - n = len(intervals) + if n == 0: + return [] - if n == 0: - return [] + inversion = inversion % n - inversion = inversion % n + if inversion == 0: + return list(intervals) - 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]] - # 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]] +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. -def voice_lead (intervals: typing.List[int], root_midi: int, previous_voicing: typing.Optional[typing.List[int]]) -> typing.List[int]: + 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. - """Find the inversion closest to a previous voicing. + 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`` - 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. + Returns: + MIDI note numbers for the best voicing + """ - 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`` + n = len(intervals) - Returns: - MIDI note numbers for the best voicing - """ + if n == 0: + return [] - n = len(intervals) + # 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] - if n == 0: - return [] + best_voicing: typing.Optional[typing.List[int]] = None + best_cost = float("inf") - # 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] + for inv in range(n): + inv_intervals = invert_chord(intervals, inv) - best_voicing: typing.Optional[typing.List[int]] = None - best_cost = float("inf") + # 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] - for inv in range(n): - inv_intervals = invert_chord(intervals, inv) + cost = sum(abs(candidate[i] - previous_voicing[i]) for i in range(n)) - # 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] + if cost < best_cost: + best_cost = cost + best_voicing = candidate - 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 + assert best_voicing is not None + return best_voicing class VoiceLeadingState: + """Track the previous voicing across chord changes. - """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: + Each pattern that uses voice leading gets its own instance so that a bass + line and a pad can voice-lead independently. - """Start with no previous voicing.""" + 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 + ``` + """ - self.previous_voicing: typing.Optional[typing.List[int]] = None + def __init__(self) -> None: + """Start with no previous voicing.""" - def next (self, intervals: typing.List[int], root_midi: int) -> typing.List[int]: + self.previous_voicing: typing.Optional[typing.List[int]] = None - """Choose the smoothest voicing and update state. + 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 + 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 - """ + Returns: + MIDI note numbers for the chosen voicing + """ - result = voice_lead(intervals, root_midi, self.previous_voicing) - self.previous_voicing = result + result = voice_lead(intervals, root_midi, self.previous_voicing) + self.previous_voicing = result - return result + return result diff --git a/subsequence/web_ui.py b/subsequence/web_ui.py index 1424f63..c874078 100644 --- a/subsequence/web_ui.py +++ b/subsequence/web_ui.py @@ -25,313 +25,335 @@ logger = logging.getLogger(__name__) -class WebUI: - """ - Background Web UI Server. - Delivers composition state to connected web clients via WebSockets without - blocking the audio loop, and serves the static frontend assets via HTTP. - - Both servers bind to localhost (127.0.0.1) by default. Pass an explicit - ``http_host`` / ``ws_host`` (e.g. "0.0.0.0") to opt into LAN exposure: the - dashboard is read-only (inbound WebSocket messages are discarded) but it - broadcasts full composition state, so only expose it on a trusted network. - """ - - def __init__ (self, composition: typing.Any, http_port: int = 8080, ws_port: int = 8765, ws_host: str = "127.0.0.1", http_host: str = "127.0.0.1") -> None: - - """ - Prepare the dashboard servers without starting them; call start() to go live. - """ - - self.composition_ref = weakref.ref(composition) - self.http_port = http_port - self.ws_port = ws_port - self.ws_host = ws_host - self.http_host = http_host - self._http_thread: typing.Optional[threading.Thread] = None - self._httpd: typing.Optional[socketserver.TCPServer] = None - self._ws_server: typing.Optional[websockets.asyncio.server.Server] = None - self._broadcast_task: typing.Optional[asyncio.Task] = None - self._last_state: typing.Optional[typing.Dict[str, typing.Any]] = None - self._clients: typing.Set[websockets.asyncio.server.ServerConnection] = set() - self._last_bar: int = -1 - self._cached_patterns: typing.List[typing.Dict[str, typing.Any]] = [] - - def start (self) -> None: - - """ - Launch the dashboard: HTTP server for the frontend, WebSocket server for live state. - """ - - self._start_http_server() - # Keep a reference: an unreferenced task may be garbage-collected - # before it completes (asyncio docs). - self._ws_bootstrap_task = asyncio.create_task(self._start_ws_server()) - - def _start_http_server (self) -> None: - - """ - Serve the static dashboard assets on a daemon thread and log the URLs to visit. - """ - - if self._http_thread and self._http_thread.is_alive(): - return - - web_dir = os.path.join(os.path.dirname(__file__), "assets", "web") - if not os.path.exists(web_dir): - os.makedirs(web_dir, exist_ok=True) - - ws_port = self.ws_port - - class Handler(http.server.SimpleHTTPRequestHandler): - def __init__ (self, *args: typing.Any, **kwargs: typing.Any) -> None: - super().__init__(*args, directory=web_dir, **kwargs) - def log_message (self, format: str, *args: typing.Any) -> None: - pass # Suppress HTTP access logging to keep the console clean - def do_GET (self) -> None: - # Serve the dashboard with the real websocket port substituted — - # the page hardcoding 8765 made WebUI(ws_port=...) a dashboard - # that could never connect. - if self.path in ("/", "/index.html"): - try: - with open(os.path.join(web_dir, "index.html"), "r", encoding="utf-8") as fh: - page = fh.read().replace("__WS_PORT__", str(ws_port)) - except OSError: - self.send_error(404) - return - body = page.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - return - super().do_GET() - - # Bind on the main thread so stop() has a reference to shut the server - # down cleanly (serve_forever runs on the worker thread below). Localhost - # by default — see the class docstring for LAN-exposure guidance. - # Subclass rather than mutating the TCPServer CLASS attribute, which - # would change behaviour for every TCPServer in the process. - class _ReusableTCPServer (socketserver.TCPServer): - allow_reuse_address = True - self._httpd = _ReusableTCPServer((self.http_host, self.http_port), Handler) - - def run_server () -> None: - try: - assert self._httpd is not None - self._httpd.serve_forever() - except Exception as e: - logger.error(f"HTTP Server error: {e}") - - self._http_thread = threading.Thread(target=run_server, daemon=True) - self._http_thread.start() - - local_ip = subsequence.helpers.network.get_local_ip() - urls = [f"http://localhost:{self.http_port}"] - # If a distinct LAN IP was discovered, add the standard loopback and the LAN IP - if local_ip != "127.0.0.1": - urls.append(f"http://127.0.0.1:{self.http_port}") - urls.append(f"http://{local_ip}:{self.http_port}") - - logger.info("Web UI Dashboard available at:\n " + "\n ".join(urls)) - - async def _handle_client (self, websocket: websockets.asyncio.server.ServerConnection) -> None: - - """ - Track a connected browser for broadcasts; incoming messages are discarded (read-only UI). - """ - - self._clients.add(websocket) - try: - # We don't process incoming commands in the PoC, just keep alive - # and listen to keep the connection open cleanly. - async for _message in websocket: - pass - except websockets.exceptions.ConnectionClosed: - pass - finally: - self._clients.remove(websocket) - - async def _start_ws_server (self) -> None: - - """ - Open the WebSocket endpoint and kick off the periodic state broadcast. - """ - - try: - self._ws_server = await websockets.asyncio.server.serve(self._handle_client, self.ws_host, self.ws_port) - self._broadcast_task = asyncio.create_task(self._broadcast_loop()) - except Exception as e: - logger.error(f"WebSocket server error: {e}") - - async def _broadcast_loop (self) -> None: - - """ - Push composition state to all connected browsers 10x/sec, skipping unchanged frames. - """ - - while True: - # Broadcast 10 times a second to keep UI snappy without bogging down the loop - await asyncio.sleep(0.1) - - if not self._clients: - continue - - comp = self.composition_ref() - if comp is None: - break - - try: - state = self._get_state(comp) - - # Serialising the full note set 10x/sec on the audio loop is - # avoidable jitter - skip when nothing changed since last send. - if state == self._last_state: - continue - - self._last_state = state - message = json.dumps(state) - websockets.broadcast(self._clients.copy(), message) - except Exception as e: - logger.error(f"Error broadcasting UI state: {e}\n{traceback.format_exc()}") - - def _get_state (self, comp: typing.Any) -> typing.Dict[str, typing.Any]: - - """ - Snapshot the musical state of the composition (tempo, chord, section, patterns, signals) as a JSON-ready dict. - """ - - state: typing.Dict[str, typing.Any] = { - # The LIVE tempo — comp.bpm is the declared value and freezes - # during target_bpm ramps, clock-follow, and Link tempo changes. - "bpm": comp.sequencer.current_bpm if comp.sequencer else comp.bpm, - "section": None, - "chord": None, - "patterns": [], - "signals": {}, - "playhead_pulse": 0, - "pulses_per_beat": 24, - "key": comp.key, - "section_bar": None, - "section_bars": None, - "next_section": None, - "global_bar": 0, - "global_beat": 0 - } - - if comp.sequencer: - state["playhead_pulse"] = comp.sequencer.pulse_count - state["pulses_per_beat"] = comp.sequencer.pulses_per_beat - state["global_bar"] = max(0, comp.sequencer.current_bar) + 1 - state["global_beat"] = max(0, comp.sequencer.current_beat) + 1 - - if comp.form_state: - section_info = comp.form_state.get_section_info() - if section_info: - state["section"] = section_info.name - state["section_bar"] = section_info.bar + 1 - state["section_bars"] = section_info.bars - state["next_section"] = section_info.next_section - - if comp.harmonic_state and comp.harmonic_state.current_chord: - state["chord"] = comp.harmonic_state.current_chord.name() - - # Refresh pattern grid only when the bar changes, so the visual update - # is synced to when the pattern *starts playing*, not when it's rebuilt - # (which happens one lookahead beat early). - current_bar = state["global_bar"] - if current_bar != self._last_bar: - self._last_bar = current_bar - self._cached_patterns = [] - for name, pattern in comp.running_patterns.items(): - pattern_data: typing.Dict[str, typing.Any] = { - "name": name, - "muted": getattr(pattern, "_muted", False), - "length_pulses": int(pattern.length * state["pulses_per_beat"]), - "drum_map": getattr(pattern, "_drum_note_map", None), - "notes": [] - } - if hasattr(pattern, "steps"): - for pulse, step in pattern.steps.items(): - for note in getattr(step, "notes", []): - pattern_data["notes"].append({ - "p": note.pitch, - "s": pulse, - "d": note.duration, - "v": note.velocity - }) - self._cached_patterns.append(pattern_data) - state["patterns"] = self._cached_patterns - - def _extract_val (val: typing.Any) -> typing.Optional[float]: - if hasattr(val, "current"): # Matches EasedValue - try: - return float(val.current) - except Exception as e: - logger.debug(f"WebUI failed to extract float from .current on {val}: {e}") - if callable(getattr(val, "value", None)): - try: - return float(val.value()) - except Exception as e: - logger.debug(f"WebUI failed to extract float from .value() on {val}: {e}") - elif hasattr(val, "value"): - try: - return float(val.value) - except Exception as e: - logger.debug(f"WebUI failed to extract float from .value on {val}: {e}") - elif type(val) in (int, float, bool): - return float(val) - return None - - # Extract from conductor - if comp.conductor: - beat_time = comp.sequencer.pulse_count / comp.sequencer.pulses_per_beat if comp.sequencer else 0.0 - for name, signal in comp.conductor._signals.items(): - try: - state["signals"][name] = float(signal.value_at(beat_time)) - except Exception as e: - # A raising signal should be diagnosable, not vanish from - # the dashboard (same treatment as _extract_val below). - logger.debug(f"WebUI failed to read conductor signal '{name}': {e}") - - # Extract from composition data dictionary - for name, val in comp.data.items(): - extracted = _extract_val(val) - if extracted is not None: - state["signals"][name] = extracted - - return state - - def stop (self) -> None: - - """ - Shut down both servers cleanly so ports and threads don't leak. - """ - - if self._broadcast_task: - self._broadcast_task.cancel() - self._broadcast_task = None - - if self._ws_server: - self._ws_server.close() - # If the event loop is still running, await the shutdown - try: - loop = asyncio.get_running_loop() - loop.create_task(self._ws_server.wait_closed()) - except RuntimeError: - pass - self._ws_server = None - - # Shut the HTTP server down cleanly so the listening port and worker - # thread don't leak for the life of the process. serve_forever() runs on - # _http_thread, so shutdown() must be called from another thread (here). - if self._httpd is not None: - self._httpd.shutdown() - self._httpd.server_close() - self._httpd = None - - if self._http_thread is not None: - self._http_thread.join(timeout=2.0) - self._http_thread = None +class WebUI: + """ + Background Web UI Server. + Delivers composition state to connected web clients via WebSockets without + blocking the audio loop, and serves the static frontend assets via HTTP. + + Both servers bind to localhost (127.0.0.1) by default. Pass an explicit + ``http_host`` / ``ws_host`` (e.g. "0.0.0.0") to opt into LAN exposure: the + dashboard is read-only (inbound WebSocket messages are discarded) but it + broadcasts full composition state, so only expose it on a trusted network. + """ + + def __init__( + self, + composition: typing.Any, + http_port: int = 8080, + ws_port: int = 8765, + ws_host: str = "127.0.0.1", + http_host: str = "127.0.0.1", + ) -> None: + """ + Prepare the dashboard servers without starting them; call start() to go live. + """ + + self.composition_ref = weakref.ref(composition) + self.http_port = http_port + self.ws_port = ws_port + self.ws_host = ws_host + self.http_host = http_host + self._http_thread: typing.Optional[threading.Thread] = None + self._httpd: typing.Optional[socketserver.TCPServer] = None + self._ws_server: typing.Optional[websockets.asyncio.server.Server] = None + self._broadcast_task: typing.Optional[asyncio.Task] = None + self._last_state: typing.Optional[typing.Dict[str, typing.Any]] = None + self._clients: typing.Set[websockets.asyncio.server.ServerConnection] = set() + self._last_bar: int = -1 + self._cached_patterns: typing.List[typing.Dict[str, typing.Any]] = [] + + def start(self) -> None: + """ + Launch the dashboard: HTTP server for the frontend, WebSocket server for live state. + """ + + self._start_http_server() + # Keep a reference: an unreferenced task may be garbage-collected + # before it completes (asyncio docs). + self._ws_bootstrap_task = asyncio.create_task(self._start_ws_server()) + + def _start_http_server(self) -> None: + """ + Serve the static dashboard assets on a daemon thread and log the URLs to visit. + """ + + if self._http_thread and self._http_thread.is_alive(): + return + + web_dir = os.path.join(os.path.dirname(__file__), "assets", "web") + if not os.path.exists(web_dir): + os.makedirs(web_dir, exist_ok=True) + + ws_port = self.ws_port + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + super().__init__(*args, directory=web_dir, **kwargs) + + def log_message(self, format: str, *args: typing.Any) -> None: + pass # Suppress HTTP access logging to keep the console clean + + def do_GET(self) -> None: + # Serve the dashboard with the real websocket port substituted — + # the page hardcoding 8765 made WebUI(ws_port=...) a dashboard + # that could never connect. + if self.path in ("/", "/index.html"): + try: + with open( + os.path.join(web_dir, "index.html"), "r", encoding="utf-8" + ) as fh: + page = fh.read().replace("__WS_PORT__", str(ws_port)) + except OSError: + self.send_error(404) + return + body = page.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + super().do_GET() + + # Bind on the main thread so stop() has a reference to shut the server + # down cleanly (serve_forever runs on the worker thread below). Localhost + # by default — see the class docstring for LAN-exposure guidance. + # Subclass rather than mutating the TCPServer CLASS attribute, which + # would change behaviour for every TCPServer in the process. + class _ReusableTCPServer(socketserver.TCPServer): + allow_reuse_address = True + + self._httpd = _ReusableTCPServer((self.http_host, self.http_port), Handler) + + def run_server() -> None: + try: + assert self._httpd is not None + self._httpd.serve_forever() + except Exception as e: + logger.error(f"HTTP Server error: {e}") + + self._http_thread = threading.Thread(target=run_server, daemon=True) + self._http_thread.start() + + local_ip = subsequence.helpers.network.get_local_ip() + urls = [f"http://localhost:{self.http_port}"] + # If a distinct LAN IP was discovered, add the standard loopback and the LAN IP + if local_ip != "127.0.0.1": + urls.append(f"http://127.0.0.1:{self.http_port}") + urls.append(f"http://{local_ip}:{self.http_port}") + + logger.info("Web UI Dashboard available at:\n " + "\n ".join(urls)) + + async def _handle_client( + self, websocket: websockets.asyncio.server.ServerConnection + ) -> None: + """ + Track a connected browser for broadcasts; incoming messages are discarded (read-only UI). + """ + + self._clients.add(websocket) + try: + # We don't process incoming commands in the PoC, just keep alive + # and listen to keep the connection open cleanly. + async for _message in websocket: + pass + except websockets.exceptions.ConnectionClosed: + pass + finally: + self._clients.remove(websocket) + + async def _start_ws_server(self) -> None: + """ + Open the WebSocket endpoint and kick off the periodic state broadcast. + """ + + try: + self._ws_server = await websockets.asyncio.server.serve( + self._handle_client, self.ws_host, self.ws_port + ) + self._broadcast_task = asyncio.create_task(self._broadcast_loop()) + except Exception as e: + logger.error(f"WebSocket server error: {e}") + + async def _broadcast_loop(self) -> None: + """ + Push composition state to all connected browsers 10x/sec, skipping unchanged frames. + """ + + while True: + # Broadcast 10 times a second to keep UI snappy without bogging down the loop + await asyncio.sleep(0.1) + + if not self._clients: + continue + + comp = self.composition_ref() + if comp is None: + break + + try: + state = self._get_state(comp) + + # Serialising the full note set 10x/sec on the audio loop is + # avoidable jitter - skip when nothing changed since last send. + if state == self._last_state: + continue + + self._last_state = state + message = json.dumps(state) + websockets.broadcast(self._clients.copy(), message) + except Exception as e: + logger.error( + f"Error broadcasting UI state: {e}\n{traceback.format_exc()}" + ) + + def _get_state(self, comp: typing.Any) -> typing.Dict[str, typing.Any]: + """ + Snapshot the musical state of the composition (tempo, chord, section, patterns, signals) as a JSON-ready dict. + """ + + state: typing.Dict[str, typing.Any] = { + # The LIVE tempo — comp.bpm is the declared value and freezes + # during target_bpm ramps, clock-follow, and Link tempo changes. + "bpm": comp.sequencer.current_bpm if comp.sequencer else comp.bpm, + "section": None, + "chord": None, + "patterns": [], + "signals": {}, + "playhead_pulse": 0, + "pulses_per_beat": 24, + "key": comp.key, + "section_bar": None, + "section_bars": None, + "next_section": None, + "global_bar": 0, + "global_beat": 0, + } + + if comp.sequencer: + state["playhead_pulse"] = comp.sequencer.pulse_count + state["pulses_per_beat"] = comp.sequencer.pulses_per_beat + state["global_bar"] = max(0, comp.sequencer.current_bar) + 1 + state["global_beat"] = max(0, comp.sequencer.current_beat) + 1 + + if comp.form_state: + section_info = comp.form_state.get_section_info() + if section_info: + state["section"] = section_info.name + state["section_bar"] = section_info.bar + 1 + state["section_bars"] = section_info.bars + state["next_section"] = section_info.next_section + + if comp.harmonic_state and comp.harmonic_state.current_chord: + state["chord"] = comp.harmonic_state.current_chord.name() + + # Refresh pattern grid only when the bar changes, so the visual update + # is synced to when the pattern *starts playing*, not when it's rebuilt + # (which happens one lookahead beat early). + current_bar = state["global_bar"] + if current_bar != self._last_bar: + self._last_bar = current_bar + self._cached_patterns = [] + for name, pattern in comp.running_patterns.items(): + pattern_data: typing.Dict[str, typing.Any] = { + "name": name, + "muted": getattr(pattern, "_muted", False), + "length_pulses": int(pattern.length * state["pulses_per_beat"]), + "drum_map": getattr(pattern, "_drum_note_map", None), + "notes": [], + } + if hasattr(pattern, "steps"): + for pulse, step in pattern.steps.items(): + for note in getattr(step, "notes", []): + pattern_data["notes"].append( + { + "p": note.pitch, + "s": pulse, + "d": note.duration, + "v": note.velocity, + } + ) + self._cached_patterns.append(pattern_data) + state["patterns"] = self._cached_patterns + + def _extract_val(val: typing.Any) -> typing.Optional[float]: + if hasattr(val, "current"): # Matches EasedValue + try: + return float(val.current) + except Exception as e: + logger.debug( + f"WebUI failed to extract float from .current on {val}: {e}" + ) + if callable(getattr(val, "value", None)): + try: + return float(val.value()) + except Exception as e: + logger.debug( + f"WebUI failed to extract float from .value() on {val}: {e}" + ) + elif hasattr(val, "value"): + try: + return float(val.value) + except Exception as e: + logger.debug( + f"WebUI failed to extract float from .value on {val}: {e}" + ) + elif type(val) in (int, float, bool): + return float(val) + return None + + # Extract from conductor + if comp.conductor: + beat_time = ( + comp.sequencer.pulse_count / comp.sequencer.pulses_per_beat + if comp.sequencer + else 0.0 + ) + for name, signal in comp.conductor._signals.items(): + try: + state["signals"][name] = float(signal.value_at(beat_time)) + except Exception as e: + # A raising signal should be diagnosable, not vanish from + # the dashboard (same treatment as _extract_val below). + logger.debug(f"WebUI failed to read conductor signal '{name}': {e}") + + # Extract from composition data dictionary + for name, val in comp.data.items(): + extracted = _extract_val(val) + if extracted is not None: + state["signals"][name] = extracted + + return state + + def stop(self) -> None: + """ + Shut down both servers cleanly so ports and threads don't leak. + """ + + if self._broadcast_task: + self._broadcast_task.cancel() + self._broadcast_task = None + + if self._ws_server: + self._ws_server.close() + # If the event loop is still running, await the shutdown + try: + loop = asyncio.get_running_loop() + loop.create_task(self._ws_server.wait_closed()) + except RuntimeError: + pass + self._ws_server = None + + # Shut the HTTP server down cleanly so the listening port and worker + # thread don't leak for the life of the process. serve_forever() runs on + # _http_thread, so shutdown() must be called from another thread (here). + if self._httpd is not None: + self._httpd.shutdown() + self._httpd.server_close() + self._httpd = None + + if self._http_thread is not None: + self._http_thread.join(timeout=2.0) + self._http_thread = None diff --git a/subsequence/weighted_graph.py b/subsequence/weighted_graph.py index 6ace96b..6b6a9ed 100644 --- a/subsequence/weighted_graph.py +++ b/subsequence/weighted_graph.py @@ -6,151 +6,151 @@ WeightModifierType = typing.Optional[typing.Callable[[NodeType, NodeType, int], float]] -class WeightedGraph (typing.Generic[NodeType]): +class WeightedGraph(typing.Generic[NodeType]): + """ + A weighted directed graph with optional runtime weight adjustment. + """ + + def __init__(self) -> None: + """ + Initialize an empty weighted graph. + """ + + self._edges: typing.Dict[NodeType, typing.Dict[NodeType, int]] = {} + self._labels: typing.Dict[typing.Tuple[NodeType, NodeType], str] = {} + + def add_transition( + self, + source: NodeType, + target: NodeType, + weight: int, + label: typing.Optional[str] = None, + ) -> None: + """ + Add a weighted transition between two nodes. + + Parameters: + source: Node the transition leaves from. + target: Node the transition arrives at. + weight: Positive transition weight. Re-adding an existing + transition accumulates, strengthening the edge. + label: Optional edge label naming the transition's musical + function (e.g. ``"cadence"``, ``"deceptive"``). A label + given on a re-add replaces the previous one; ``None`` + leaves any existing label untouched. + """ + + if weight <= 0: + raise ValueError("Weight must be positive") + + if source not in self._edges: + self._edges[source] = {} + + # If a transition already exists, accumulate to strengthen the edge. + if target in self._edges[source]: + self._edges[source][target] += weight + + else: + self._edges[source][target] = weight + + if label is not None: + self._labels[(source, target)] = label + + def get_label(self, source: NodeType, target: NodeType) -> typing.Optional[str]: + """ + Return the label for a transition, or None if it has none. + """ + + return self._labels.get((source, target)) + + def transitions_with_label( + self, source: NodeType, label: str + ) -> typing.List[typing.Tuple[NodeType, int]]: + """ + Return the outgoing transitions from *source* that carry *label*. + """ + + return [ + (target, weight) + for target, weight in self.get_transitions(source) + if self._labels.get((source, target)) == label + ] + + def nodes(self) -> typing.List[NodeType]: + """ + Return every node that appears in the graph (as a source or a target), + in first-seen order. + """ + + seen: typing.Dict[NodeType, None] = {} + + for source, targets in self._edges.items(): + seen.setdefault(source) + for target in targets: + seen.setdefault(target) + + return list(seen) + + def get_transitions( + self, source: NodeType + ) -> typing.List[typing.Tuple[NodeType, int]]: + """ + Return weighted transitions for a source node. + """ + + if source not in self._edges: + return [] + + return list(self._edges[source].items()) + + def choose_next( + self, + source: NodeType, + rng: random.Random, + weight_modifier: WeightModifierType = None, + ) -> NodeType: + """ + Choose the next node from a source using weighted randomness. + + Returns *source* unchanged if the node has no outgoing transitions, + or if every outgoing transition has been suppressed by a weight + modifier that returned zero or a negative value. + """ + + options = self.get_transitions(source) + + if not options: + # Decision path: with no outgoing edges we remain on the current node. + return source + + adjusted: typing.List[typing.Tuple[NodeType, float]] = [] + total_weight = 0.0 + + for target, weight in options: + if weight_modifier is None: + modifier = 1.0 + + else: + modifier = float(weight_modifier(source, target, weight)) + + if modifier <= 0: + # Decision path: non-positive modifiers suppress this transition entirely. + continue - """ - A weighted directed graph with optional runtime weight adjustment. - """ + adjusted_weight = float(weight) * modifier + adjusted.append((target, adjusted_weight)) + total_weight += adjusted_weight - def __init__ (self) -> None: + if total_weight <= 0: + # Decision path: if every transition is suppressed, stay on the current node. + return source - """ - Initialize an empty weighted graph. - """ + roll = rng.uniform(0, total_weight) + accum = 0.0 - self._edges: typing.Dict[NodeType, typing.Dict[NodeType, int]] = {} - self._labels: typing.Dict[typing.Tuple[NodeType, NodeType], str] = {} + for target, adj_weight in adjusted: + accum += adj_weight + if roll <= accum: + return target - - def add_transition (self, source: NodeType, target: NodeType, weight: int, label: typing.Optional[str] = None) -> None: - - """ - Add a weighted transition between two nodes. - - Parameters: - source: Node the transition leaves from. - target: Node the transition arrives at. - weight: Positive transition weight. Re-adding an existing - transition accumulates, strengthening the edge. - label: Optional edge label naming the transition's musical - function (e.g. ``"cadence"``, ``"deceptive"``). A label - given on a re-add replaces the previous one; ``None`` - leaves any existing label untouched. - """ - - if weight <= 0: - raise ValueError("Weight must be positive") - - if source not in self._edges: - self._edges[source] = {} - - # If a transition already exists, accumulate to strengthen the edge. - if target in self._edges[source]: - self._edges[source][target] += weight - - else: - self._edges[source][target] = weight - - if label is not None: - self._labels[(source, target)] = label - - - def get_label (self, source: NodeType, target: NodeType) -> typing.Optional[str]: - - """ - Return the label for a transition, or None if it has none. - """ - - return self._labels.get((source, target)) - - - def transitions_with_label (self, source: NodeType, label: str) -> typing.List[typing.Tuple[NodeType, int]]: - - """ - Return the outgoing transitions from *source* that carry *label*. - """ - - return [ - (target, weight) - for target, weight in self.get_transitions(source) - if self._labels.get((source, target)) == label - ] - - - def nodes (self) -> typing.List[NodeType]: - - """ - Return every node that appears in the graph (as a source or a target), - in first-seen order. - """ - - seen: typing.Dict[NodeType, None] = {} - - for source, targets in self._edges.items(): - seen.setdefault(source) - for target in targets: - seen.setdefault(target) - - return list(seen) - - - def get_transitions (self, source: NodeType) -> typing.List[typing.Tuple[NodeType, int]]: - - """ - Return weighted transitions for a source node. - """ - - if source not in self._edges: - return [] - - return list(self._edges[source].items()) - - - def choose_next (self, source: NodeType, rng: random.Random, weight_modifier: WeightModifierType = None) -> NodeType: - - """ - Choose the next node from a source using weighted randomness. - - Returns *source* unchanged if the node has no outgoing transitions, - or if every outgoing transition has been suppressed by a weight - modifier that returned zero or a negative value. - """ - - options = self.get_transitions(source) - - if not options: - # Decision path: with no outgoing edges we remain on the current node. - return source - - adjusted: typing.List[typing.Tuple[NodeType, float]] = [] - total_weight = 0.0 - - for target, weight in options: - - if weight_modifier is None: - modifier = 1.0 - - else: - modifier = float(weight_modifier(source, target, weight)) - - if modifier <= 0: - # Decision path: non-positive modifiers suppress this transition entirely. - continue - - adjusted_weight = float(weight) * modifier - adjusted.append((target, adjusted_weight)) - total_weight += adjusted_weight - - if total_weight <= 0: - # Decision path: if every transition is suppressed, stay on the current node. - return source - - roll = rng.uniform(0, total_weight) - accum = 0.0 - - for target, adj_weight in adjusted: - accum += adj_weight - if roll <= accum: - return target - - return adjusted[-1][0] + return adjusted[-1][0] diff --git a/tests/conftest.py b/tests/conftest.py index 8431538..038670b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,165 +5,148 @@ class FakeMidiOut: + """Minimal MIDI output stub for tests.""" - """Minimal MIDI output stub for tests.""" + def send(self, message: mido.Message) -> None: + """Ignore outgoing MIDI messages.""" - def send (self, message: mido.Message) -> None: + return None - """Ignore outgoing MIDI messages.""" + def close(self) -> None: + """No-op close for the fake device.""" - return None + return None + def panic(self) -> None: + """No-op panic for the fake device.""" - def close (self) -> None: + return None - """No-op close for the fake device.""" + def reset(self) -> None: + """No-op reset for the fake device.""" - return None - - - def panic (self) -> None: - - """No-op panic for the fake device.""" - - return None - - - def reset (self) -> None: - - """No-op reset for the fake device.""" - - return None + return None class SpyMidiOut(FakeMidiOut): + """FakeMidiOut that records every sent message for test assertions.""" - """FakeMidiOut that records every sent message for test assertions.""" - - def __init__ (self) -> None: - - self.sent: typing.List[mido.Message] = [] + def __init__(self) -> None: + self.sent: typing.List[mido.Message] = [] - - def send (self, message: mido.Message) -> None: - - self.sent.append(message) + def send(self, message: mido.Message) -> None: + self.sent.append(message) class FakeMidiIn: + """Minimal MIDI input stub for tests.""" - """Minimal MIDI input stub for tests.""" - - def __init__ (self, callback: typing.Optional[typing.Callable] = None) -> None: + def __init__(self, callback: typing.Optional[typing.Callable] = None) -> None: + """Store the callback for injecting test messages.""" - """Store the callback for injecting test messages.""" + self.callback = callback - self.callback = callback + def close(self) -> None: + """No-op close for the fake device.""" - def close (self) -> None: + return None - """No-op close for the fake device.""" + def inject(self, message: mido.Message) -> None: + """Simulate receiving a MIDI message by calling the stored callback.""" - return None + if self.callback is not None: + self.callback(message) - def inject (self, message: mido.Message) -> None: - """Simulate receiving a MIDI message by calling the stored callback.""" +def _fake_get_output_names() -> list[str]: + """Return a fixed list of MIDI output names for tests.""" - if self.callback is not None: - self.callback(message) + return ["Dummy MIDI"] -def _fake_get_output_names () -> list[str]: +def _fake_open_output(name: str) -> FakeMidiOut: + """Return a fake MIDI output regardless of the name.""" - """Return a fixed list of MIDI output names for tests.""" - - return ["Dummy MIDI"] - - -def _fake_open_output (name: str) -> FakeMidiOut: - - """Return a fake MIDI output regardless of the name.""" - - return FakeMidiOut() + return FakeMidiOut() # Module-level reference so tests can access the most recently created FakeMidiIn. _current_fake_input: typing.Optional[FakeMidiIn] = None -def _fake_get_input_names () -> list[str]: - - """Return a fixed list of MIDI input names for tests.""" +def _fake_get_input_names() -> list[str]: + """Return a fixed list of MIDI input names for tests.""" - return ["Dummy MIDI"] + return ["Dummy MIDI"] -def _fake_open_input (name: str, callback: typing.Optional[typing.Callable] = None) -> FakeMidiIn: +def _fake_open_input( + name: str, callback: typing.Optional[typing.Callable] = None +) -> FakeMidiIn: + """Return a fake MIDI input regardless of the name.""" - """Return a fake MIDI input regardless of the name.""" - - global _current_fake_input - fake = FakeMidiIn(callback=callback) - _current_fake_input = fake - return fake + global _current_fake_input + fake = FakeMidiIn(callback=callback) + _current_fake_input = fake + return fake @pytest.fixture -def patch_midi (monkeypatch: pytest.MonkeyPatch) -> None: - - """Patch mido to use fake MIDI output and input for all tests that need it.""" +def patch_midi(monkeypatch: pytest.MonkeyPatch) -> None: + """Patch mido to use fake MIDI output and input for all tests that need it.""" - monkeypatch.setattr(mido, "get_output_names", _fake_get_output_names) - monkeypatch.setattr(mido, "open_output", _fake_open_output) - monkeypatch.setattr(mido, "get_input_names", _fake_get_input_names) - monkeypatch.setattr(mido, "open_input", _fake_open_input) + monkeypatch.setattr(mido, "get_output_names", _fake_get_output_names) + monkeypatch.setattr(mido, "open_output", _fake_open_output) + monkeypatch.setattr(mido, "get_input_names", _fake_get_input_names) + monkeypatch.setattr(mido, "open_input", _fake_open_input) # --------------------------------------------------------------------------- # Multi-device helpers # --------------------------------------------------------------------------- -class NamedSpyMidiOut(SpyMidiOut): - - """SpyMidiOut with a human-readable name for multi-device tests.""" - def __init__ (self, name: str = "unnamed") -> None: - super().__init__() - self.name = name +class NamedSpyMidiOut(SpyMidiOut): + """SpyMidiOut with a human-readable name for multi-device tests.""" + def __init__(self, name: str = "unnamed") -> None: + super().__init__() + self.name = name -def _make_multi_output_patcher (device_names: typing.List[str]) -> typing.Callable: - """Return an open_output stub that creates a NamedSpyMidiOut per device name.""" +def _make_multi_output_patcher(device_names: typing.List[str]) -> typing.Callable: + """Return an open_output stub that creates a NamedSpyMidiOut per device name.""" - ports: typing.Dict[str, NamedSpyMidiOut] = {n: NamedSpyMidiOut(n) for n in device_names} + ports: typing.Dict[str, NamedSpyMidiOut] = { + n: NamedSpyMidiOut(n) for n in device_names + } - def _open (name: str) -> NamedSpyMidiOut: - if name not in ports: - ports[name] = NamedSpyMidiOut(name) - return ports[name] + def _open(name: str) -> NamedSpyMidiOut: + if name not in ports: + ports[name] = NamedSpyMidiOut(name) + return ports[name] - _open._ports = ports # type: ignore[attr-defined] - return _open + _open._ports = ports # type: ignore[attr-defined] + return _open @pytest.fixture -def patch_midi_multi (monkeypatch: pytest.MonkeyPatch) -> typing.Dict[str, NamedSpyMidiOut]: - - """Patch mido for multi-device tests. +def patch_midi_multi( + monkeypatch: pytest.MonkeyPatch, +) -> typing.Dict[str, NamedSpyMidiOut]: + """Patch mido for multi-device tests. - Returns a dict mapping device names to their NamedSpyMidiOut instances. - Registers three outputs: 'Primary MIDI', 'Secondary MIDI', and 'Third MIDI'. - """ + Returns a dict mapping device names to their NamedSpyMidiOut instances. + Registers three outputs: 'Primary MIDI', 'Secondary MIDI', and 'Third MIDI'. + """ - device_names = ["Primary MIDI", "Secondary MIDI", "Third MIDI"] + device_names = ["Primary MIDI", "Secondary MIDI", "Third MIDI"] - opener = _make_multi_output_patcher(device_names) + opener = _make_multi_output_patcher(device_names) - monkeypatch.setattr(mido, "get_output_names", lambda: device_names) - monkeypatch.setattr(mido, "open_output", opener) - monkeypatch.setattr(mido, "get_input_names", lambda: ["Input A", "Input B"]) - monkeypatch.setattr(mido, "open_input", _fake_open_input) + monkeypatch.setattr(mido, "get_output_names", lambda: device_names) + monkeypatch.setattr(mido, "open_output", opener) + monkeypatch.setattr(mido, "get_input_names", lambda: ["Input A", "Input B"]) + monkeypatch.setattr(mido, "open_input", _fake_open_input) - return opener._ports # type: ignore[attr-defined] + return opener._ports # type: ignore[attr-defined] diff --git a/tests/test_api_consistency.py b/tests/test_api_consistency.py index eb77ac9..41ef0f2 100644 --- a/tests/test_api_consistency.py +++ b/tests/test_api_consistency.py @@ -22,33 +22,35 @@ import subsequence.pattern_builder -def _make_builder ( - length: float = 4, - cycle: int = 0, -) -> typing.Tuple[subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder]: - - """Create a Pattern and PatternBuilder pair for testing (no MIDI required).""" - - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - pattern = subsequence.pattern.Pattern(channel=0, length=length) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=cycle, - default_grid=default_grid, - data={}, - ) - return pattern, builder - - -def _placements (pattern: subsequence.pattern.Pattern) -> typing.List[typing.Tuple[int, int, int]]: - - """Flatten placed notes into a comparable, order-stable list of ``(pulse, pitch, velocity)``.""" - - out: typing.List[typing.Tuple[int, int, int]] = [] - for pulse in sorted(pattern.steps.keys()): - for note in pattern.steps[pulse].notes: - out.append((pulse, note.pitch, note.velocity)) - return out +def _make_builder( + length: float = 4, + cycle: int = 0, +) -> typing.Tuple[ + subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder +]: + """Create a Pattern and PatternBuilder pair for testing (no MIDI required).""" + + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pattern = subsequence.pattern.Pattern(channel=0, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=cycle, + default_grid=default_grid, + data={}, + ) + return pattern, builder + + +def _placements( + pattern: subsequence.pattern.Pattern, +) -> typing.List[typing.Tuple[int, int, int]]: + """Flatten placed notes into a comparable, order-stable list of ``(pulse, pitch, velocity)``.""" + + out: typing.List[typing.Tuple[int, int, int]] = [] + for pulse in sorted(pattern.steps.keys()): + for note in pattern.steps[pulse].notes: + out.append((pulse, note.pitch, note.velocity)) + return out # ── R1: the inverted generators draw ZERO times at the default probability=1.0 ── @@ -58,138 +60,145 @@ def _placements (pattern: subsequence.pattern.Pattern) -> typing.List[typing.Tup # seeded compositions reproduce bit-for-bit. A fixed int velocity rules out any # velocity-tuple draw, isolating the placement guard. -@pytest.mark.parametrize("place", [ - lambda b, rng: b.euclidean(36, pulses=5, velocity=100, rng=rng), - lambda b, rng: b.cellular_1d(36, velocity=100, rng=rng), - lambda b, rng: b.thue_morse(36, velocity=100, rng=rng), - lambda b, rng: b.reaction_diffusion(36, velocity=100, rng=rng), - lambda b, rng: b.cellular_2d([36, 38, 42], velocity=100, generation=0, rng=rng), -]) -def test_inverted_generator_draws_nothing_at_default_probability (place: typing.Callable) -> None: - _, builder = _make_builder() - rng = random.Random(0) - state_before = rng.getstate() - place(builder, rng) - assert rng.getstate() == state_before - - -def test_euclidean_draws_when_probability_below_one () -> None: - - _, builder = _make_builder() - rng = random.Random(0) - state_before = rng.getstate() - builder.euclidean(36, pulses=8, velocity=100, probability=0.5, rng=rng) - assert rng.getstate() != state_before +@pytest.mark.parametrize( + "place", + [ + lambda b, rng: b.euclidean(36, pulses=5, velocity=100, rng=rng), + lambda b, rng: b.cellular_1d(36, velocity=100, rng=rng), + lambda b, rng: b.thue_morse(36, velocity=100, rng=rng), + lambda b, rng: b.reaction_diffusion(36, velocity=100, rng=rng), + lambda b, rng: b.cellular_2d([36, 38, 42], velocity=100, generation=0, rng=rng), + ], +) +def test_inverted_generator_draws_nothing_at_default_probability( + place: typing.Callable, +) -> None: + _, builder = _make_builder() + rng = random.Random(0) + state_before = rng.getstate() + place(builder, rng) + assert rng.getstate() == state_before + + +def test_euclidean_draws_when_probability_below_one() -> None: + _, builder = _make_builder() + rng = random.Random(0) + state_before = rng.getstate() + builder.euclidean(36, pulses=8, velocity=100, probability=0.5, rng=rng) + assert rng.getstate() != state_before # ── The renames are hard breaks: the old keywords are gone, not aliased. ── -def test_removed_alias_keywords_now_raise () -> None: - chord = subsequence.chords.Chord(root_pc=0, quality="major") - _, builder = _make_builder() +def test_removed_alias_keywords_now_raise() -> None: + chord = subsequence.chords.Chord(root_pc=0, quality="major") + _, builder = _make_builder() - with pytest.raises(TypeError): - builder.euclidean(36, pulses=5, dropout=0.3) # dropout= → probability= + with pytest.raises(TypeError): + builder.euclidean(36, pulses=5, dropout=0.3) # dropout= → probability= - with pytest.raises(TypeError): - builder.strum(chord, root=60, offset=0.1) # offset= → spacing= + with pytest.raises(TypeError): + builder.strum(chord, root=60, offset=0.1) # offset= → spacing= - with pytest.raises(TypeError): - builder.swing(amount=60) # amount= → percent= + with pytest.raises(TypeError): + builder.swing(amount=60) # amount= → percent= - with pytest.raises(TypeError): - builder.fibonacci(36, steps=5) # steps= → count= + with pytest.raises(TypeError): + builder.fibonacci(36, steps=5) # steps= → count= - with pytest.raises(TypeError): - builder.evolve([60, 64], steps=4) # steps= → length= + with pytest.raises(TypeError): + builder.evolve([60, 64], steps=4) # steps= → length= # ── The shared _rng_from resolver: precedence rng > seed > self.rng, and a warning # when both seed= and rng= are supplied. ── -def test_rng_from_precedence_and_double_warning () -> None: - _, builder = _make_builder() +def test_rng_from_precedence_and_double_warning() -> None: + _, builder = _make_builder() - explicit = random.Random(123) + explicit = random.Random(123) - # rng= wins over seed=, and warns about the redundancy. - with pytest.warns(UserWarning, match="both"): - assert builder._rng_from(5, explicit) is explicit + # rng= wins over seed=, and warns about the redundancy. + with pytest.warns(UserWarning, match="both"): + assert builder._rng_from(5, explicit) is explicit - # seed= alone → a fresh Random(seed), independent of the pattern's own RNG. - resolved = builder._rng_from(5, None) - assert resolved is not builder.rng - assert resolved.random() == random.Random(5).random() + # seed= alone → a fresh Random(seed), independent of the pattern's own RNG. + resolved = builder._rng_from(5, None) + assert resolved is not builder.rng + assert resolved.random() == random.Random(5).random() - # Neither → the pattern's own RNG. - assert builder._rng_from(None, None) is builder.rng + # Neither → the pattern's own RNG. + assert builder._rng_from(None, None) is builder.rng # ── cellular_2d: the seed/grid split — initial_state= ("center"/"random"/grid) plus an # integer seed= for a reproducible random fill. ── -def test_cellular_2d_center_is_the_default () -> None: - - p_default, b_default = _make_builder() - p_explicit, b_explicit = _make_builder() - - b_default.cellular_2d([36, 38, 42], velocity=100, generation=0) - b_explicit.cellular_2d([36, 38, 42], velocity=100, generation=0, initial_state="center") - - assert _placements(p_default) == _placements(p_explicit) +def test_cellular_2d_center_is_the_default() -> None: + p_default, b_default = _make_builder() + p_explicit, b_explicit = _make_builder() -def test_cellular_2d_random_is_reproducible_with_seed () -> None: + b_default.cellular_2d([36, 38, 42], velocity=100, generation=0) + b_explicit.cellular_2d( + [36, 38, 42], velocity=100, generation=0, initial_state="center" + ) - p1, b1 = _make_builder() - p2, b2 = _make_builder() + assert _placements(p_default) == _placements(p_explicit) - b1.cellular_2d([36, 38, 42], velocity=100, generation=3, initial_state="random", seed=7) - b2.cellular_2d([36, 38, 42], velocity=100, generation=3, initial_state="random", seed=7) - assert _placements(p1) == _placements(p2) - assert _placements(p1) # a random fill placed at least one note +def test_cellular_2d_random_is_reproducible_with_seed() -> None: + p1, b1 = _make_builder() + p2, b2 = _make_builder() + b1.cellular_2d( + [36, 38, 42], velocity=100, generation=3, initial_state="random", seed=7 + ) + b2.cellular_2d( + [36, 38, 42], velocity=100, generation=3, initial_state="random", seed=7 + ) -def test_cellular_2d_grid_via_initial_state () -> None: + assert _placements(p1) == _placements(p2) + assert _placements(p1) # a random fill placed at least one note - # Row 0 (pitch 36) all live, the other rows empty; generation=0 keeps it as-is. - grid = [[1] * 16, [0] * 16, [0] * 16] - pattern, builder = _make_builder() - builder.cellular_2d([36, 38, 42], velocity=100, generation=0, initial_state=grid) +def test_cellular_2d_grid_via_initial_state() -> None: + # Row 0 (pitch 36) all live, the other rows empty; generation=0 keeps it as-is. + grid = [[1] * 16, [0] * 16, [0] * 16] + pattern, builder = _make_builder() - pitches = {pitch for _, pitch, _ in _placements(pattern)} - assert pitches == {36} + builder.cellular_2d([36, 38, 42], velocity=100, generation=0, initial_state=grid) + pitches = {pitch for _, pitch, _ in _placements(pattern)} + assert pitches == {36} -def test_cellular_2d_seed_with_center_warns () -> None: - """Passing seed= with a non-random initial_state warns that the seed is ignored. +def test_cellular_2d_seed_with_center_warns() -> None: + """Passing seed= with a non-random initial_state warns that the seed is ignored. - Regression: cellular_2d(pitches, seed=7) at the default initial_state="center" - silently dropped the seed — a footgun once the old int-or-grid seed= was removed. - """ + Regression: cellular_2d(pitches, seed=7) at the default initial_state="center" + silently dropped the seed — a footgun once the old int-or-grid seed= was removed. + """ - _, builder = _make_builder() + _, builder = _make_builder() - with pytest.warns(UserWarning, match="seed="): - builder.cellular_2d([36, 38, 42], velocity=100, generation=0, seed=7) + with pytest.warns(UserWarning, match="seed="): + builder.cellular_2d([36, 38, 42], velocity=100, generation=0, seed=7) # ── P2: the one approved sound change — broken_chord is a chord voice, so its # default velocity is the softer chord bucket (90), matching chord()/strum(). ── -def test_broken_chord_defaults_to_chord_velocity () -> None: - chord = subsequence.chords.Chord(root_pc=0, quality="major") - pattern, builder = _make_builder() +def test_broken_chord_defaults_to_chord_velocity() -> None: + chord = subsequence.chords.Chord(root_pc=0, quality="major") + pattern, builder = _make_builder() - builder.broken_chord(chord, root=60, order=[0, 1, 2]) + builder.broken_chord(chord, root=60, order=[0, 1, 2]) - velocities = {velocity for _, _, velocity in _placements(pattern)} - assert velocities == {subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY} - assert subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY == 90 + velocities = {velocity for _, _, velocity in _placements(pattern)} + assert velocities == {subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY} + assert subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY == 90 diff --git a/tests/test_cadences.py b/tests/test_cadences.py index 94bdc88..bef01cb 100644 --- a/tests/test_cadences.py +++ b/tests/test_cadences.py @@ -28,50 +28,47 @@ # --------------------------------------------------------------------------- -def test_cadence_table_producer_names () -> None: +def test_cadence_table_producer_names() -> None: + """The four producer names map to the documented formulas and closes.""" - """The four producer names map to the documented formulas and closes.""" + strong = subsequence.cadences.cadence_formula("strong") + assert strong.formula == ("V", 1) + assert strong.close_degree == 1 + assert strong.theory_name == "authentic" - strong = subsequence.cadences.cadence_formula("strong") - assert strong.formula == ("V", 1) - assert strong.close_degree == 1 - assert strong.theory_name == "authentic" + soft = subsequence.cadences.cadence_formula("soft") + assert soft.formula == (4, 1) + assert soft.close_degree == 1 - soft = subsequence.cadences.cadence_formula("soft") - assert soft.formula == (4, 1) - assert soft.close_degree == 1 + open_half = subsequence.cadences.cadence_formula("open") + assert open_half.formula == (4, "V") + assert open_half.close_degree == 5 - open_half = subsequence.cadences.cadence_formula("open") - assert open_half.formula == (4, "V") - assert open_half.close_degree == 5 + fakeout = subsequence.cadences.cadence_formula("fakeout") + assert fakeout.formula == ("V", 6) + assert fakeout.close_degree == 1 - fakeout = subsequence.cadences.cadence_formula("fakeout") - assert fakeout.formula == ("V", 6) - assert fakeout.close_degree == 1 +def test_cadence_theory_aliases() -> None: + """Theory names resolve to the same table entries.""" -def test_cadence_theory_aliases () -> None: + assert subsequence.cadences.cadence_formula("authentic").name == "strong" + assert subsequence.cadences.cadence_formula("perfect").name == "strong" + assert subsequence.cadences.cadence_formula("plagal").name == "soft" + assert subsequence.cadences.cadence_formula("half").name == "open" + assert subsequence.cadences.cadence_formula("deceptive").name == "fakeout" + assert subsequence.cadences.cadence_formula("interrupted").name == "fakeout" + assert subsequence.cadences.cadence_formula(" Strong ").name == "strong" - """Theory names resolve to the same table entries.""" - assert subsequence.cadences.cadence_formula("authentic").name == "strong" - assert subsequence.cadences.cadence_formula("perfect").name == "strong" - assert subsequence.cadences.cadence_formula("plagal").name == "soft" - assert subsequence.cadences.cadence_formula("half").name == "open" - assert subsequence.cadences.cadence_formula("deceptive").name == "fakeout" - assert subsequence.cadences.cadence_formula("interrupted").name == "fakeout" - assert subsequence.cadences.cadence_formula(" Strong ").name == "strong" +def test_cadence_unknown_name_lists_the_table() -> None: + """An unknown name raises, listing every valid name and alias.""" + with pytest.raises(ValueError, match="fakeout, open, soft, strong"): + subsequence.cadences.cadence_formula("huge") -def test_cadence_unknown_name_lists_the_table () -> None: - - """An unknown name raises, listing every valid name and alias.""" - - with pytest.raises(ValueError, match="fakeout, open, soft, strong"): - subsequence.cadences.cadence_formula("huge") - - with pytest.raises(TypeError, match="named by string"): - subsequence.cadences.cadence_formula(1) # type: ignore[arg-type] + with pytest.raises(TypeError, match="named by string"): + subsequence.cadences.cadence_formula(1) # type: ignore[arg-type] # --------------------------------------------------------------------------- @@ -79,49 +76,47 @@ def test_cadence_unknown_name_lists_the_table () -> None: # --------------------------------------------------------------------------- -def test_progression_cadence_substitutes_the_tail () -> None: - - """The final spans take the formula's chords; the rest stay.""" - - value = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") - resolved = value.resolve("A", "aeolian") +def test_progression_cadence_substitutes_the_tail() -> None: + """The final spans take the formula's chords; the rest stay.""" - names = [span.chord.name() for span in resolved.spans] - assert names == ["Am", "F", "Dm", "E"] # iv → V: the half close + value = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") + resolved = value.resolve("A", "aeolian") + names = [span.chord.name() for span in resolved.spans] + assert names == ["Am", "F", "Dm", "E"] # iv → V: the half close -def test_progression_cadence_strong_in_minor_is_the_major_dominant () -> None: - """``"V"`` carries its quality — E major in A minor, the convention.""" +def test_progression_cadence_strong_in_minor_is_the_major_dominant() -> None: + """``"V"`` carries its quality — E major in A minor, the convention.""" - value = subsequence.progression([1, 6, 4, 5]).cadence("strong").resolve("A", "aeolian") + value = ( + subsequence.progression([1, 6, 4, 5]).cadence("strong").resolve("A", "aeolian") + ) - names = [span.chord.name() for span in value.spans] - assert names[-2:] == ["E", "Am"] + names = [span.chord.name() for span in value.spans] + assert names[-2:] == ["E", "Am"] -def test_progression_cadence_keeps_beats_and_drops_decorations () -> None: +def test_progression_cadence_keeps_beats_and_drops_decorations() -> None: + """Replaced spans keep their lengths; old chords and spice go.""" - """Replaced spans keep their lengths; old chords and spice go.""" + value = subsequence.progression([("Am", 4), ("F", 2), ("C", 6)]).extend(9) + closed = value.cadence("fakeout") - value = subsequence.progression([("Am", 4), ("F", 2), ("C", 6)]).extend(9) - closed = value.cadence("fakeout") + assert [span.beats for span in closed.spans] == [4.0, 2.0, 6.0] + assert closed.spans[0].extensions == (9,) # untouched outside the tail + assert closed.spans[1].extensions == () + assert closed.spans[2].extensions == () - assert [span.beats for span in closed.spans] == [4.0, 2.0, 6.0] - assert closed.spans[0].extensions == (9,) # untouched outside the tail - assert closed.spans[1].extensions == () - assert closed.spans[2].extensions == () + resolved = closed.resolve("A", "aeolian") + assert [span.chord.name() for span in resolved.spans[-2:]] == ["E", "F"] # V → VI - resolved = closed.resolve("A", "aeolian") - assert [span.chord.name() for span in resolved.spans[-2:]] == ["E", "F"] # V → VI +def test_progression_cadence_too_short_raises() -> None: + """A one-span progression cannot take a two-chord formula.""" -def test_progression_cadence_too_short_raises () -> None: - - """A one-span progression cannot take a two-chord formula.""" - - with pytest.raises(ValueError, match="only 1"): - subsequence.progression(["Am"]).cadence("strong") + with pytest.raises(ValueError, match="only 1"): + subsequence.progression(["Am"]).cadence("strong") # --------------------------------------------------------------------------- @@ -129,82 +124,107 @@ def test_progression_cadence_too_short_raises () -> None: # --------------------------------------------------------------------------- -def test_generate_cadence_pins_the_tail () -> None: - - """The walk arrives V → I in C major; the body is still walked.""" - - value = subsequence.Progression.generate( - style="functional_major", bars=4, key="C", seed=3, cadence="strong", - ) - - names = [span.chord.name() for span in value.spans] - assert names[-2:] == ["G", "C"] +def test_generate_cadence_pins_the_tail() -> None: + """The walk arrives V → I in C major; the body is still walked.""" + value = subsequence.Progression.generate( + style="functional_major", + bars=4, + key="C", + seed=3, + cadence="strong", + ) -def test_generate_cadence_soft_is_walkable () -> None: + names = [span.chord.name() for span in value.spans] + assert names[-2:] == ["G", "C"] - """The plagal close exists in the functional major grammar (IV → I).""" - value = subsequence.Progression.generate( - style="functional_major", bars=4, key="C", seed=3, cadence="soft", - ) +def test_generate_cadence_soft_is_walkable() -> None: + """The plagal close exists in the functional major grammar (IV → I).""" - names = [span.chord.name() for span in value.spans] - assert names[-2:] == ["F", "C"] + value = subsequence.Progression.generate( + style="functional_major", + bars=4, + key="C", + seed=3, + cadence="soft", + ) + names = [span.chord.name() for span in value.spans] + assert names[-2:] == ["F", "C"] -def test_generate_cadence_fakeout_swerves () -> None: - """V → vi: the harmony promises home and lands on the submediant.""" +def test_generate_cadence_fakeout_swerves() -> None: + """V → vi: the harmony promises home and lands on the submediant.""" - value = subsequence.Progression.generate( - style="functional_major", bars=4, key="C", seed=3, cadence="fakeout", - ) + value = subsequence.Progression.generate( + style="functional_major", + bars=4, + key="C", + seed=3, + cadence="fakeout", + ) - names = [span.chord.name() for span in value.spans] - assert names[-2:] == ["G", "Am"] + names = [span.chord.name() for span in value.spans] + assert names[-2:] == ["G", "Am"] -def test_generate_cadence_key_relative_value () -> None: +def test_generate_cadence_key_relative_value() -> None: + """Without key= the cadence rides the key-relative spelling.""" - """Without key= the cadence rides the key-relative spelling.""" + value = subsequence.Progression.generate( + style="aeolian_minor", + bars=4, + seed=3, + cadence="strong", + ) - value = subsequence.Progression.generate( - style="aeolian_minor", bars=4, seed=3, cadence="strong", - ) + resolved = value.resolve("A", "aeolian") + assert [span.chord.name() for span in resolved.spans[-2:]] == ["E", "Am"] - resolved = value.resolve("A", "aeolian") - assert [span.chord.name() for span in resolved.spans[-2:]] == ["E", "Am"] +def test_generate_cadence_conflicts_raise() -> None: + """cadence= cannot combine with end= or pins on the formula bars.""" -def test_generate_cadence_conflicts_raise () -> None: + with pytest.raises(ValueError, match="conflicts with end"): + subsequence.Progression.generate( + style="functional_major", + bars=4, + key="C", + seed=1, + cadence="strong", + end="V", + ) - """cadence= cannot combine with end= or pins on the formula bars.""" + with pytest.raises(ValueError, match=r"pins\[3\]"): + subsequence.Progression.generate( + style="functional_major", + bars=4, + key="C", + seed=1, + cadence="strong", + pins={3: "F"}, + ) - with pytest.raises(ValueError, match="conflicts with end"): - subsequence.Progression.generate( - style="functional_major", bars=4, key="C", seed=1, cadence="strong", end="V", - ) + with pytest.raises(ValueError, match="needs 2 bars"): + subsequence.Progression.generate( + style="functional_major", + bars=1, + key="C", + seed=1, + cadence="strong", + ) - with pytest.raises(ValueError, match=r"pins\[3\]"): - subsequence.Progression.generate( - style="functional_major", bars=4, key="C", seed=1, cadence="strong", pins={3: "F"}, - ) - with pytest.raises(ValueError, match="needs 2 bars"): - subsequence.Progression.generate( - style="functional_major", bars=1, key="C", seed=1, cadence="strong", - ) +def test_progression_factory_passes_cadence_through() -> None: + """The lowercase factory's style path forwards cadence=.""" + value = subsequence.progression( + style="functional_major", key="C", bars=4, seed=3, cadence="strong" + ) -def test_progression_factory_passes_cadence_through () -> None: - - """The lowercase factory's style path forwards cadence=.""" - - value = subsequence.progression(style="functional_major", key="C", bars=4, seed=3, cadence="strong") - - names = [span.chord.name() for span in value.spans] - assert names[-2:] == ["G", "C"] + names = [span.chord.name() for span in value.spans] + assert names[-2:] == ["G", "C"] # --------------------------------------------------------------------------- @@ -212,42 +232,45 @@ def test_progression_factory_passes_cadence_through () -> None: # --------------------------------------------------------------------------- -def test_freeze_cadence_pins_the_tail (patch_midi: None) -> None: - - """freeze(cadence=) compiles the formula into the final bars.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=5) - composition.harmony(style="functional_major", cycle_beats=4) +def test_freeze_cadence_pins_the_tail(patch_midi: None) -> None: + """freeze(cadence=) compiles the formula into the final bars.""" - frozen = composition.freeze(4, cadence="strong") + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=5 + ) + composition.harmony(style="functional_major", cycle_beats=4) - names = [span.chord.name() for span in frozen.spans] - assert names[-2:] == ["G", "C"] + frozen = composition.freeze(4, cadence="strong") + names = [span.chord.name() for span in frozen.spans] + assert names[-2:] == ["G", "C"] -def test_freeze_cadence_conflicts_with_end (patch_midi: None) -> None: - """The same conflict rules as generation.""" +def test_freeze_cadence_conflicts_with_end(patch_midi: None) -> None: + """The same conflict rules as generation.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=5) - composition.harmony(style="functional_major", cycle_beats=4) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=5 + ) + composition.harmony(style="functional_major", cycle_beats=4) - with pytest.raises(ValueError, match="conflicts with end"): - composition.freeze(4, cadence="strong", end="V") + with pytest.raises(ValueError, match="conflicts with end"): + composition.freeze(4, cadence="strong", end="V") -def test_constraint_scale_inferred_from_style (patch_midi: None) -> None: +def test_constraint_scale_inferred_from_style(patch_midi: None) -> None: + """Without Composition(scale=), int constraints follow the harmony style.""" - """Without Composition(scale=), int constraints follow the harmony style.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="A", seed=5 + ) + composition.harmony(style="aeolian_minor", cycle_beats=4) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="A", seed=5) - composition.harmony(style="aeolian_minor", cycle_beats=4) + assert composition._constraint_scale() == "minor" - assert composition._constraint_scale() == "minor" - - frozen = composition.freeze(4, cadence="strong") - names = [span.chord.name() for span in frozen.spans] - assert names[-2:] == ["E", "Am"] # the tonic pin resolved minor + frozen = composition.freeze(4, cadence="strong") + names = [span.chord.name() for span in frozen.spans] + assert names[-2:] == ["E", "Am"] # the tonic pin resolved minor # --------------------------------------------------------------------------- @@ -255,25 +278,23 @@ def test_constraint_scale_inferred_from_style (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_motif_generate_cadence_close () -> None: - - """cadence= is end_on sugar: strong lands on 1, open on 5.""" - - strong = subsequence.Motif.generate(rhythm=[0, 1, 2, 3], cadence="strong", seed=7) - last = [event.pitch for event in strong.events if event.pitch is not None][-1] - assert isinstance(last, subsequence.Degree) and last.step == 1 +def test_motif_generate_cadence_close() -> None: + """cadence= is end_on sugar: strong lands on 1, open on 5.""" - open_half = subsequence.Motif.generate(rhythm=[0, 1, 2, 3], cadence="open", seed=7) - last = [event.pitch for event in open_half.events if event.pitch is not None][-1] - assert isinstance(last, subsequence.Degree) and last.step == 5 + strong = subsequence.Motif.generate(rhythm=[0, 1, 2, 3], cadence="strong", seed=7) + last = [event.pitch for event in strong.events if event.pitch is not None][-1] + assert isinstance(last, subsequence.Degree) and last.step == 1 + open_half = subsequence.Motif.generate(rhythm=[0, 1, 2, 3], cadence="open", seed=7) + last = [event.pitch for event in open_half.events if event.pitch is not None][-1] + assert isinstance(last, subsequence.Degree) and last.step == 5 -def test_motif_generate_cadence_conflicts_with_end_on () -> None: - """Both name the close — together they raise.""" +def test_motif_generate_cadence_conflicts_with_end_on() -> None: + """Both name the close — together they raise.""" - with pytest.raises(ValueError, match="conflicts with end_on"): - subsequence.Motif.generate(rhythm=[0, 1], cadence="strong", end_on=5, seed=1) + with pytest.raises(ValueError, match="conflicts with end_on"): + subsequence.Motif.generate(rhythm=[0, 1], cadence="strong", end_on=5, seed=1) # --------------------------------------------------------------------------- @@ -281,277 +302,276 @@ def test_motif_generate_cadence_conflicts_with_end_on () -> None: # --------------------------------------------------------------------------- -async def _capture_clock (**kwargs: typing.Any) -> typing.Tuple[ - typing.Callable[[int], typing.Optional[float]], - "subsequence.composition._HarmonyHorizon", +async def _capture_clock( + **kwargs: typing.Any, +) -> typing.Tuple[ + typing.Callable[[int], typing.Optional[float]], + "subsequence.composition._HarmonyHorizon", ]: + """Schedule the clock against a mock sequencer; return (callback, horizon).""" - """Schedule the clock against a mock sequencer; return (callback, horizon).""" - - captured: typing.Dict[str, typing.Any] = {} - - mock_seq = unittest.mock.MagicMock() - mock_seq.pulses_per_beat = 24 + captured: typing.Dict[str, typing.Any] = {} - async def capture (callback: typing.Callable, start_pulse: int = 0, reschedule_lookahead: float = 1) -> None: - captured["callback"] = callback + mock_seq = unittest.mock.MagicMock() + mock_seq.pulses_per_beat = 24 - mock_seq.schedule_callback_sequence = capture + async def capture( + callback: typing.Callable, start_pulse: int = 0, reschedule_lookahead: float = 1 + ) -> None: + captured["callback"] = callback - horizon = subsequence.composition._HarmonyHorizon() + mock_seq.schedule_callback_sequence = capture - await subsequence.composition.schedule_harmonic_clock( - sequencer = mock_seq, - horizon = horizon, - bar_beats = 4.0, - **kwargs, - ) + horizon = subsequence.composition._HarmonyHorizon() - return captured["callback"], horizon + await subsequence.composition.schedule_harmonic_clock( + sequencer=mock_seq, + horizon=horizon, + bar_beats=4.0, + **kwargs, + ) + return captured["callback"], horizon -def _major_resolver (key_pc: int = 0) -> typing.Callable[[str], typing.List[subsequence.chords.Chord]]: - """A resolve_cadence callable for a major key rooted at key_pc.""" +def _major_resolver( + key_pc: int = 0, +) -> typing.Callable[[str], typing.List[subsequence.chords.Chord]]: + """A resolve_cadence callable for a major key rooted at key_pc.""" - def resolve (name: str) -> typing.List[subsequence.chords.Chord]: - spec = subsequence.cadences.cadence_formula(name) - return [ - subsequence.progressions.resolve_constraint(element, key_pc, "ionian", "cadence") - for element in spec.formula - ] + def resolve(name: str) -> typing.List[subsequence.chords.Chord]: + spec = subsequence.cadences.cadence_formula(name) + return [ + subsequence.progressions.resolve_constraint( + element, key_pc, "ionian", "cadence" + ) + for element in spec.formula + ] - return resolve + return resolve @pytest.mark.asyncio -async def test_request_cadence_steers_the_live_walk (patch_midi: None) -> None: +async def test_request_cadence_steers_the_live_walk(patch_midi: None) -> None: + """A pending request plans a constrained approach: the formula arrives at its bar.""" - """A pending request plans a constrained approach: the formula arrives at its bar.""" + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) + requests = {4: "strong"} - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) - requests = {4: "strong"} + cb, horizon = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + cadence_requests=requests, + resolve_cadence=_major_resolver(0), + ) - cb, horizon = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - cadence_requests = requests, - resolve_cadence = _major_resolver(0), - ) + # Beat 0 sounds the tonic; the request is still pending. + assert horizon.chord_at(0.0).name() == "C" + assert requests == {4: "strong"} - # Beat 0 sounds the tonic; the request is still pending. - assert horizon.chord_at(0.0).name() == "C" - assert requests == {4: "strong"} + # The first boundary plans the whole approach and consumes the request. + cb(4 * 24) + assert requests == {} + assert hs.current_chord.name() not in ("",) # a real chord committed - # The first boundary plans the whole approach and consumes the request. - cb(4 * 24) - assert requests == {} - assert hs.current_chord.name() not in ("",) # a real chord committed + cb(8 * 24) + assert horizon.chord_at(8.0).name() == "G" # the dominant in place at bar 3 - cb(8 * 24) - assert horizon.chord_at(8.0).name() == "G" # the dominant in place at bar 3 + cb(12 * 24) + assert horizon.chord_at(12.0).name() == "C" # the arrival at bar 4 + assert hs.current_chord.name() == "C" - cb(12 * 24) - assert horizon.chord_at(12.0).name() == "C" # the arrival at bar 4 - assert hs.current_chord.name() == "C" - - # The journey continued through the close: every boundary committed. - assert len(hs.history) == 3 + # The journey continued through the close: every boundary committed. + assert len(hs.history) == 3 @pytest.mark.asyncio -async def test_request_cadence_publishes_the_planned_approach (patch_midi: None) -> None: - - """While the queue serves, the window's next chord is the queued one — no fresh draw.""" +async def test_request_cadence_publishes_the_planned_approach(patch_midi: None) -> None: + """While the queue serves, the window's next chord is the queued one — no fresh draw.""" - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) - cb, horizon = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - cadence_requests = {4: "strong"}, - resolve_cadence = _major_resolver(0), - ) + cb, horizon = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + cadence_requests={4: "strong"}, + resolve_cadence=_major_resolver(0), + ) - cb(4 * 24) - # The dominant is already visible one boundary ahead. - assert horizon.chord_at(8.0).name() == "G" + cb(4 * 24) + # The dominant is already visible one boundary ahead. + assert horizon.chord_at(8.0).name() == "G" - cb(8 * 24) - assert horizon.chord_at(12.0).name() == "C" + cb(8 * 24) + assert horizon.chord_at(12.0).name() == "C" @pytest.mark.asyncio -async def test_request_cadence_unwalkable_falls_back_to_fiat ( - patch_midi: None, caplog: pytest.LogCaptureFixture +async def test_request_cadence_unwalkable_falls_back_to_fiat( + patch_midi: None, caplog: pytest.LogCaptureFixture ) -> None: + """A formula outside the graph's vocabulary lands by fiat, loudly.""" - """A formula outside the graph's vocabulary lands by fiat, loudly.""" - - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) - out_of_vocabulary = [ - subsequence.chords.Chord(root_pc=1, quality="major"), # C# - subsequence.chords.Chord(root_pc=6, quality="major"), # F# - ] + out_of_vocabulary = [ + subsequence.chords.Chord(root_pc=1, quality="major"), # C# + subsequence.chords.Chord(root_pc=6, quality="major"), # F# + ] - cb, horizon = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - cadence_requests = {3: "strong"}, - resolve_cadence = lambda name: list(out_of_vocabulary), - ) + cb, horizon = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + cadence_requests={3: "strong"}, + resolve_cadence=lambda name: list(out_of_vocabulary), + ) - with caplog.at_level(logging.WARNING): - cb(4 * 24) - cb(8 * 24) + with caplog.at_level(logging.WARNING): + cb(4 * 24) + cb(8 * 24) - assert "lands by fiat" in caplog.text - assert horizon.chord_at(4.0).name() == "C#" - assert horizon.chord_at(8.0).name() == "F#" - assert hs.current_chord.name() == "F#" # fiat still commits — the engine stays coherent + assert "lands by fiat" in caplog.text + assert horizon.chord_at(4.0).name() == "C#" + assert horizon.chord_at(8.0).name() == "F#" + assert ( + hs.current_chord.name() == "F#" + ) # fiat still commits — the engine stays coherent @pytest.mark.asyncio -async def test_request_cadence_expires_when_the_bar_passes ( - patch_midi: None, caplog: pytest.LogCaptureFixture +async def test_request_cadence_expires_when_the_bar_passes( + patch_midi: None, caplog: pytest.LogCaptureFixture ) -> None: + """A request whose bar has passed is dropped with a warning.""" - """A request whose bar has passed is dropped with a warning.""" + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) + requests = {1: "strong"} - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) - requests = {1: "strong"} + cb, _horizon = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + cadence_requests=requests, + resolve_cadence=_major_resolver(0), + ) - cb, _horizon = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - cadence_requests = requests, - resolve_cadence = _major_resolver(0), - ) + with caplog.at_level(logging.WARNING): + cb(4 * 24) - with caplog.at_level(logging.WARNING): - cb(4 * 24) - - assert requests == {} - assert "expired unserved" in caplog.text + assert requests == {} + assert "expired unserved" in caplog.text @pytest.mark.asyncio -async def test_request_cadence_too_late_uses_the_formula_tail ( - patch_midi: None, caplog: pytest.LogCaptureFixture +async def test_request_cadence_too_late_uses_the_formula_tail( + patch_midi: None, caplog: pytest.LogCaptureFixture ) -> None: + """One boundary of room: only the arrival chord lands, with a warning.""" - """One boundary of room: only the arrival chord lands, with a warning.""" - - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) - cb, horizon = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - cadence_requests = {2: "strong"}, - resolve_cadence = _major_resolver(0), - ) + cb, horizon = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + cadence_requests={2: "strong"}, + resolve_cadence=_major_resolver(0), + ) - with caplog.at_level(logging.WARNING): - cb(4 * 24) + with caplog.at_level(logging.WARNING): + cb(4 * 24) - assert "formula's tail" in caplog.text - assert horizon.chord_at(4.0).name() == "C" # the arrival, at its bar + assert "formula's tail" in caplog.text + assert horizon.chord_at(4.0).name() == "C" # the arrival, at its bar @pytest.mark.asyncio -async def test_section_cadence_registers_at_the_final_bar (patch_midi: None) -> None: +async def test_section_cadence_registers_at_the_final_bar(patch_midi: None) -> None: + """Entering a live section with a registered cadence requests its last bar.""" - """Entering a live section with a registered cadence requests its last bar.""" + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) + requests: typing.Dict[int, str] = {} - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) - requests: typing.Dict[int, str] = {} + cb, horizon = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_section_progression=lambda: ("verse", 0, 4, None), + cadence_requests=requests, + resolve_cadence=_major_resolver(0), + get_section_cadence={"verse": "open"}.get, + ) - cb, horizon = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_section_progression = lambda: ("verse", 0, 4, None), - cadence_requests = requests, - resolve_cadence = _major_resolver(0), - get_section_cadence = {"verse": "open"}.get, - ) + # Entry at bar 1, 4 bars → the arrival lands at bar 4. + assert requests == {4: "open"} - # Entry at bar 1, 4 bars → the arrival lands at bar 4. - assert requests == {4: "open"} + cb(4 * 24) + cb(8 * 24) + assert horizon.chord_at(8.0).name() == "F" # IV approaching - cb(4 * 24) - cb(8 * 24) - assert horizon.chord_at(8.0).name() == "F" # IV approaching - - cb(12 * 24) - assert horizon.chord_at(12.0).name() == "G" # hanging on V at bar 4 + cb(12 * 24) + assert horizon.chord_at(12.0).name() == "G" # hanging on V at bar 4 @pytest.mark.asyncio -async def test_section_cadence_ignores_data_bound_sections (patch_midi: None) -> None: - - """A section with bound chords is data — no request is registered.""" - - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) - prog = subsequence.progression(["Am", "F", "C", "G"]).resolve("C") - requests: typing.Dict[int, str] = {} +async def test_section_cadence_ignores_data_bound_sections(patch_midi: None) -> None: + """A section with bound chords is data — no request is registered.""" - await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_section_progression = lambda: ("verse", 0, 4, prog), - cadence_requests = requests, - resolve_cadence = _major_resolver(0), - get_section_cadence = {"verse": "open"}.get, - ) + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) + prog = subsequence.progression(["Am", "F", "C", "G"]).resolve("C") + requests: typing.Dict[int, str] = {} - assert requests == {} + await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_section_progression=lambda: ("verse", 0, 4, prog), + cadence_requests=requests, + resolve_cadence=_major_resolver(0), + get_section_cadence={"verse": "open"}.get, + ) + assert requests == {} -def test_request_cadence_method_validates (patch_midi: None) -> None: - """The Composition method validates the name and the bar, loudly.""" +def test_request_cadence_method_validates(patch_midi: None) -> None: + """The Composition method validates the name and the bar, loudly.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - with pytest.raises(ValueError, match="Unknown cadence"): - composition.request_cadence("huge", bar=8) + with pytest.raises(ValueError, match="Unknown cadence"): + composition.request_cadence("huge", bar=8) - with pytest.raises(ValueError, match="needs bar="): - composition.request_cadence("strong") + with pytest.raises(ValueError, match="needs bar="): + composition.request_cadence("strong") - composition.request_cadence("half", bar=8) - assert composition._cadence_requests == {8: "open"} # aliases normalise + composition.request_cadence("half", bar=8) + assert composition._cadence_requests == {8: "open"} # aliases normalise -def test_section_cadence_method_registers_and_unregisters (patch_midi: None) -> None: +def test_section_cadence_method_registers_and_unregisters(patch_midi: None) -> None: + """section_cadence binds by name; None unbinds.""" - """section_cadence binds by name; None unbinds.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.section_cadence("verse", "deceptive") + assert composition._section_cadences == {"verse": "fakeout"} - composition.section_cadence("verse", "deceptive") - assert composition._section_cadences == {"verse": "fakeout"} + composition.section_cadence("verse", None) + assert composition._section_cadences == {} - composition.section_cadence("verse", None) - assert composition._section_cadences == {} - - with pytest.raises(ValueError, match="Unknown cadence"): - composition.section_cadence("verse", "huge") + with pytest.raises(ValueError, match="Unknown cadence"): + composition.section_cadence("verse", "huge") # --------------------------------------------------------------------------- @@ -559,38 +579,40 @@ def test_section_cadence_method_registers_and_unregisters (patch_midi: None) -> # --------------------------------------------------------------------------- -def test_functional_major_cadential_edges_are_labelled () -> None: - - """The cadence arrivals carry their producer labels (and IV→I exists).""" +def test_functional_major_cadential_edges_are_labelled() -> None: + """The cadence arrivals carry their producer labels (and IV→I exists).""" - hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="functional_major") + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major" + ) - chord = subsequence.chords.Chord - tonic = chord(root_pc=0, quality="major") - subdominant = chord(root_pc=5, quality="major") - dominant = chord(root_pc=7, quality="major") - submediant = chord(root_pc=9, quality="minor") + chord = subsequence.chords.Chord + tonic = chord(root_pc=0, quality="major") + subdominant = chord(root_pc=5, quality="major") + dominant = chord(root_pc=7, quality="major") + submediant = chord(root_pc=9, quality="minor") - assert hs.graph.get_label(dominant, tonic) == "strong" - assert hs.graph.get_label(subdominant, tonic) == "soft" - assert hs.graph.get_label(dominant, submediant) == "fakeout" - assert hs.graph.get_label(subdominant, dominant) == "open" + assert hs.graph.get_label(dominant, tonic) == "strong" + assert hs.graph.get_label(subdominant, tonic) == "soft" + assert hs.graph.get_label(dominant, submediant) == "fakeout" + assert hs.graph.get_label(subdominant, dominant) == "open" -def test_aeolian_minor_cadential_edges_are_labelled () -> None: +def test_aeolian_minor_cadential_edges_are_labelled() -> None: + """The minor flagship carries the same labels on its cadence edges.""" - """The minor flagship carries the same labels on its cadence edges.""" + hs = subsequence.harmonic_state.HarmonicState( + key_name="A", graph_style="aeolian_minor" + ) - hs = subsequence.harmonic_state.HarmonicState(key_name="A", graph_style="aeolian_minor") + chord = subsequence.chords.Chord + tonic = chord(root_pc=9, quality="minor") + subdominant = chord(root_pc=2, quality="minor") + dominant = chord(root_pc=4, quality="major") - chord = subsequence.chords.Chord - tonic = chord(root_pc=9, quality="minor") - subdominant = chord(root_pc=2, quality="minor") - dominant = chord(root_pc=4, quality="major") - - assert hs.graph.get_label(dominant, tonic) == "strong" - assert hs.graph.get_label(subdominant, tonic) == "soft" - assert hs.graph.get_label(subdominant, dominant) == "open" + assert hs.graph.get_label(dominant, tonic) == "strong" + assert hs.graph.get_label(subdominant, tonic) == "soft" + assert hs.graph.get_label(subdominant, dominant) == "open" # --------------------------------------------------------------------------- @@ -598,122 +620,116 @@ def test_aeolian_minor_cadential_edges_are_labelled () -> None: # --------------------------------------------------------------------------- -def test_sentence_shape_and_close () -> None: - - """Four units: idea, idea, contrast, contrast closing on the cadence degree.""" - - idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) - value = subsequence.sentence(idea, bars=8, cadence="strong", seed=11) - - assert len(value.segments) == 4 - assert value.length == 32.0 - - # The presentation states the idea twice, exactly. - assert value.segments[0].events == value.segments[1].events - - # The continuation differs from the idea (re-pitched, same rhythm). - assert value.segments[2].events != value.segments[0].events - assert [e.beat for e in value.segments[2].events] == [e.beat for e in value.segments[0].events] - - # The cadential unit lands on 1. - last = [e.pitch for e in value.segments[3].events if e.pitch is not None][-1] - assert isinstance(last, subsequence.Degree) and last.step == 1 - - -def test_sentence_open_close_and_recipe () -> None: +def test_sentence_shape_and_close() -> None: + """Four units: idea, idea, contrast, contrast closing on the cadence degree.""" - """cadence="open" lands on 5; the recipe records plan and cadence.""" + idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) + value = subsequence.sentence(idea, bars=8, cadence="strong", seed=11) - idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) - value = subsequence.sentence(idea, bars=8, cadence="half", seed=11) + assert len(value.segments) == 4 + assert value.length == 32.0 - last = [e.pitch for e in value.segments[3].events if e.pitch is not None][-1] - assert isinstance(last, subsequence.Degree) and last.step == 5 + # The presentation states the idea twice, exactly. + assert value.segments[0].events == value.segments[1].events - assert value.recipe is not None - assert value.recipe.plan == "sentence" - assert value.recipe.cadence == "open" + # The continuation differs from the idea (re-pitched, same rhythm). + assert value.segments[2].events != value.segments[0].events + assert [e.beat for e in value.segments[2].events] == [ + e.beat for e in value.segments[0].events + ] - # Rerolls compose — the recipe survives. - rerolled = value.reroll(bar=5, seed=4) - assert rerolled.recipe is not None and rerolled.recipe.plan == "sentence" + # The cadential unit lands on 1. + last = [e.pitch for e in value.segments[3].events if e.pitch is not None][-1] + assert isinstance(last, subsequence.Degree) and last.step == 1 -def test_sentence_tiles_a_short_idea () -> None: +def test_sentence_open_close_and_recipe() -> None: + """cadence="open" lands on 5; the recipe records plan and cadence.""" - """A 1-bar idea in an 8-bar sentence tiles up to the 2-bar unit.""" + idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) + value = subsequence.sentence(idea, bars=8, cadence="half", seed=11) - idea = subsequence.motif([1, 2, 3, 4]) - value = subsequence.sentence(idea, bars=8, seed=2) + last = [e.pitch for e in value.segments[3].events if e.pitch is not None][-1] + assert isinstance(last, subsequence.Degree) and last.step == 5 - assert value.segments[0].length == 8.0 # two tiles of the 4-beat idea - assert len(value.segments) == 4 + assert value.recipe is not None + assert value.recipe.plan == "sentence" + assert value.recipe.cadence == "open" + # Rerolls compose — the recipe survives. + rerolled = value.reroll(bar=5, seed=4) + assert rerolled.recipe is not None and rerolled.recipe.plan == "sentence" -def test_sentence_seed_or_warn_and_determinism () -> None: - """Seeded sentences reproduce; unseeded ones warn.""" +def test_sentence_tiles_a_short_idea() -> None: + """A 1-bar idea in an 8-bar sentence tiles up to the 2-bar unit.""" - idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) + idea = subsequence.motif([1, 2, 3, 4]) + value = subsequence.sentence(idea, bars=8, seed=2) - first = subsequence.sentence(idea, seed=11) - second = subsequence.sentence(idea, seed=11) - assert first.segments == second.segments + assert value.segments[0].length == 8.0 # two tiles of the 4-beat idea + assert len(value.segments) == 4 - with pytest.warns(UserWarning, match="seed"): - subsequence.sentence(idea) +def test_sentence_seed_or_warn_and_determinism() -> None: + """Seeded sentences reproduce; unseeded ones warn.""" -def test_period_question_and_answer () -> None: + idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) - """Two halves, identical except the closes: 5 (open) then 1 (strong).""" + first = subsequence.sentence(idea, seed=11) + second = subsequence.sentence(idea, seed=11) + assert first.segments == second.segments - idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4]) - value = subsequence.period(idea) + with pytest.warns(UserWarning, match="seed"): + subsequence.sentence(idea) - assert len(value.segments) == 2 - assert value.length == 16.0 - ante_pitches = [e.pitch for e in value.segments[0].events if e.pitch is not None] - cons_pitches = [e.pitch for e in value.segments[1].events if e.pitch is not None] +def test_period_question_and_answer() -> None: + """Two halves, identical except the closes: 5 (open) then 1 (strong).""" - assert ante_pitches[:-1] == cons_pitches[:-1] # same body - assert ante_pitches[-1].step == 5 # the question - assert cons_pitches[-1].step == 1 # the answer + idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4]) + value = subsequence.period(idea) - assert value.recipe is not None and value.recipe.plan == "period" + assert len(value.segments) == 2 + assert value.length == 16.0 + ante_pitches = [e.pitch for e in value.segments[0].events if e.pitch is not None] + cons_pitches = [e.pitch for e in value.segments[1].events if e.pitch is not None] -def test_period_keeps_phrase_segmentation () -> None: + assert ante_pitches[:-1] == cons_pitches[:-1] # same body + assert ante_pitches[-1].step == 5 # the question + assert cons_pitches[-1].step == 1 # the answer - """A Phrase antecedent keeps its segments; only tail units re-aim.""" + assert value.recipe is not None and value.recipe.plan == "period" - a = subsequence.motif([5, 6, 5, 3]) - b = subsequence.motif([1, 2, 3, 4]) - value = subsequence.period(subsequence.Phrase([a, b]), cadence="strong") - assert len(value.segments) == 4 +def test_period_keeps_phrase_segmentation() -> None: + """A Phrase antecedent keeps its segments; only tail units re-aim.""" - # The non-tail units are untouched restatements. - assert value.segments[0].events == a.events - assert value.segments[2].events == a.events + a = subsequence.motif([5, 6, 5, 3]) + b = subsequence.motif([1, 2, 3, 4]) + value = subsequence.period(subsequence.Phrase([a, b]), cadence="strong") - ante_last = [e.pitch for e in value.segments[1].events if e.pitch is not None][-1] - cons_last = [e.pitch for e in value.segments[3].events if e.pitch is not None][-1] - assert ante_last.step == 5 and cons_last.step == 1 + assert len(value.segments) == 4 + # The non-tail units are untouched restatements. + assert value.segments[0].events == a.events + assert value.segments[2].events == a.events -def test_period_is_deterministic () -> None: + ante_last = [e.pitch for e in value.segments[1].events if e.pitch is not None][-1] + cons_last = [e.pitch for e in value.segments[3].events if e.pitch is not None][-1] + assert ante_last.step == 5 and cons_last.step == 1 - """No generation, no seed — the same input always gives the same period.""" - idea = subsequence.motif([3, 4, 5, 1]) - assert subsequence.period(idea).segments == subsequence.period(idea).segments +def test_period_is_deterministic() -> None: + """No generation, no seed — the same input always gives the same period.""" + idea = subsequence.motif([3, 4, 5, 1]) + assert subsequence.period(idea).segments == subsequence.period(idea).segments -def test_period_rejects_empty_antecedent () -> None: - """An empty antecedent raises loudly.""" +def test_period_rejects_empty_antecedent() -> None: + """An empty antecedent raises loudly.""" - with pytest.raises(ValueError, match="empty antecedent"): - subsequence.period(subsequence.Phrase([])) + with pytest.raises(ValueError, match="empty antecedent"): + subsequence.period(subsequence.Phrase([])) diff --git a/tests/test_callback_sequence.py b/tests/test_callback_sequence.py index 43df3c4..af57859 100644 --- a/tests/test_callback_sequence.py +++ b/tests/test_callback_sequence.py @@ -14,272 +14,292 @@ import subsequence.sequencer -def make_sequencer () -> subsequence.sequencer.Sequencer: +def make_sequencer() -> subsequence.sequencer.Sequencer: + """A sequencer with a dummy device (patch_midi supplies the backend).""" - """A sequencer with a dummy device (patch_midi supplies the backend).""" - - return subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + return subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) @pytest.mark.asyncio -async def test_fires_at_lookahead_before_boundary (patch_midi: None) -> None: - - """The callback fires exactly lookahead before its boundary, not earlier.""" +async def test_fires_at_lookahead_before_boundary(patch_midi: None) -> None: + """The callback fires exactly lookahead before its boundary, not earlier.""" - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - fired: typing.List[int] = [] + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + fired: typing.List[int] = [] - def clock (boundary: int) -> float: - fired.append(boundary) - return 4.0 + def clock(boundary: int) -> float: + fired.append(boundary) + return 4.0 - await sequencer.schedule_callback_sequence(clock, start_pulse=8 * ppb, reschedule_lookahead=1) + await sequencer.schedule_callback_sequence( + clock, start_pulse=8 * ppb, reschedule_lookahead=1 + ) - await sequencer._maybe_reschedule_patterns(7 * ppb - 1) - assert fired == [] + await sequencer._maybe_reschedule_patterns(7 * ppb - 1) + assert fired == [] - await sequencer._maybe_reschedule_patterns(7 * ppb) - assert fired == [8 * ppb] + await sequencer._maybe_reschedule_patterns(7 * ppb) + assert fired == [8 * ppb] @pytest.mark.asyncio -async def test_receives_boundary_pulse_not_fire_pulse (patch_midi: None) -> None: +async def test_receives_boundary_pulse_not_fire_pulse(patch_midi: None) -> None: + """The callback argument is the boundary it prepares, not the clock 'now'.""" - """The callback argument is the boundary it prepares, not the clock 'now'.""" + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + received: typing.List[int] = [] - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - received: typing.List[int] = [] + def clock(boundary: int) -> typing.Optional[float]: + received.append(boundary) + return None - def clock (boundary: int) -> typing.Optional[float]: - received.append(boundary) - return None + await sequencer.schedule_callback_sequence( + clock, start_pulse=4 * ppb, reschedule_lookahead=2 + ) - await sequencer.schedule_callback_sequence(clock, start_pulse=4 * ppb, reschedule_lookahead=2) + await sequencer._maybe_reschedule_patterns(2 * ppb) - await sequencer._maybe_reschedule_patterns(2 * ppb) - - assert received == [4 * ppb] + assert received == [4 * ppb] @pytest.mark.asyncio -async def test_variable_intervals_walk_irregular_spans (patch_midi: None) -> None: - - """Each return value sets the next hop: 2 then 4 then 2 beats.""" +async def test_variable_intervals_walk_irregular_spans(patch_midi: None) -> None: + """Each return value sets the next hop: 2 then 4 then 2 beats.""" - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - boundaries: typing.List[int] = [] - spans = iter([2.0, 4.0, 2.0]) + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + boundaries: typing.List[int] = [] + spans = iter([2.0, 4.0, 2.0]) - def clock (boundary: int) -> typing.Optional[float]: - boundaries.append(boundary) - return next(spans, None) + def clock(boundary: int) -> typing.Optional[float]: + boundaries.append(boundary) + return next(spans, None) - await sequencer.schedule_callback_sequence(clock, start_pulse=0, reschedule_lookahead=1) + await sequencer.schedule_callback_sequence( + clock, start_pulse=0, reschedule_lookahead=1 + ) - for pulse in range(0, 10 * ppb): - await sequencer._maybe_reschedule_patterns(pulse) + for pulse in range(0, 10 * ppb): + await sequencer._maybe_reschedule_patterns(pulse) - assert boundaries == [0, 2 * ppb, 6 * ppb, 8 * ppb] + assert boundaries == [0, 2 * ppb, 6 * ppb, 8 * ppb] @pytest.mark.asyncio -async def test_none_stops_the_sequence (patch_midi: None) -> None: +async def test_none_stops_the_sequence(patch_midi: None) -> None: + """Returning None drops the sequence — no further fires, queue empties.""" - """Returning None drops the sequence — no further fires, queue empties.""" + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + count = [0] - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - count = [0] + def clock(boundary: int) -> typing.Optional[float]: + count[0] += 1 + return None - def clock (boundary: int) -> typing.Optional[float]: - count[0] += 1 - return None + await sequencer.schedule_callback_sequence( + clock, start_pulse=0, reschedule_lookahead=1 + ) - await sequencer.schedule_callback_sequence(clock, start_pulse=0, reschedule_lookahead=1) + for pulse in range(0, 8 * ppb): + await sequencer._maybe_reschedule_patterns(pulse) - for pulse in range(0, 8 * ppb): - await sequencer._maybe_reschedule_patterns(pulse) - - assert count[0] == 1 - assert sequencer.callback_sequence_queue == [] + assert count[0] == 1 + assert sequencer.callback_sequence_queue == [] @pytest.mark.asyncio -async def test_backshift_fires_immediately_at_start (patch_midi: None) -> None: - - """start_pulse=0 with lookahead means the first fire is already due at pulse 0.""" +async def test_backshift_fires_immediately_at_start(patch_midi: None) -> None: + """start_pulse=0 with lookahead means the first fire is already due at pulse 0.""" - sequencer = make_sequencer() - fired: typing.List[int] = [] + sequencer = make_sequencer() + fired: typing.List[int] = [] - def clock (boundary: int) -> typing.Optional[float]: - fired.append(boundary) - return None + def clock(boundary: int) -> typing.Optional[float]: + fired.append(boundary) + return None - await sequencer.schedule_callback_sequence(clock, start_pulse=0, reschedule_lookahead=1) + await sequencer.schedule_callback_sequence( + clock, start_pulse=0, reschedule_lookahead=1 + ) - await sequencer._maybe_reschedule_patterns(0) + await sequencer._maybe_reschedule_patterns(0) - assert fired == [0] + assert fired == [0] @pytest.mark.asyncio -async def test_async_callback_is_awaited (patch_midi: None) -> None: - - """Coroutine callbacks work and their return value drives the hop.""" +async def test_async_callback_is_awaited(patch_midi: None) -> None: + """Coroutine callbacks work and their return value drives the hop.""" - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - boundaries: typing.List[int] = [] + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + boundaries: typing.List[int] = [] - async def clock (boundary: int) -> typing.Optional[float]: - boundaries.append(boundary) - return 2.0 if len(boundaries) < 2 else None + async def clock(boundary: int) -> typing.Optional[float]: + boundaries.append(boundary) + return 2.0 if len(boundaries) < 2 else None - await sequencer.schedule_callback_sequence(clock, start_pulse=0, reschedule_lookahead=1) + await sequencer.schedule_callback_sequence( + clock, start_pulse=0, reschedule_lookahead=1 + ) - for pulse in range(0, 4 * ppb): - await sequencer._maybe_reschedule_patterns(pulse) + for pulse in range(0, 4 * ppb): + await sequencer._maybe_reschedule_patterns(pulse) - assert boundaries == [0, 2 * ppb] + assert boundaries == [0, 2 * ppb] @pytest.mark.asyncio -async def test_failing_callback_is_dropped_and_isolated (patch_midi: None) -> None: +async def test_failing_callback_is_dropped_and_isolated(patch_midi: None) -> None: + """An exception stops that sequence; other sequences keep firing.""" - """An exception stops that sequence; other sequences keep firing.""" + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + healthy: typing.List[int] = [] - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - healthy: typing.List[int] = [] + def bad(boundary: int) -> float: + raise RuntimeError("boom") - def bad (boundary: int) -> float: - raise RuntimeError("boom") + def good(boundary: int) -> typing.Optional[float]: + healthy.append(boundary) + return 2.0 if len(healthy) < 3 else None - def good (boundary: int) -> typing.Optional[float]: - healthy.append(boundary) - return 2.0 if len(healthy) < 3 else None + await sequencer.schedule_callback_sequence( + bad, start_pulse=0, reschedule_lookahead=1 + ) + await sequencer.schedule_callback_sequence( + good, start_pulse=0, reschedule_lookahead=1 + ) - await sequencer.schedule_callback_sequence(bad, start_pulse=0, reschedule_lookahead=1) - await sequencer.schedule_callback_sequence(good, start_pulse=0, reschedule_lookahead=1) + for pulse in range(0, 8 * ppb): + await sequencer._maybe_reschedule_patterns(pulse) - for pulse in range(0, 8 * ppb): - await sequencer._maybe_reschedule_patterns(pulse) - - assert healthy == [0, 2 * ppb, 4 * ppb] - assert sequencer.callback_sequence_queue == [] + assert healthy == [0, 2 * ppb, 4 * ppb] + assert sequencer.callback_sequence_queue == [] @pytest.mark.asyncio -async def test_tiny_interval_clamps_and_stays_monotonic (patch_midi: None) -> None: - - """A near-zero interval advances at least one pulse — no same-pulse spin.""" +async def test_tiny_interval_clamps_and_stays_monotonic(patch_midi: None) -> None: + """A near-zero interval advances at least one pulse — no same-pulse spin.""" - sequencer = make_sequencer() - boundaries: typing.List[int] = [] + sequencer = make_sequencer() + boundaries: typing.List[int] = [] - def clock (boundary: int) -> typing.Optional[float]: - boundaries.append(boundary) - return 0.0 if len(boundaries) < 3 else None + def clock(boundary: int) -> typing.Optional[float]: + boundaries.append(boundary) + return 0.0 if len(boundaries) < 3 else None - await sequencer.schedule_callback_sequence(clock, start_pulse=0, reschedule_lookahead=0) + await sequencer.schedule_callback_sequence( + clock, start_pulse=0, reschedule_lookahead=0 + ) - for pulse in range(0, 10): - await sequencer._maybe_reschedule_patterns(pulse) + for pulse in range(0, 10): + await sequencer._maybe_reschedule_patterns(pulse) - assert boundaries == [0, 1, 2] + assert boundaries == [0, 1, 2] @pytest.mark.asyncio -async def test_fixed_callbacks_fire_before_sequences_at_same_pulse (patch_midi: None) -> None: +async def test_fixed_callbacks_fire_before_sequences_at_same_pulse( + patch_midi: None, +) -> None: + """Registration-independent ordering: fixed (the form clock) before sequences (harmony).""" - """Registration-independent ordering: fixed (the form clock) before sequences (harmony).""" + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + order: typing.List[str] = [] - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - order: typing.List[str] = [] + def harmony_clock(boundary: int) -> typing.Optional[float]: + order.append("harmony") + return None - def harmony_clock (boundary: int) -> typing.Optional[float]: - order.append("harmony") - return None + def form_clock(pulse: int) -> None: + order.append("form") - def form_clock (pulse: int) -> None: - order.append("form") + # Deliberately register the sequence FIRST — ordering must not depend on it. + await sequencer.schedule_callback_sequence( + harmony_clock, start_pulse=4 * ppb, reschedule_lookahead=1 + ) + await sequencer.schedule_callback_repeating( + form_clock, interval_beats=4, start_pulse=4 * ppb, reschedule_lookahead=1 + ) - # Deliberately register the sequence FIRST — ordering must not depend on it. - await sequencer.schedule_callback_sequence(harmony_clock, start_pulse=4 * ppb, reschedule_lookahead=1) - await sequencer.schedule_callback_repeating(form_clock, interval_beats=4, start_pulse=4 * ppb, reschedule_lookahead=1) + await sequencer._maybe_reschedule_patterns(3 * ppb) - await sequencer._maybe_reschedule_patterns(3 * ppb) - - assert order == ["form", "harmony"] + assert order == ["form", "harmony"] @pytest.mark.asyncio -async def test_sequences_fire_before_pattern_rebuilds_at_same_pulse (patch_midi: None) -> None: - - """The clock must publish the window before patterns rebuild against it.""" +async def test_sequences_fire_before_pattern_rebuilds_at_same_pulse( + patch_midi: None, +) -> None: + """The clock must publish the window before patterns rebuild against it.""" - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - order: typing.List[str] = [] + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + order: typing.List[str] = [] - class Recorder (subsequence.pattern.Pattern): + class Recorder(subsequence.pattern.Pattern): + """Pattern that records when it rebuilds.""" - """Pattern that records when it rebuilds.""" + def __init__(self) -> None: + super().__init__(channel=0, length=4, reschedule_lookahead=1) + self.add_note(position=0, pitch=60, velocity=100, duration=6) - def __init__ (self) -> None: - super().__init__(channel=0, length=4, reschedule_lookahead=1) - self.add_note(position=0, pitch=60, velocity=100, duration=6) + def on_reschedule(self) -> None: + order.append("pattern") - def on_reschedule (self) -> None: - order.append("pattern") + def harmony_clock(boundary: int) -> typing.Optional[float]: + order.append("harmony") + return None - def harmony_clock (boundary: int) -> typing.Optional[float]: - order.append("harmony") - return None + pattern = Recorder() + await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) + await sequencer.schedule_callback_sequence( + harmony_clock, start_pulse=4 * ppb, reschedule_lookahead=1 + ) - pattern = Recorder() - await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) - await sequencer.schedule_callback_sequence(harmony_clock, start_pulse=4 * ppb, reschedule_lookahead=1) + # Both are due at 3 beats: pattern reschedules at length - lookahead, + # the clock fires at boundary - lookahead. + await sequencer._maybe_reschedule_patterns(3 * ppb) - # Both are due at 3 beats: pattern reschedules at length - lookahead, - # the clock fires at boundary - lookahead. - await sequencer._maybe_reschedule_patterns(3 * ppb) - - assert order == ["harmony", "pattern"] + assert order == ["harmony", "pattern"] @pytest.mark.asyncio -async def test_negative_lookahead_raises (patch_midi: None) -> None: - - """A negative lookahead is a hard error.""" +async def test_negative_lookahead_raises(patch_midi: None) -> None: + """A negative lookahead is a hard error.""" - sequencer = make_sequencer() + sequencer = make_sequencer() - with pytest.raises(ValueError): - await sequencer.schedule_callback_sequence(lambda boundary: None, reschedule_lookahead=-1) + with pytest.raises(ValueError): + await sequencer.schedule_callback_sequence( + lambda boundary: None, reschedule_lookahead=-1 + ) @pytest.mark.asyncio -async def test_fractional_beat_intervals_convert_to_pulses (patch_midi: None) -> None: - - """Half-beat spans land on exact pulse boundaries.""" +async def test_fractional_beat_intervals_convert_to_pulses(patch_midi: None) -> None: + """Half-beat spans land on exact pulse boundaries.""" - sequencer = make_sequencer() - ppb = sequencer.pulses_per_beat - boundaries: typing.List[int] = [] + sequencer = make_sequencer() + ppb = sequencer.pulses_per_beat + boundaries: typing.List[int] = [] - def clock (boundary: int) -> typing.Optional[float]: - boundaries.append(boundary) - return 0.5 if len(boundaries) < 3 else None + def clock(boundary: int) -> typing.Optional[float]: + boundaries.append(boundary) + return 0.5 if len(boundaries) < 3 else None - await sequencer.schedule_callback_sequence(clock, start_pulse=0, reschedule_lookahead=0) + await sequencer.schedule_callback_sequence( + clock, start_pulse=0, reschedule_lookahead=0 + ) - for pulse in range(0, 2 * ppb): - await sequencer._maybe_reschedule_patterns(pulse) + for pulse in range(0, 2 * ppb): + await sequencer._maybe_reschedule_patterns(pulse) - assert boundaries == [0, ppb // 2, ppb] + assert boundaries == [0, ppb // 2, ppb] diff --git a/tests/test_cc_forward.py b/tests/test_cc_forward.py index 45e02fe..95e2548 100644 --- a/tests/test_cc_forward.py +++ b/tests/test_cc_forward.py @@ -18,374 +18,415 @@ _created_input_loops: typing.List[asyncio.AbstractEventLoop] = [] -def _make_sequencer (spy: conftest.SpyMidiOut) -> subsequence.sequencer.Sequencer: - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - ) - seq.midi_out = spy - # _on_midi_input has an early-return guard on these; initialise them - # so the CC mapping / forwarding code is reachable in unit tests. - loop = asyncio.new_event_loop() - _created_input_loops.append(loop) - seq._midi_input_queue = asyncio.Queue() - seq._input_loop = loop - return seq +def _make_sequencer(spy: conftest.SpyMidiOut) -> subsequence.sequencer.Sequencer: + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + ) + seq.midi_out = spy + # _on_midi_input has an early-return guard on these; initialise them + # so the CC mapping / forwarding code is reachable in unit tests. + loop = asyncio.new_event_loop() + _created_input_loops.append(loop) + seq._midi_input_queue = asyncio.Queue() + seq._input_loop = loop + return seq @pytest.fixture(autouse=True) -def _close_input_loops () -> typing.Iterator[None]: +def _close_input_loops() -> typing.Iterator[None]: + """Close every input loop created during the test.""" - """Close every input loop created during the test.""" + yield - yield + while _created_input_loops: + _created_input_loops.pop().close() - while _created_input_loops: - _created_input_loops.pop().close() - -def _cc_msg (control: int, value: int, channel: int = 0) -> mido.Message: - return mido.Message('control_change', channel=channel, control=control, value=value) +def _cc_msg(control: int, value: int, channel: int = 0) -> mido.Message: + return mido.Message("control_change", channel=channel, control=control, value=value) # --------------------------------------------------------------------------- # Composition API — registration # --------------------------------------------------------------------------- -def test_cc_forward_stores_mapping (patch_midi: None) -> None: - """cc_forward() should append one entry to _cc_forwards.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, "cc") - assert len(comp._cc_forwards) == 1 - fwd = comp._cc_forwards[0] - assert fwd['cc'] == 1 - assert fwd['mode'] == 'instant' - assert callable(fwd['transform']) + +def test_cc_forward_stores_mapping(patch_midi: None) -> None: + """cc_forward() should append one entry to _cc_forwards.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, "cc") + assert len(comp._cc_forwards) == 1 + fwd = comp._cc_forwards[0] + assert fwd["cc"] == 1 + assert fwd["mode"] == "instant" + assert callable(fwd["transform"]) -def test_cc_forward_invalid_mode_raises (patch_midi: None) -> None: - comp = subsequence.Composition(bpm=120) - with pytest.raises(ValueError, match="mode"): - comp.cc_forward(1, "cc", mode="realtime") +def test_cc_forward_invalid_mode_raises(patch_midi: None) -> None: + comp = subsequence.Composition(bpm=120) + with pytest.raises(ValueError, match="mode"): + comp.cc_forward(1, "cc", mode="realtime") -def test_cc_forward_invalid_cc_raises (patch_midi: None) -> None: - comp = subsequence.Composition(bpm=120) - with pytest.raises(ValueError): - comp.cc_forward(200, "cc") +def test_cc_forward_invalid_cc_raises(patch_midi: None) -> None: + comp = subsequence.Composition(bpm=120) + with pytest.raises(ValueError): + comp.cc_forward(200, "cc") -def test_cc_forward_coexists_with_cc_map (patch_midi: None) -> None: - """Same CC can be registered in both cc_map and cc_forward.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, "cc") - comp.cc_map(1, "mod_wheel") - assert len(comp._cc_forwards) == 1 - assert len(comp._cc_mappings) == 1 +def test_cc_forward_coexists_with_cc_map(patch_midi: None) -> None: + """Same CC can be registered in both cc_map and cc_forward.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, "cc") + comp.cc_map(1, "mod_wheel") + assert len(comp._cc_forwards) == 1 + assert len(comp._cc_mappings) == 1 # --------------------------------------------------------------------------- # Transform presets # --------------------------------------------------------------------------- -def test_preset_cc_identity (patch_midi: None) -> None: - """'cc' preset: identity forward, same CC number and value.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(74, "cc") - transform = comp._cc_forwards[0]['transform'] - msg = transform(64, 0) - assert msg is not None - assert msg.type == 'control_change' - assert msg.control == 74 - assert msg.value == 64 - - -def test_preset_cc_remap (patch_midi: None) -> None: - """'cc:N' preset: forward as CC number N.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, "cc:74") - transform = comp._cc_forwards[0]['transform'] - msg = transform(100, 0) - assert msg is not None - assert msg.type == 'control_change' - assert msg.control == 74 - assert msg.value == 100 - - -def test_preset_cc_remap_invalid_raises (patch_midi: None) -> None: - comp = subsequence.Composition(bpm=120) - with pytest.raises(ValueError): - comp.cc_forward(1, "cc:banana") - - -def test_preset_pitchwheel_min (patch_midi: None) -> None: - """'pitchwheel' preset: CC 0 → pitch -8192.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, "pitchwheel") - transform = comp._cc_forwards[0]['transform'] - msg = transform(0, 0) - assert msg is not None - assert msg.type == 'pitchwheel' - assert msg.pitch == -8192 - - -def test_preset_pitchwheel_max (patch_midi: None) -> None: - """'pitchwheel' preset: CC 127 → pitch 8191.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, "pitchwheel") - transform = comp._cc_forwards[0]['transform'] - msg = transform(127, 0) - assert msg is not None - assert msg.type == 'pitchwheel' - assert msg.pitch == 8191 - - -def test_preset_pitchwheel_midpoint (patch_midi: None) -> None: - """'pitchwheel' preset: CC 64 should be near 0.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, "pitchwheel") - transform = comp._cc_forwards[0]['transform'] - msg = transform(64, 0) - assert msg is not None - assert abs(msg.pitch) < 200 # close to 0 - - -def test_callable_transform (patch_midi: None) -> None: - """User-supplied callable is stored and invoked correctly.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, lambda v, ch: mido.Message('control_change', channel=ch, control=74, value=v // 2)) - transform = comp._cc_forwards[0]['transform'] - msg = transform(100, 0) - assert msg is not None - assert msg.control == 74 - assert msg.value == 50 - - -def test_callable_can_return_none (patch_midi: None) -> None: - """Callable returning None suppresses forwarding.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, lambda v, ch: None) - transform = comp._cc_forwards[0]['transform'] - assert transform(64, 0) is None - - -def test_unknown_preset_raises (patch_midi: None) -> None: - comp = subsequence.Composition(bpm=120) - with pytest.raises(ValueError, match="unknown preset"): - comp.cc_forward(1, "pitchbend") + +def test_preset_cc_identity(patch_midi: None) -> None: + """'cc' preset: identity forward, same CC number and value.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(74, "cc") + transform = comp._cc_forwards[0]["transform"] + msg = transform(64, 0) + assert msg is not None + assert msg.type == "control_change" + assert msg.control == 74 + assert msg.value == 64 + + +def test_preset_cc_remap(patch_midi: None) -> None: + """'cc:N' preset: forward as CC number N.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, "cc:74") + transform = comp._cc_forwards[0]["transform"] + msg = transform(100, 0) + assert msg is not None + assert msg.type == "control_change" + assert msg.control == 74 + assert msg.value == 100 + + +def test_preset_cc_remap_invalid_raises(patch_midi: None) -> None: + comp = subsequence.Composition(bpm=120) + with pytest.raises(ValueError): + comp.cc_forward(1, "cc:banana") + + +def test_preset_pitchwheel_min(patch_midi: None) -> None: + """'pitchwheel' preset: CC 0 → pitch -8192.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, "pitchwheel") + transform = comp._cc_forwards[0]["transform"] + msg = transform(0, 0) + assert msg is not None + assert msg.type == "pitchwheel" + assert msg.pitch == -8192 + + +def test_preset_pitchwheel_max(patch_midi: None) -> None: + """'pitchwheel' preset: CC 127 → pitch 8191.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, "pitchwheel") + transform = comp._cc_forwards[0]["transform"] + msg = transform(127, 0) + assert msg is not None + assert msg.type == "pitchwheel" + assert msg.pitch == 8191 + + +def test_preset_pitchwheel_midpoint(patch_midi: None) -> None: + """'pitchwheel' preset: CC 64 should be near 0.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, "pitchwheel") + transform = comp._cc_forwards[0]["transform"] + msg = transform(64, 0) + assert msg is not None + assert abs(msg.pitch) < 200 # close to 0 + + +def test_callable_transform(patch_midi: None) -> None: + """User-supplied callable is stored and invoked correctly.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward( + 1, + lambda v, ch: mido.Message( + "control_change", channel=ch, control=74, value=v // 2 + ), + ) + transform = comp._cc_forwards[0]["transform"] + msg = transform(100, 0) + assert msg is not None + assert msg.control == 74 + assert msg.value == 50 + + +def test_callable_can_return_none(patch_midi: None) -> None: + """Callable returning None suppresses forwarding.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, lambda v, ch: None) + transform = comp._cc_forwards[0]["transform"] + assert transform(64, 0) is None + + +def test_unknown_preset_raises(patch_midi: None) -> None: + comp = subsequence.Composition(bpm=120) + with pytest.raises(ValueError, match="unknown preset"): + comp.cc_forward(1, "pitchbend") # --------------------------------------------------------------------------- # Sequencer — instant mode # --------------------------------------------------------------------------- -def test_instant_sends_immediately (patch_midi: None) -> None: - """Instant forward should call midi_out.send() from _on_midi_input.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_forwards = [{ - 'cc': 1, - 'channel': None, - 'mode': 'instant', - 'transform': lambda v, ch: mido.Message('control_change', channel=ch, control=74, value=v), - }] - seq._on_midi_input(_cc_msg(1, 64)) - assert len(spy.sent) == 1 - assert spy.sent[0].type == 'control_change' - assert spy.sent[0].value == 64 - - -def test_instant_channel_filter_match (patch_midi: None) -> None: - """Instant forward with channel filter should fire on matching channel.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_forwards = [{ - 'cc': 1, - 'channel': 2, # 0-indexed - 'mode': 'instant', - 'transform': lambda v, ch: mido.Message('control_change', channel=ch, control=1, value=v), - }] - seq._on_midi_input(_cc_msg(1, 64, channel=2)) - assert len(spy.sent) == 1 - - -def test_instant_channel_filter_no_match (patch_midi: None) -> None: - """Instant forward with channel filter should not fire on non-matching channel.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_forwards = [{ - 'cc': 1, - 'channel': 2, - 'mode': 'instant', - 'transform': lambda v, ch: mido.Message('control_change', channel=ch, control=1, value=v), - }] - seq._on_midi_input(_cc_msg(1, 64, channel=3)) - assert len(spy.sent) == 0 - - -def test_instant_cc_filter (patch_midi: None) -> None: - """Instant forward should not fire on non-matching CC number.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_forwards = [{ - 'cc': 74, - 'channel': None, - 'mode': 'instant', - 'transform': lambda v, ch: mido.Message('control_change', channel=ch, control=74, value=v), - }] - seq._on_midi_input(_cc_msg(1, 64)) # CC 1, not 74 - assert len(spy.sent) == 0 - - -def test_instant_transform_exception_does_not_crash (patch_midi: None) -> None: - """A broken transform should log but not raise from _on_midi_input.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - - def _bad_transform (v: int, ch: int) -> int: - raise RuntimeError("transform error") - - seq.cc_forwards = [{ - 'cc': 1, - 'channel': None, - 'mode': 'instant', - 'transform': _bad_transform, - }] - # Should not raise - seq._on_midi_input(_cc_msg(1, 64)) - assert len(spy.sent) == 0 - - -def test_instant_transform_none_suppresses (patch_midi: None) -> None: - """Instant forward with transform returning None should not call send.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_forwards = [{ - 'cc': 1, - 'channel': None, - 'mode': 'instant', - 'transform': lambda v, ch: None, - }] - seq._on_midi_input(_cc_msg(1, 64)) - assert len(spy.sent) == 0 + +def test_instant_sends_immediately(patch_midi: None) -> None: + """Instant forward should call midi_out.send() from _on_midi_input.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_forwards = [ + { + "cc": 1, + "channel": None, + "mode": "instant", + "transform": lambda v, ch: mido.Message( + "control_change", channel=ch, control=74, value=v + ), + } + ] + seq._on_midi_input(_cc_msg(1, 64)) + assert len(spy.sent) == 1 + assert spy.sent[0].type == "control_change" + assert spy.sent[0].value == 64 + + +def test_instant_channel_filter_match(patch_midi: None) -> None: + """Instant forward with channel filter should fire on matching channel.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_forwards = [ + { + "cc": 1, + "channel": 2, # 0-indexed + "mode": "instant", + "transform": lambda v, ch: mido.Message( + "control_change", channel=ch, control=1, value=v + ), + } + ] + seq._on_midi_input(_cc_msg(1, 64, channel=2)) + assert len(spy.sent) == 1 + + +def test_instant_channel_filter_no_match(patch_midi: None) -> None: + """Instant forward with channel filter should not fire on non-matching channel.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_forwards = [ + { + "cc": 1, + "channel": 2, + "mode": "instant", + "transform": lambda v, ch: mido.Message( + "control_change", channel=ch, control=1, value=v + ), + } + ] + seq._on_midi_input(_cc_msg(1, 64, channel=3)) + assert len(spy.sent) == 0 + + +def test_instant_cc_filter(patch_midi: None) -> None: + """Instant forward should not fire on non-matching CC number.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_forwards = [ + { + "cc": 74, + "channel": None, + "mode": "instant", + "transform": lambda v, ch: mido.Message( + "control_change", channel=ch, control=74, value=v + ), + } + ] + seq._on_midi_input(_cc_msg(1, 64)) # CC 1, not 74 + assert len(spy.sent) == 0 + + +def test_instant_transform_exception_does_not_crash(patch_midi: None) -> None: + """A broken transform should log but not raise from _on_midi_input.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + + def _bad_transform(v: int, ch: int) -> int: + raise RuntimeError("transform error") + + seq.cc_forwards = [ + { + "cc": 1, + "channel": None, + "mode": "instant", + "transform": _bad_transform, + } + ] + # Should not raise + seq._on_midi_input(_cc_msg(1, 64)) + assert len(spy.sent) == 0 + + +def test_instant_transform_none_suppresses(patch_midi: None) -> None: + """Instant forward with transform returning None should not call send.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_forwards = [ + { + "cc": 1, + "channel": None, + "mode": "instant", + "transform": lambda v, ch: None, + } + ] + seq._on_midi_input(_cc_msg(1, 64)) + assert len(spy.sent) == 0 # --------------------------------------------------------------------------- # Sequencer — queued mode # --------------------------------------------------------------------------- -def test_queued_enters_buffer (patch_midi: None) -> None: - """Queued forward should append to _forward_buffer.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_forwards = [{ - 'cc': 1, - 'channel': None, - 'mode': 'queued', - 'transform': lambda v, ch: mido.Message('control_change', channel=ch, control=74, value=v), - }] - seq._on_midi_input(_cc_msg(1, 64)) - assert len(seq._forward_buffer) == 1 - assert len(spy.sent) == 0 # not sent immediately - - -@pytest.mark.asyncio -async def test_queued_drained_in_process_pulse (patch_midi: None) -> None: - """_process_pulse() should drain _forward_buffer and send the messages.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq._event_loop = asyncio.get_event_loop() - - out_msg = mido.Message('control_change', channel=0, control=74, value=64) - seq._forward_buffer.append((0, out_msg, 0)) - - await seq._process_pulse(0) - - assert len(seq._forward_buffer) == 0 - assert any(m.control == 74 and m.value == 64 for m in spy.sent) - -def test_midi_event_from_mido_cc (patch_midi: None) -> None: - """from_mido should correctly convert a CC message.""" - msg = mido.Message('control_change', channel=1, control=74, value=100) - event = subsequence.sequencer.MidiEvent.from_mido(10, msg) - assert event.pulse == 10 - assert event.message_type == 'control_change' - assert event.channel == 1 - assert event.control == 74 - assert event.value == 100 +def test_queued_enters_buffer(patch_midi: None) -> None: + """Queued forward should append to _forward_buffer.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_forwards = [ + { + "cc": 1, + "channel": None, + "mode": "queued", + "transform": lambda v, ch: mido.Message( + "control_change", channel=ch, control=74, value=v + ), + } + ] + seq._on_midi_input(_cc_msg(1, 64)) + assert len(seq._forward_buffer) == 1 + assert len(spy.sent) == 0 # not sent immediately -def test_midi_event_from_mido_pitchwheel (patch_midi: None) -> None: - """from_mido should correctly convert a pitchwheel message.""" - msg = mido.Message('pitchwheel', channel=0, pitch=-4096) - event = subsequence.sequencer.MidiEvent.from_mido(5, msg) - assert event.pulse == 5 - assert event.message_type == 'pitchwheel' - assert event.value == -4096 - @pytest.mark.asyncio -async def test_queued_forward_routes_to_output_device (patch_midi: None) -> None: - - """Queued forwards must reach their configured output device, not device 0. - - Regression: the queued buffer dropped the output device, so every queued - forward was sent to the primary output. - """ - - spy_primary = conftest.SpyMidiOut() - spy_extra = conftest.SpyMidiOut() - seq = _make_sequencer(spy_primary) - seq.add_output_device("Extra", spy_extra) - seq._event_loop = asyncio.get_event_loop() - - seq.cc_forwards = [{ - 'cc': 74, - 'channel': None, - 'mode': 'queued', - 'transform': lambda value, channel: mido.Message('control_change', channel=0, control=74, value=value), - 'input_device': None, - 'output_device': 1, - }] - - seq._on_midi_input(mido.Message('control_change', channel=0, control=74, value=99), device_idx=0) +async def test_queued_drained_in_process_pulse(patch_midi: None) -> None: + """_process_pulse() should drain _forward_buffer and send the messages.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq._event_loop = asyncio.get_event_loop() - await seq._process_pulse(seq.pulse_count) + out_msg = mido.Message("control_change", channel=0, control=74, value=64) + seq._forward_buffer.append((0, out_msg, 0)) - assert any(m.type == 'control_change' and m.value == 99 for m in spy_extra.sent) - assert not any(m.type == 'control_change' and m.value == 99 for m in spy_primary.sent) + await seq._process_pulse(0) -def test_callable_with_output_channel_rechannels (patch_midi: None) -> None: + assert len(seq._forward_buffer) == 0 + assert any(m.control == 74 and m.value == 64 for m in spy.sent) - """A callable transform combined with output_channel= must re-channel, not crash. - Regression: the wrapper rebuilt the message passing 'type' twice, raising - TypeError on every incoming CC so nothing was ever forwarded. - """ +def test_midi_event_from_mido_cc(patch_midi: None) -> None: + """from_mido should correctly convert a CC message.""" + msg = mido.Message("control_change", channel=1, control=74, value=100) + event = subsequence.sequencer.MidiEvent.from_mido(10, msg) + assert event.pulse == 10 + assert event.message_type == "control_change" + assert event.channel == 1 + assert event.control == 74 + assert event.value == 100 - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - comp.midi_input("Dummy MIDI") - comp.cc_forward( - 1, - lambda value, channel: mido.Message('control_change', channel=channel, control=10, value=value), - output_channel=5, - ) - transform = comp._cc_forwards[0]['transform'] - result = transform(64, 2) +def test_midi_event_from_mido_pitchwheel(patch_midi: None) -> None: + """from_mido should correctly convert a pitchwheel message.""" + msg = mido.Message("pitchwheel", channel=0, pitch=-4096) + event = subsequence.sequencer.MidiEvent.from_mido(5, msg) + assert event.pulse == 5 + assert event.message_type == "pitchwheel" + assert event.value == -4096 - assert result is not None - assert result.type == 'control_change' - assert result.value == 64 - assert result.channel == 4 # user channel 5 -> 0-indexed 4 - -def test_callable_with_output_channel_passes_none_through (patch_midi: None) -> None: - - """A callable returning None (filter) must stay None under output_channel=.""" - - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - comp.midi_input("Dummy MIDI") - comp.cc_forward(1, lambda value, channel: None, output_channel=5) - - assert comp._cc_forwards[0]['transform'](64, 2) is None +@pytest.mark.asyncio +async def test_queued_forward_routes_to_output_device(patch_midi: None) -> None: + """Queued forwards must reach their configured output device, not device 0. + + Regression: the queued buffer dropped the output device, so every queued + forward was sent to the primary output. + """ + + spy_primary = conftest.SpyMidiOut() + spy_extra = conftest.SpyMidiOut() + seq = _make_sequencer(spy_primary) + seq.add_output_device("Extra", spy_extra) + seq._event_loop = asyncio.get_event_loop() + + seq.cc_forwards = [ + { + "cc": 74, + "channel": None, + "mode": "queued", + "transform": lambda value, channel: mido.Message( + "control_change", channel=0, control=74, value=value + ), + "input_device": None, + "output_device": 1, + } + ] + + seq._on_midi_input( + mido.Message("control_change", channel=0, control=74, value=99), device_idx=0 + ) + + await seq._process_pulse(seq.pulse_count) + + assert any(m.type == "control_change" and m.value == 99 for m in spy_extra.sent) + assert not any( + m.type == "control_change" and m.value == 99 for m in spy_primary.sent + ) + + +def test_callable_with_output_channel_rechannels(patch_midi: None) -> None: + """A callable transform combined with output_channel= must re-channel, not crash. + + Regression: the wrapper rebuilt the message passing 'type' twice, raising + TypeError on every incoming CC so nothing was ever forwarded. + """ + + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + comp.midi_input("Dummy MIDI") + comp.cc_forward( + 1, + lambda value, channel: mido.Message( + "control_change", channel=channel, control=10, value=value + ), + output_channel=5, + ) + + transform = comp._cc_forwards[0]["transform"] + result = transform(64, 2) + + assert result is not None + assert result.type == "control_change" + assert result.value == 64 + assert result.channel == 4 # user channel 5 -> 0-indexed 4 + + +def test_callable_with_output_channel_passes_none_through(patch_midi: None) -> None: + """A callable returning None (filter) must stay None under output_channel=.""" + + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + comp.midi_input("Dummy MIDI") + comp.cc_forward(1, lambda value, channel: None, output_channel=5) + + assert comp._cc_forwards[0]["transform"](64, 2) is None diff --git a/tests/test_cc_name_map.py b/tests/test_cc_name_map.py index 6008cfc..f90275e 100644 --- a/tests/test_cc_name_map.py +++ b/tests/test_cc_name_map.py @@ -11,104 +11,96 @@ CC_MAP = {"filter_cutoff": 74, "volume": 7, "expression": 11} -def _make_builder (cc_name_map: dict = None) -> tuple: +def _make_builder(cc_name_map: dict = None) -> tuple: + """Create a Pattern and PatternBuilder pair with optional cc_name_map.""" - """Create a Pattern and PatternBuilder pair with optional cc_name_map.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) - pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + cc_name_map=cc_name_map, + default_grid=16, + ) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - cc_name_map = cc_name_map, - default_grid = 16, - ) + return pattern, builder - return pattern, builder +def test_cc_string_resolves_via_cc_name_map() -> None: + """A string CC name should resolve to the correct CC number via cc_name_map.""" -def test_cc_string_resolves_via_cc_name_map () -> None: + pattern, builder = _make_builder(cc_name_map=CC_MAP) + builder.cc("filter_cutoff", 100) - """A string CC name should resolve to the correct CC number via cc_name_map.""" + assert len(pattern.cc_events) == 1 + assert pattern.cc_events[0].control == 74 + assert pattern.cc_events[0].value == 100 - pattern, builder = _make_builder(cc_name_map=CC_MAP) - builder.cc("filter_cutoff", 100) - assert len(pattern.cc_events) == 1 - assert pattern.cc_events[0].control == 74 - assert pattern.cc_events[0].value == 100 +def test_cc_int_still_works_with_cc_name_map() -> None: + """Raw integer CC numbers should still work when a cc_name_map is set.""" + pattern, builder = _make_builder(cc_name_map=CC_MAP) + builder.cc(74, 100) -def test_cc_int_still_works_with_cc_name_map () -> None: + assert len(pattern.cc_events) == 1 + assert pattern.cc_events[0].control == 74 - """Raw integer CC numbers should still work when a cc_name_map is set.""" - pattern, builder = _make_builder(cc_name_map=CC_MAP) - builder.cc(74, 100) +def test_cc_int_works_without_cc_name_map() -> None: + """Raw integer CC numbers should work when no cc_name_map is provided.""" - assert len(pattern.cc_events) == 1 - assert pattern.cc_events[0].control == 74 + pattern, builder = _make_builder() + builder.cc(74, 100) + assert len(pattern.cc_events) == 1 + assert pattern.cc_events[0].control == 74 -def test_cc_int_works_without_cc_name_map () -> None: - """Raw integer CC numbers should work when no cc_name_map is provided.""" +def test_cc_string_without_cc_name_map_raises() -> None: + """Using a string CC name without a cc_name_map should raise ValueError.""" - pattern, builder = _make_builder() - builder.cc(74, 100) + _, builder = _make_builder() - assert len(pattern.cc_events) == 1 - assert pattern.cc_events[0].control == 74 + with pytest.raises(ValueError, match="requires a cc_name_map"): + builder.cc("filter_cutoff", 100) -def test_cc_string_without_cc_name_map_raises () -> None: +def test_cc_unknown_name_raises() -> None: + """An unrecognised CC name should raise ValueError.""" - """Using a string CC name without a cc_name_map should raise ValueError.""" + _, builder = _make_builder(cc_name_map=CC_MAP) - _, builder = _make_builder() + with pytest.raises(ValueError, match="not found in cc_name_map"): + builder.cc("nonexistent_param", 100) - with pytest.raises(ValueError, match="requires a cc_name_map"): - builder.cc("filter_cutoff", 100) +def test_cc_ramp_string_resolves() -> None: + """cc_ramp() should resolve string CC names via cc_name_map.""" -def test_cc_unknown_name_raises () -> None: + pattern, builder = _make_builder(cc_name_map=CC_MAP) + builder.cc_ramp("filter_cutoff", 0, 127, beat_start=0, beat_end=1, resolution=24) - """An unrecognised CC name should raise ValueError.""" + assert len(pattern.cc_events) >= 1 + assert all(e.control == 74 for e in pattern.cc_events) + assert pattern.cc_events[0].value == 0 + assert pattern.cc_events[-1].value == 127 - _, builder = _make_builder(cc_name_map=CC_MAP) - with pytest.raises(ValueError, match="not found in cc_name_map"): - builder.cc("nonexistent_param", 100) +def test_cc_name_map_passed_through_decorator() -> None: + """cc_name_map should flow from @composition.pattern() to PatternBuilder.""" + import subsequence -def test_cc_ramp_string_resolves () -> None: + composition = subsequence.Composition(bpm=120) - """cc_ramp() should resolve string CC names via cc_name_map.""" + @composition.pattern(channel=1, beats=4, cc_name_map=CC_MAP) + def sweep(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.cc("filter_cutoff", 100) - pattern, builder = _make_builder(cc_name_map=CC_MAP) - builder.cc_ramp("filter_cutoff", 0, 127, beat_start=0, beat_end=1, resolution=24) - - assert len(pattern.cc_events) >= 1 - assert all(e.control == 74 for e in pattern.cc_events) - assert pattern.cc_events[0].value == 0 - assert pattern.cc_events[-1].value == 127 - - -def test_cc_name_map_passed_through_decorator () -> None: - - """cc_name_map should flow from @composition.pattern() to PatternBuilder.""" - - import subsequence - - composition = subsequence.Composition(bpm=120) - - @composition.pattern(channel=1, beats=4, cc_name_map=CC_MAP) - def sweep (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.cc("filter_cutoff", 100) - - # The pattern is registered as pending — verify it carries the cc_name_map - assert len(composition._pending_patterns) == 1 - assert composition._pending_patterns[0].cc_name_map is CC_MAP + # The pattern is registered as pending — verify it carries the cc_name_map + assert len(composition._pending_patterns) == 1 + assert composition._pending_patterns[0].cc_name_map is CC_MAP # --- nrpn_name_map (parallel to cc_name_map) --- @@ -117,52 +109,49 @@ def sweep (p: "subsequence.pattern_builder.PatternBuilder") -> None: NRPN_MAP = {"osc1_freq_fine": 9, "filter_freq": 29, "lfo1_amt": 62} -def test_nrpn_string_resolves_via_nrpn_name_map () -> None: - - """A string NRPN name should resolve via nrpn_name_map.""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - nrpn_name_map = NRPN_MAP, - default_grid = 16, - ) - builder.nrpn("filter_freq", 100, null_reset=False) - - # CC 99 (NRPN MSB) = 0; CC 98 (NRPN LSB) = 29 (filter_freq) - assert pattern.cc_events[0].value == 0 - assert pattern.cc_events[1].value == 29 +def test_nrpn_string_resolves_via_nrpn_name_map() -> None: + """A string NRPN name should resolve via nrpn_name_map.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + nrpn_name_map=NRPN_MAP, + default_grid=16, + ) + builder.nrpn("filter_freq", 100, null_reset=False) -def test_nrpn_name_map_passed_through_decorator () -> None: + # CC 99 (NRPN MSB) = 0; CC 98 (NRPN LSB) = 29 (filter_freq) + assert pattern.cc_events[0].value == 0 + assert pattern.cc_events[1].value == 29 - """nrpn_name_map should flow from @composition.pattern() to PatternBuilder.""" - import subsequence +def test_nrpn_name_map_passed_through_decorator() -> None: + """nrpn_name_map should flow from @composition.pattern() to PatternBuilder.""" - composition = subsequence.Composition(bpm=120) + import subsequence - @composition.pattern(channel=1, beats=4, nrpn_name_map=NRPN_MAP) - def sweep (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.nrpn("filter_freq", 500, fine=True) + composition = subsequence.Composition(bpm=120) - # Pattern registered as pending — confirm the map is carried through - assert len(composition._pending_patterns) == 1 - assert composition._pending_patterns[0].nrpn_name_map is NRPN_MAP + @composition.pattern(channel=1, beats=4, nrpn_name_map=NRPN_MAP) + def sweep(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.nrpn("filter_freq", 500, fine=True) + # Pattern registered as pending — confirm the map is carried through + assert len(composition._pending_patterns) == 1 + assert composition._pending_patterns[0].nrpn_name_map is NRPN_MAP -def test_nrpn_unknown_string_with_map_raises () -> None: - """Strings absent from nrpn_name_map raise ValueError.""" +def test_nrpn_unknown_string_with_map_raises() -> None: + """Strings absent from nrpn_name_map raise ValueError.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - nrpn_name_map = NRPN_MAP, - default_grid = 16, - ) + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + nrpn_name_map=NRPN_MAP, + default_grid=16, + ) - with pytest.raises(ValueError, match="Unknown NRPN name"): - builder.nrpn("not_in_map", 50) + with pytest.raises(ValueError, match="Unknown NRPN name"): + builder.nrpn("not_in_map", 50) diff --git a/tests/test_chord_graphs.py b/tests/test_chord_graphs.py index 4737bc3..7dc24c8 100644 --- a/tests/test_chord_graphs.py +++ b/tests/test_chord_graphs.py @@ -9,272 +9,270 @@ import subsequence.harmonic_state -def test_aeolian_minor_phrygian_cadence () -> None: +def test_aeolian_minor_phrygian_cadence() -> None: + """The aeolian minor graph should include a bII to i (Phrygian cadence) edge.""" - """The aeolian minor graph should include a bII to i (Phrygian cadence) edge.""" + graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() + graph, tonic = graph_obj.build("A") - graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() - graph, tonic = graph_obj.build("A") + # bII of A minor is Bb major (root_pc=10+1=11? No: A=9, bII = 9+1=10 = Bb) + flat_two = subsequence.chords.Chord(root_pc=10, quality="major") - # bII of A minor is Bb major (root_pc=10+1=11? No: A=9, bII = 9+1=10 = Bb) - flat_two = subsequence.chords.Chord(root_pc=10, quality="major") + transitions = graph.get_transitions(flat_two) - transitions = graph.get_transitions(flat_two) + assert any(chord == tonic for chord, _ in transitions) - assert any(chord == tonic for chord, _ in transitions) +def test_aeolian_minor_authentic_cadence() -> None: + """The aeolian minor graph should include a V to i edge.""" -def test_aeolian_minor_authentic_cadence () -> None: + graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() + graph, tonic = graph_obj.build("A") - """The aeolian minor graph should include a V to i edge.""" + # V of A minor is E major (root_pc=4) + dominant = subsequence.chords.Chord(root_pc=4, quality="major") - graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() - graph, tonic = graph_obj.build("A") + transitions = graph.get_transitions(dominant) - # V of A minor is E major (root_pc=4) - dominant = subsequence.chords.Chord(root_pc=4, quality="major") + assert any(chord == tonic for chord, _ in transitions) - transitions = graph.get_transitions(dominant) - assert any(chord == tonic for chord, _ in transitions) +def test_aeolian_minor_plagal() -> None: + """The aeolian minor graph should include an iv to i edge.""" + graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() + graph, tonic = graph_obj.build("A") -def test_aeolian_minor_plagal () -> None: + # iv of A minor is D minor (root_pc=2) + subdominant = subsequence.chords.Chord(root_pc=2, quality="minor") - """The aeolian minor graph should include an iv to i edge.""" + transitions = graph.get_transitions(subdominant) - graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() - graph, tonic = graph_obj.build("A") + assert any(chord == tonic for chord, _ in transitions) - # iv of A minor is D minor (root_pc=2) - subdominant = subsequence.chords.Chord(root_pc=2, quality="minor") - transitions = graph.get_transitions(subdominant) +def test_aeolian_minor_tonic_is_minor() -> None: + """The aeolian minor tonic should be a minor chord.""" - assert any(chord == tonic for chord, _ in transitions) + graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() + _, tonic = graph_obj.build("A") + assert tonic.quality == "minor" + assert tonic.root_pc == 9 -def test_aeolian_minor_tonic_is_minor () -> None: - """The aeolian minor tonic should be a minor chord.""" +def test_aeolian_minor_gravity_sets() -> None: + """The aeolian minor gravity sets should include minor-key chords.""" - graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() - _, tonic = graph_obj.build("A") + graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() + diatonic, functional = graph_obj.gravity_sets("A") - assert tonic.quality == "minor" - assert tonic.root_pc == 9 + # Tonic (Am) should be in both sets. + tonic = subsequence.chords.Chord(root_pc=9, quality="minor") + assert tonic in diatonic + assert tonic in functional -def test_aeolian_minor_gravity_sets () -> None: + # bII (Bb major) should be in functional set. + flat_two = subsequence.chords.Chord(root_pc=10, quality="major") - """The aeolian minor gravity sets should include minor-key chords.""" + assert flat_two in functional - graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor() - diatonic, functional = graph_obj.gravity_sets("A") + # V (E major) should be in functional set. + dominant = subsequence.chords.Chord(root_pc=4, quality="major") - # Tonic (Am) should be in both sets. - tonic = subsequence.chords.Chord(root_pc=9, quality="minor") + assert dominant in functional - assert tonic in diatonic - assert tonic in functional - # bII (Bb major) should be in functional set. - flat_two = subsequence.chords.Chord(root_pc=10, quality="major") +def test_aeolian_minor_no_dead_ends() -> None: + """Every chord reachable in the aeolian minor graph should have at least one outgoing transition.""" - assert flat_two in functional + graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor( + include_dominant_7th=True + ) + graph, tonic = graph_obj.build("A") - # V (E major) should be in functional set. - dominant = subsequence.chords.Chord(root_pc=4, quality="major") + # Walk every reachable chord via BFS from tonic. + visited = set() + queue = [tonic] - assert dominant in functional + while queue: + current = queue.pop(0) + if current in visited: + continue -def test_aeolian_minor_no_dead_ends () -> None: + visited.add(current) - """Every chord reachable in the aeolian minor graph should have at least one outgoing transition.""" + transitions = graph.get_transitions(current) - graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor(include_dominant_7th=True) - graph, tonic = graph_obj.build("A") + assert len(transitions) > 0, f"Dead end: {current} has no outgoing transitions" - # Walk every reachable chord via BFS from tonic. - visited = set() - queue = [tonic] + for target, _ in transitions: + if target not in visited: + queue.append(target) - while queue: - current = queue.pop(0) - if current in visited: - continue +def test_harmonic_state_accepts_chord_graph_instance() -> None: + """HarmonicState should accept a ChordGraph instance directly.""" - visited.add(current) + graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor( + include_dominant_7th=True + ) - transitions = graph.get_transitions(current) + state = subsequence.harmonic_state.HarmonicState( + key_name="E", graph_style=graph_obj, key_gravity_blend=0.8 + ) - assert len(transitions) > 0, f"Dead end: {current} has no outgoing transitions" + assert state.current_chord.quality == "minor" + assert state.key_name == "E" - for target, _ in transitions: - if target not in visited: - queue.append(target) +def test_harmonic_state_new_string_names() -> None: + """HarmonicState should accept the new canonical string names.""" + state_diatonic = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="diatonic_major" + ) -def test_harmonic_state_accepts_chord_graph_instance () -> None: + assert state_diatonic.current_chord.quality == "major" - """HarmonicState should accept a ChordGraph instance directly.""" + state_turnaround = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="turnaround" + ) - graph_obj = subsequence.chord_graphs.aeolian_minor.AeolianMinor(include_dominant_7th=True) + assert state_turnaround.current_chord.quality == "major" - state = subsequence.harmonic_state.HarmonicState( - key_name = "E", - graph_style = graph_obj, - key_gravity_blend = 0.8 - ) + state_aeolian = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="aeolian_minor" + ) - assert state.current_chord.quality == "minor" - assert state.key_name == "E" + assert state_aeolian.current_chord.quality == "minor" + state_phrygian = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="phrygian_minor" + ) -def test_harmonic_state_new_string_names () -> None: + assert state_phrygian.current_chord.quality == "minor" - """HarmonicState should accept the new canonical string names.""" - state_diatonic = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="diatonic_major") +def test_phrygian_minor_all_chords_are_minor() -> None: + """Every chord in the phrygian_minor graph must be minor quality.""" - assert state_diatonic.current_chord.quality == "major" + graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() + graph, tonic = graph_obj.build("E") - state_turnaround = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="turnaround") + # Walk every reachable chord and verify quality. + visited = set() + queue = [tonic] - assert state_turnaround.current_chord.quality == "major" + while queue: + current = queue.pop(0) - state_aeolian = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="aeolian_minor") + if current in visited: + continue - assert state_aeolian.current_chord.quality == "minor" + visited.add(current) - state_phrygian = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="phrygian_minor") + assert current.quality == "minor", ( + f"{current} is {current.quality}, expected minor" + ) - assert state_phrygian.current_chord.quality == "minor" + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) + assert len(visited) == 4 -def test_phrygian_minor_all_chords_are_minor () -> None: - """Every chord in the phrygian_minor graph must be minor quality.""" +def test_phrygian_minor_tonic() -> None: + """The phrygian_minor tonic should be a minor chord on the key root.""" - graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() - graph, tonic = graph_obj.build("E") + graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() + _, tonic = graph_obj.build("E") - # Walk every reachable chord and verify quality. - visited = set() - queue = [tonic] + assert tonic.quality == "minor" + assert tonic.root_pc == 4 - while queue: - current = queue.pop(0) - if current in visited: - continue +def test_phrygian_minor_phrygian_cadence() -> None: + """The phrygian_minor graph should include a bii to i Phrygian cadence.""" - visited.add(current) + graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() + graph, tonic = graph_obj.build("E") - assert current.quality == "minor", f"{current} is {current.quality}, expected minor" + # bii of E minor is F minor (root_pc=5). + flat_two = subsequence.chords.Chord(root_pc=5, quality="minor") - for target, _ in graph.get_transitions(current): + transitions = graph.get_transitions(flat_two) - if target not in visited: - queue.append(target) + assert any(chord == tonic for chord, _ in transitions) - assert len(visited) == 4 +def test_phrygian_minor_gravity_sets() -> None: + """Gravity sets should contain only minor chords.""" -def test_phrygian_minor_tonic () -> None: + graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() + diatonic, functional = graph_obj.gravity_sets("E") - """The phrygian_minor tonic should be a minor chord on the key root.""" + for chord in diatonic: + assert chord.quality == "minor" - graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() - _, tonic = graph_obj.build("E") + for chord in functional: + assert chord.quality == "minor" - assert tonic.quality == "minor" - assert tonic.root_pc == 4 + # Tonic should be in both. + tonic = subsequence.chords.Chord(root_pc=4, quality="minor") + assert tonic in diatonic + assert tonic in functional -def test_phrygian_minor_phrygian_cadence () -> None: + # bii should be in functional. + flat_two = subsequence.chords.Chord(root_pc=5, quality="minor") - """The phrygian_minor graph should include a bii to i Phrygian cadence.""" + assert flat_two in functional - graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() - graph, tonic = graph_obj.build("E") - # bii of E minor is F minor (root_pc=5). - flat_two = subsequence.chords.Chord(root_pc=5, quality="minor") +def test_phrygian_minor_no_dead_ends() -> None: + """Every chord in the phrygian_minor graph should have outgoing transitions.""" - transitions = graph.get_transitions(flat_two) + graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() + graph, tonic = graph_obj.build("E") - assert any(chord == tonic for chord, _ in transitions) + visited = set() + queue = [tonic] + while queue: + current = queue.pop(0) -def test_phrygian_minor_gravity_sets () -> None: + if current in visited: + continue - """Gravity sets should contain only minor chords.""" + visited.add(current) - graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() - diatonic, functional = graph_obj.gravity_sets("E") + transitions = graph.get_transitions(current) - for chord in diatonic: - assert chord.quality == "minor" + assert len(transitions) > 0, f"Dead end: {current} has no outgoing transitions" - for chord in functional: - assert chord.quality == "minor" + for target, _ in transitions: + if target not in visited: + queue.append(target) - # Tonic should be in both. - tonic = subsequence.chords.Chord(root_pc=4, quality="minor") - assert tonic in diatonic - assert tonic in functional +def test_phrygian_minor_string_name() -> None: + """HarmonicState should accept 'phrygian_minor' as a style string.""" - # bii should be in functional. - flat_two = subsequence.chords.Chord(root_pc=5, quality="minor") + state = subsequence.harmonic_state.HarmonicState( + key_name="E", graph_style="phrygian_minor" + ) - assert flat_two in functional + assert state.current_chord.quality == "minor" -def test_phrygian_minor_no_dead_ends () -> None: +def test_unknown_string_raises() -> None: + """An unknown graph style string should raise ValueError.""" - """Every chord in the phrygian_minor graph should have outgoing transitions.""" - - graph_obj = subsequence.chord_graphs.phrygian_minor.PhrygianMinor() - graph, tonic = graph_obj.build("E") - - visited = set() - queue = [tonic] - - while queue: - current = queue.pop(0) - - if current in visited: - continue - - visited.add(current) - - transitions = graph.get_transitions(current) - - assert len(transitions) > 0, f"Dead end: {current} has no outgoing transitions" - - for target, _ in transitions: - - if target not in visited: - queue.append(target) - - -def test_phrygian_minor_string_name () -> None: - - """HarmonicState should accept 'phrygian_minor' as a style string.""" - - state = subsequence.harmonic_state.HarmonicState(key_name="E", graph_style="phrygian_minor") - - assert state.current_chord.quality == "minor" - - -def test_unknown_string_raises () -> None: - - """An unknown graph style string should raise ValueError.""" - - with pytest.raises(ValueError, match="Unknown graph style"): - subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="nonexistent") + with pytest.raises(ValueError, match="Unknown graph style"): + subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="nonexistent" + ) diff --git a/tests/test_chord_quality_registry.py b/tests/test_chord_quality_registry.py index 723ec55..7388103 100644 --- a/tests/test_chord_quality_registry.py +++ b/tests/test_chord_quality_registry.py @@ -6,138 +6,131 @@ @pytest.fixture(autouse=True) -def _clean_registry (): +def _clean_registry(): + """Restore the quality tables after each test — registration is module-global.""" - """Restore the quality tables after each test — registration is module-global.""" + intervals_before = dict(subsequence.chords.CHORD_INTERVALS) + suffix_before = dict(subsequence.chords.CHORD_SUFFIX) + parse_before = dict(subsequence.chords._SUFFIX_TO_QUALITY) - intervals_before = dict(subsequence.chords.CHORD_INTERVALS) - suffix_before = dict(subsequence.chords.CHORD_SUFFIX) - parse_before = dict(subsequence.chords._SUFFIX_TO_QUALITY) + yield - yield - - subsequence.chords.CHORD_INTERVALS.clear() - subsequence.chords.CHORD_INTERVALS.update(intervals_before) - subsequence.chords.CHORD_SUFFIX.clear() - subsequence.chords.CHORD_SUFFIX.update(suffix_before) - subsequence.chords._SUFFIX_TO_QUALITY.clear() - subsequence.chords._SUFFIX_TO_QUALITY.update(parse_before) + subsequence.chords.CHORD_INTERVALS.clear() + subsequence.chords.CHORD_INTERVALS.update(intervals_before) + subsequence.chords.CHORD_SUFFIX.clear() + subsequence.chords.CHORD_SUFFIX.update(suffix_before) + subsequence.chords._SUFFIX_TO_QUALITY.clear() + subsequence.chords._SUFFIX_TO_QUALITY.update(parse_before) # --------------------------------------------------------------------------- # diminished_7th builtin # --------------------------------------------------------------------------- -def test_diminished_7th_builtin () -> None: - - """The full diminished seventh is a shipped quality with parseable suffixes.""" - chord = subsequence.chords.Chord(root_pc=11, quality="diminished_7th") +def test_diminished_7th_builtin() -> None: + """The full diminished seventh is a shipped quality with parseable suffixes.""" - assert chord.intervals() == [0, 3, 6, 9] - assert chord.name() == "Bdim7" - assert subsequence.chords.parse_chord("Bdim7") == chord - assert subsequence.chords.parse_chord("B°7") == chord - assert subsequence.chords.parse_chord("Bo7") == chord + chord = subsequence.chords.Chord(root_pc=11, quality="diminished_7th") + assert chord.intervals() == [0, 3, 6, 9] + assert chord.name() == "Bdim7" + assert subsequence.chords.parse_chord("Bdim7") == chord + assert subsequence.chords.parse_chord("B°7") == chord + assert subsequence.chords.parse_chord("Bo7") == chord -def test_half_diminished_seven_alias () -> None: - """The explicit ø7 spelling parses to half_diminished_7th.""" +def test_half_diminished_seven_alias() -> None: + """The explicit ø7 spelling parses to half_diminished_7th.""" - assert subsequence.chords.parse_chord("Aø7").quality == "half_diminished_7th" + assert subsequence.chords.parse_chord("Aø7").quality == "half_diminished_7th" # --------------------------------------------------------------------------- # register_chord_quality() # --------------------------------------------------------------------------- -def test_register_quality_with_suffix_round_trips () -> None: - - """A registered quality parses by suffix and prints it back.""" - - subsequence.chords.register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9") - - chord = subsequence.chords.parse_chord("Am9") - - assert chord == subsequence.chords.Chord(root_pc=9, quality="minor_9th") - assert chord.intervals() == [0, 3, 7, 10, 14] - assert chord.name() == "Am9" - assert subsequence.chords.parse_chord(chord.name()) == chord - - -def test_register_quality_without_suffix_prints_parenthesised () -> None: - - """A suffix-less quality never masquerades as a major chord.""" - subsequence.chords.register_chord_quality("quartal", [0, 5, 10]) +def test_register_quality_with_suffix_round_trips() -> None: + """A registered quality parses by suffix and prints it back.""" - chord = subsequence.chords.Chord(root_pc=0, quality="quartal") + subsequence.chords.register_chord_quality( + "minor_9th", [0, 3, 7, 10, 14], suffix="m9" + ) - assert chord.tones(60) == [60, 65, 70] - assert chord.name() == "C(quartal)" + chord = subsequence.chords.parse_chord("Am9") + assert chord == subsequence.chords.Chord(root_pc=9, quality="minor_9th") + assert chord.intervals() == [0, 3, 7, 10, 14] + assert chord.name() == "Am9" + assert subsequence.chords.parse_chord(chord.name()) == chord -def test_register_quality_is_idempotent () -> None: - """Re-registering a custom quality must not raise (live reload re-runs it).""" +def test_register_quality_without_suffix_prints_parenthesised() -> None: + """A suffix-less quality never masquerades as a major chord.""" - subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="q4") - subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="q4") + subsequence.chords.register_chord_quality("quartal", [0, 5, 10]) - assert subsequence.chords.parse_chord("Fq4").quality == "quartal" + chord = subsequence.chords.Chord(root_pc=0, quality="quartal") + assert chord.tones(60) == [60, 65, 70] + assert chord.name() == "C(quartal)" -def test_reregistration_drops_stale_suffix () -> None: - """Renaming a quality's suffix removes the old alias.""" +def test_register_quality_is_idempotent() -> None: + """Re-registering a custom quality must not raise (live reload re-runs it).""" - subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="q4") - subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="quar") + subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="q4") + subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="q4") - assert subsequence.chords.parse_chord("Cquar").quality == "quartal" + assert subsequence.chords.parse_chord("Fq4").quality == "quartal" - with pytest.raises(ValueError): - subsequence.chords.parse_chord("Cq4") +def test_reregistration_drops_stale_suffix() -> None: + """Renaming a quality's suffix removes the old alias.""" -def test_register_quality_rejects_builtin_name () -> None: + subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="q4") + subsequence.chords.register_chord_quality("quartal", [0, 5, 10], suffix="quar") - """Built-in qualities cannot be overwritten.""" + assert subsequence.chords.parse_chord("Cquar").quality == "quartal" - with pytest.raises(ValueError, match="built-in"): - subsequence.chords.register_chord_quality("minor", [0, 3, 7]) + with pytest.raises(ValueError): + subsequence.chords.parse_chord("Cq4") -def test_register_quality_rejects_builtin_suffix () -> None: +def test_register_quality_rejects_builtin_name() -> None: + """Built-in qualities cannot be overwritten.""" - """Built-in suffixes cannot be reused for a custom quality.""" + with pytest.raises(ValueError, match="built-in"): + subsequence.chords.register_chord_quality("minor", [0, 3, 7]) - with pytest.raises(ValueError, match="built-in"): - subsequence.chords.register_chord_quality("my_minor", [0, 3, 7], suffix="m") +def test_register_quality_rejects_builtin_suffix() -> None: + """Built-in suffixes cannot be reused for a custom quality.""" -def test_register_quality_validates_intervals () -> None: + with pytest.raises(ValueError, match="built-in"): + subsequence.chords.register_chord_quality("my_minor", [0, 3, 7], suffix="m") - """Interval rules: non-empty, ints, start at 0, strictly ascending, 0–24.""" - with pytest.raises(ValueError): - subsequence.chords.register_chord_quality("bad", []) - with pytest.raises(ValueError): - subsequence.chords.register_chord_quality("bad", [1, 4, 7]) - with pytest.raises(ValueError): - subsequence.chords.register_chord_quality("bad", [0, 7, 7]) - with pytest.raises(ValueError): - subsequence.chords.register_chord_quality("bad", [0, 4, 25]) - with pytest.raises(ValueError): - subsequence.chords.register_chord_quality("bad", [0, 4.5, 7]) # type: ignore[list-item] +def test_register_quality_validates_intervals() -> None: + """Interval rules: non-empty, ints, start at 0, strictly ascending, 0–24.""" + with pytest.raises(ValueError): + subsequence.chords.register_chord_quality("bad", []) + with pytest.raises(ValueError): + subsequence.chords.register_chord_quality("bad", [1, 4, 7]) + with pytest.raises(ValueError): + subsequence.chords.register_chord_quality("bad", [0, 7, 7]) + with pytest.raises(ValueError): + subsequence.chords.register_chord_quality("bad", [0, 4, 25]) + with pytest.raises(ValueError): + subsequence.chords.register_chord_quality("bad", [0, 4.5, 7]) # type: ignore[list-item] -def test_register_quality_rejects_ambiguous_suffix () -> None: - """A suffix starting with a note letter or digit would garble parsing.""" +def test_register_quality_rejects_ambiguous_suffix() -> None: + """A suffix starting with a note letter or digit would garble parsing.""" - with pytest.raises(ValueError, match="ambiguous"): - subsequence.chords.register_chord_quality("weird", [0, 1, 2], suffix="A1") - with pytest.raises(ValueError, match="ambiguous"): - subsequence.chords.register_chord_quality("weird", [0, 1, 2], suffix="9th") + with pytest.raises(ValueError, match="ambiguous"): + subsequence.chords.register_chord_quality("weird", [0, 1, 2], suffix="A1") + with pytest.raises(ValueError, match="ambiguous"): + subsequence.chords.register_chord_quality("weird", [0, 1, 2], suffix="9th") diff --git a/tests/test_clock_output.py b/tests/test_clock_output.py index 31da5a8..18447db 100644 --- a/tests/test_clock_output.py +++ b/tests/test_clock_output.py @@ -14,59 +14,65 @@ @pytest.mark.asyncio -async def test_clock_output_sends_ticks_and_transport (patch_midi: None) -> None: +async def test_clock_output_sends_ticks_and_transport(patch_midi: None) -> None: + """One bar of playback emits start, 96 clock ticks (24 per beat), then stop.""" - """One bar of playback emits start, 96 clock ticks (24 per beat), then stop.""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + clock_output=True, + ) + spy = conftest.SpyMidiOut() + sequencer.midi_out = spy - sequencer = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", initial_bpm=120, clock_output=True, - ) - spy = conftest.SpyMidiOut() - sequencer.midi_out = spy + sequencer.render_mode = True + sequencer.render_bars = 1 - sequencer.render_mode = True - sequencer.render_bars = 1 + pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) + pattern.add_note(position=0, pitch=60, velocity=100, duration=12) + await sequencer.schedule_pattern(pattern, start_pulse=0) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) - pattern.add_note(position=0, pitch=60, velocity=100, duration=12) - await sequencer.schedule_pattern(pattern, start_pulse=0) + await sequencer.start() + assert sequencer.task is not None + await sequencer.task + await sequencer.stop() - await sequencer.start() - assert sequencer.task is not None - await sequencer.task - await sequencer.stop() + types = [message.type for message in spy.sent] - types = [message.type for message in spy.sent] + # 24 ticks per beat × 4 beats = 96 for the rendered bar. + assert types.count("clock") == 96 + assert types.count("start") == 1 + assert types.count("stop") == 1 - # 24 ticks per beat × 4 beats = 96 for the rendered bar. - assert types.count("clock") == 96 - assert types.count("start") == 1 - assert types.count("stop") == 1 - - # Transport framing: start precedes the first tick, stop follows the last. - assert types.index("start") < types.index("clock") - assert len(types) - 1 - types[::-1].index("stop") > len(types) - 1 - types[::-1].index("clock") + # Transport framing: start precedes the first tick, stop follows the last. + assert types.index("start") < types.index("clock") + assert len(types) - 1 - types[::-1].index("stop") > len(types) - 1 - types[ + ::-1 + ].index("clock") @pytest.mark.asyncio -async def test_clock_output_disabled_sends_no_realtime_messages (patch_midi: None) -> None: - - """The default (clock_output=False) emits no clock/start/stop messages.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - spy = conftest.SpyMidiOut() - sequencer.midi_out = spy - - sequencer.render_mode = True - sequencer.render_bars = 1 - - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) - pattern.add_note(position=0, pitch=60, velocity=100, duration=12) - await sequencer.schedule_pattern(pattern, start_pulse=0) - - await sequencer.start() - assert sequencer.task is not None - await sequencer.task - await sequencer.stop() - - assert not any(message.type in ("clock", "start", "stop") for message in spy.sent) +async def test_clock_output_disabled_sends_no_realtime_messages( + patch_midi: None, +) -> None: + """The default (clock_output=False) emits no clock/start/stop messages.""" + + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + spy = conftest.SpyMidiOut() + sequencer.midi_out = spy + + sequencer.render_mode = True + sequencer.render_bars = 1 + + pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) + pattern.add_note(position=0, pitch=60, velocity=100, duration=12) + await sequencer.schedule_pattern(pattern, start_pulse=0) + + await sequencer.start() + assert sequencer.task is not None + await sequencer.task + await sequencer.stop() + + assert not any(message.type in ("clock", "start", "stop") for message in spy.sent) diff --git a/tests/test_composition.py b/tests/test_composition.py index cad6169..36652b7 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -13,1982 +13,1965 @@ import subsequence.voicings -def test_composition_creates_sequencer (patch_midi: None) -> None: +def test_composition_creates_sequencer(patch_midi: None) -> None: + """Composition should create a working sequencer with the given device and BPM.""" - """Composition should create a working sequencer with the given device and BPM.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=140, key="C") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=140, key="C") + assert composition._sequencer is not None + assert composition._sequencer.current_bpm == 140 + assert composition.output_device == "Dummy MIDI" + assert composition.key == "C" - assert composition._sequencer is not None - assert composition._sequencer.current_bpm == 140 - assert composition.output_device == "Dummy MIDI" - assert composition.key == "C" +def test_composition_harmony_creates_state(patch_midi: None) -> None: + """Calling harmony() should create a HarmonicState with the given parameters.""" -def test_composition_harmony_creates_state (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") - """Calling harmony() should create a HarmonicState with the given parameters.""" + composition.harmony( + style="turnaround_global", + cycle_beats=4, + dominant_7th=True, + gravity=0.8, + minor_turnaround_weight=0.25, + ) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") + assert composition._harmonic_state is not None + assert composition._harmonic_state.key_name == "E" + assert composition._harmony_cycle_beats == 4 - composition.harmony( - style = "turnaround_global", - cycle_beats = 4, - dominant_7th = True, - gravity = 0.8, - minor_turnaround_weight = 0.25 - ) - assert composition._harmonic_state is not None - assert composition._harmonic_state.key_name == "E" - assert composition._harmony_cycle_beats == 4 +def test_composition_harmony_without_key_raises(patch_midi: None) -> None: + """Calling harmony() without a key should raise ValueError.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125) -def test_composition_harmony_without_key_raises (patch_midi: None) -> None: + with pytest.raises(ValueError): + composition.harmony(style="turnaround_global", cycle_beats=4) - """Calling harmony() without a key should raise ValueError.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125) +def test_harmony_preserves_history_across_calls(patch_midi: None) -> None: + """Calling harmony() again should preserve chord history from the previous state.""" - with pytest.raises(ValueError): - composition.harmony(style="turnaround_global", cycle_beats=4) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", gravity=0.5) + # Build up history by stepping through several chords. + for _ in range(4): + composition._harmonic_state.step() -def test_harmony_preserves_history_across_calls (patch_midi: None) -> None: + history_before = composition._harmonic_state.history.copy() + current_before = composition._harmonic_state.current_chord - """Calling harmony() again should preserve chord history from the previous state.""" + assert len(history_before) == 4 - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", gravity=0.5) + # Reconfigure harmony with different parameters. + composition.harmony(style="functional_major", gravity=0.8) - # Build up history by stepping through several chords. - for _ in range(4): - composition._harmonic_state.step() + assert composition._harmonic_state.history == history_before + assert composition._harmonic_state.current_chord == current_before - history_before = composition._harmonic_state.history.copy() - current_before = composition._harmonic_state.current_chord - assert len(history_before) == 4 +def test_harmony_drops_current_chord_on_graph_switch(patch_midi: None) -> None: + """Switching graph style should not preserve a chord that doesn't exist in the new graph.""" - # Reconfigure harmony with different parameters. - composition.harmony(style="functional_major", gravity=0.8) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", gravity=0.5) - assert composition._harmonic_state.history == history_before - assert composition._harmonic_state.current_chord == current_before + # Step a few times to build history and move away from tonic. + for _ in range(4): + composition._harmonic_state.step() + old_current = composition._harmonic_state.current_chord -def test_harmony_drops_current_chord_on_graph_switch (patch_midi: None) -> None: + # Switch to a completely different graph style. + composition.harmony(style="suspended", gravity=0.5) - """Switching graph style should not preserve a chord that doesn't exist in the new graph.""" + new_current = composition._harmonic_state.current_chord - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", gravity=0.5) + # The new current should be a valid node in the new graph (has outgoing edges). + transitions = composition._harmonic_state.graph.get_transitions(new_current) + assert len(transitions) > 0 - # Step a few times to build history and move away from tonic. - for _ in range(4): - composition._harmonic_state.step() + # Step should work (not stuck) — it returns a usable chord. + result = composition._harmonic_state.step() + assert result is not None - old_current = composition._harmonic_state.current_chord - # Switch to a completely different graph style. - composition.harmony(style="suspended", gravity=0.5) +def test_pattern_decorator_registers_pending(patch_midi: None) -> None: + """The pattern decorator should register a pending pattern without scheduling immediately.""" - new_current = composition._harmonic_state.current_chord + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - # The new current should be a valid node in the new graph (has outgoing edges). - transitions = composition._harmonic_state.graph.get_transitions(new_current) - assert len(transitions) > 0 + @composition.pattern(channel=10, beats=4) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - # Step should work (not stuck) — it returns a usable chord. - result = composition._harmonic_state.step() - assert result is not None + assert len(composition._pending_patterns) == 1 + assert composition._pending_patterns[0].channel == 9 # 10 - 1, 1-indexed default + assert composition._pending_patterns[0].length == 4 -def test_pattern_decorator_registers_pending (patch_midi: None) -> None: +def test_pattern_decorator_returns_original_function(patch_midi: None) -> None: + """The pattern decorator should return the original function unchanged.""" - """The pattern decorator should register a pending pattern without scheduling immediately.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + def my_fn(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - @composition.pattern(channel=10, beats=4) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + decorated = composition.pattern(channel=1, beats=4)(my_fn) - assert len(composition._pending_patterns) == 1 - assert composition._pending_patterns[0].channel == 9 # 10 - 1, 1-indexed default - assert composition._pending_patterns[0].length == 4 + assert decorated is my_fn -def test_pattern_decorator_returns_original_function (patch_midi: None) -> None: +def test_build_pattern_from_pending_calls_builder(patch_midi: None) -> None: + """Building a pattern from pending should call the builder function.""" - """The pattern decorator should return the original function unchanged.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + calls = [] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + calls.append("called") - def my_fn (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - decorated = composition.pattern(channel=1, beats=4)(my_fn) + pattern = composition._build_pattern_from_pending(pending) - assert decorated is my_fn + assert len(calls) == 1 + assert isinstance(pattern, subsequence.pattern.Pattern) + assert pattern.channel == 1 + assert pattern.length == 4 -def test_build_pattern_from_pending_calls_builder (patch_midi: None) -> None: +def test_build_pattern_rebuilds_on_reschedule(patch_midi: None) -> None: + """The decorator pattern should re-run the builder on on_reschedule.""" - """Building a pattern from pending should call the builder function.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + call_count = [0] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - calls = [] + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + call_count[0] += 1 - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - calls.append("called") + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pattern = composition._build_pattern_from_pending(pending) - pattern = composition._build_pattern_from_pending(pending) + assert call_count[0] == 1 - assert len(calls) == 1 - assert isinstance(pattern, subsequence.pattern.Pattern) - assert pattern.channel == 1 - assert pattern.length == 4 + pattern.on_reschedule() + assert call_count[0] == 2 -def test_build_pattern_rebuilds_on_reschedule (patch_midi: None) -> None: - """The decorator pattern should re-run the builder on on_reschedule.""" +def test_drone_raw_events_do_not_accumulate_across_cycles(patch_midi: None) -> None: + """An unconditional drone must place exactly one raw note_on per cycle. - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - call_count = [0] + Regression: ``_rebuild()`` clears ``steps``/``cc_events``/``osc_events`` each + cycle, but ``raw_note_events`` was omitted — so drones (and ``note_on`` / + ``note_off``) accumulated and re-fired on every reschedule instead of once. + """ - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - call_count[0] += 1 + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + def drone_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.drone(60) - pattern = composition._build_pattern_from_pending(pending) + pending = subsequence.composition._PendingPattern( + builder_fn=drone_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - assert call_count[0] == 1 + pattern = composition._build_pattern_from_pending(pending) - pattern.on_reschedule() + assert len(pattern.raw_note_events) == 1 - assert call_count[0] == 2 + # Five reschedules must not grow the buffer — one drone per cycle, not six. + for _ in range(5): + pattern.on_reschedule() + assert len(pattern.raw_note_events) == 1 -def test_drone_raw_events_do_not_accumulate_across_cycles (patch_midi: None) -> None: +def test_builder_exception_produces_silent_pattern(patch_midi: None) -> None: + """A builder that raises should produce an empty (silent) pattern, not crash.""" - """An unconditional drone must place exactly one raw note_on per cycle. + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - Regression: ``_rebuild()`` clears ``steps``/``cc_events``/``osc_events`` each - cycle, but ``raw_note_events`` was omitted — so drones (and ``note_on`` / - ``note_off``) accumulated and re-fired on every reschedule instead of once. - """ + def bad_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + raise RuntimeError("intentional test error") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + pending = subsequence.composition._PendingPattern( + builder_fn=bad_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - def drone_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.drone(60) + pattern = composition._build_pattern_from_pending(pending) - pending = subsequence.composition._PendingPattern( - builder_fn = drone_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + # Pattern should exist but have no notes (steps empty). + assert isinstance(pattern, subsequence.pattern.Pattern) + assert len(pattern.steps) == 0 - pattern = composition._build_pattern_from_pending(pending) + # Rebuilding should also not crash. + pattern.on_reschedule() + assert len(pattern.steps) == 0 - assert len(pattern.raw_note_events) == 1 - # Five reschedules must not grow the buffer — one drone per cycle, not six. - for _ in range(5): - pattern.on_reschedule() - assert len(pattern.raw_note_events) == 1 +def test_injected_chord_tones_truncates_count_under_voice_leading() -> None: + """_InjectedChord.tones(count=N) yields exactly N notes even with voice leading. + Regression: under voice leading, count < chord size returned the full voiced + chord instead of truncating to count (the non-voice-led path always honoured count). + """ -def test_builder_exception_produces_silent_pattern (patch_midi: None) -> None: + chord = subsequence.chords.Chord(0, "major") # 3 tones + vl_state = subsequence.voicings.VoiceLeadingState() + injected = subsequence.composition._InjectedChord(chord, vl_state) - """A builder that raises should produce an empty (silent) pattern, not crash.""" + assert len(injected.tones(60, count=2)) == 2 # truncates (previously returned 3) + assert len(injected.tones(60, count=3)) == 3 + assert len(injected.tones(60, count=6)) == 6 # cycles up into octaves - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - def bad_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - raise RuntimeError("intentional test error") +def test_builder_cycle_injection(patch_midi: None) -> None: + """The builder should receive the current cycle count.""" - pending = subsequence.composition._PendingPattern( - builder_fn = bad_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + received_cycles = [] - pattern = composition._build_pattern_from_pending(pending) + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + received_cycles.append(p.cycle) - # Pattern should exist but have no notes (steps empty). - assert isinstance(pattern, subsequence.pattern.Pattern) - assert len(pattern.steps) == 0 + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - # Rebuilding should also not crash. - pattern.on_reschedule() - assert len(pattern.steps) == 0 + pattern = composition._build_pattern_from_pending(pending) + assert received_cycles == [0] -def test_injected_chord_tones_truncates_count_under_voice_leading () -> None: + pattern.on_reschedule() + assert received_cycles == [0, 1] - """_InjectedChord.tones(count=N) yields exactly N notes even with voice leading. + pattern.on_reschedule() + assert received_cycles == [0, 1, 2] - Regression: under voice leading, count < chord size returned the full voiced - chord instead of truncating to count (the non-voice-led path always honoured count). - """ - chord = subsequence.chords.Chord(0, "major") # 3 tones - vl_state = subsequence.voicings.VoiceLeadingState() - injected = subsequence.composition._InjectedChord(chord, vl_state) +def test_chord_injection(patch_midi: None) -> None: + """Builder functions with a chord parameter should receive the current chord.""" - assert len(injected.tones(60, count=2)) == 2 # truncates (previously returned 3) - assert len(injected.tones(60, count=3)) == 3 - assert len(injected.tones(60, count=6)) == 6 # cycles up into octaves + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") + composition.harmony(style="turnaround_global", cycle_beats=4, dominant_7th=True) -def test_builder_cycle_injection (patch_midi: None) -> None: + received_chords = [] - """The builder should receive the current cycle count.""" + def my_builder( + p: "subsequence.pattern_builder.PatternBuilder", + chord: "subsequence.chords.Chord", + ) -> None: + received_chords.append(chord) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - received_cycles = [] + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - received_cycles.append(p.cycle) + pattern = composition._build_pattern_from_pending(pending) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + assert len(received_chords) == 1 + assert received_chords[0] is not None - pattern = composition._build_pattern_from_pending(pending) + injected = received_chords[0] + raw_chord = composition._harmonic_state.get_current_chord() - assert received_cycles == [0] + # Injected chord should report the same name as the raw chord. + assert injected.name() == raw_chord.name() - pattern.on_reschedule() - assert received_cycles == [0, 1] + # Injected chord should correctly transpose relative to the key of E (pc=4). + # For the initial E major chord: offset = (4-4)%12 = 0, so root_midi(40) = 40. + assert injected.root_midi(40) == 40 - pattern.on_reschedule() - assert received_cycles == [0, 1, 2] +def test_chord_not_injected_without_parameter(patch_midi: None) -> None: + """Builder functions without a chord parameter should work without harmony.""" -def test_chord_injection (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + calls = [] - """Builder functions with a chord parameter should receive the current chord.""" + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + calls.append("called") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - composition.harmony( - style = "turnaround_global", - cycle_beats = 4, - dominant_7th = True - ) + pattern = composition._build_pattern_from_pending(pending) - received_chords = [] + assert len(calls) == 1 - def my_builder (p: "subsequence.pattern_builder.PatternBuilder", chord: "subsequence.chords.Chord") -> None: - received_chords.append(chord) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) +def test_data_store_exists(patch_midi: None) -> None: + """Composition should have an empty data dict on creation.""" - pattern = composition._build_pattern_from_pending(pending) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - assert len(received_chords) == 1 - assert received_chords[0] is not None + assert isinstance(composition.data, dict) + assert len(composition.data) == 0 - injected = received_chords[0] - raw_chord = composition._harmonic_state.get_current_chord() - # Injected chord should report the same name as the raw chord. - assert injected.name() == raw_chord.name() +def test_schedule_registers_pending(patch_midi: None) -> None: + """Calling schedule() should append to _pending_scheduled.""" - # Injected chord should correctly transpose relative to the key of E (pc=4). - # For the initial E major chord: offset = (4-4)%12 = 0, so root_midi(40) = 40. - assert injected.root_midi(40) == 40 + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + def my_task() -> None: + pass -def test_chord_not_injected_without_parameter (patch_midi: None) -> None: + composition.schedule(my_task, cycle_beats=16) - """Builder functions without a chord parameter should work without harmony.""" + assert len(composition._pending_scheduled) == 1 + assert composition._pending_scheduled[0].fn is my_task + assert composition._pending_scheduled[0].cycle_beats == 16 - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - calls = [] - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - calls.append("called") +def test_data_accessible_from_builder(patch_midi: None) -> None: + """Builder functions should be able to read composition.data via closure.""" - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition.data["test_key"] = 42 + read_values = [] - pattern = composition._build_pattern_from_pending(pending) + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + read_values.append(composition.data.get("test_key")) - assert len(calls) == 1 + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) + composition._build_pattern_from_pending(pending) -def test_data_store_exists (patch_midi: None) -> None: + assert read_values == [42] - """Composition should have an empty data dict on creation.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") +def test_data_default_when_not_set(patch_midi: None) -> None: + """Data store get() should return the default when key is not set.""" - assert isinstance(composition.data, dict) - assert len(composition.data) == 0 + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + assert composition.data.get("missing", 0.5) == 0.5 -def test_schedule_registers_pending (patch_midi: None) -> None: - """Calling schedule() should append to _pending_scheduled.""" +def test_p_data_is_composition_data(patch_midi: None) -> None: + """p.data inside a pattern builder should be the same object as composition.data.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition.data["sentinel"] = "hello" + captured = [] - def my_task () -> None: - pass + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + captured.append(p.data.get("sentinel")) + p.data["from_pattern"] = "world" - composition.schedule(my_task, cycle_beats=16) + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - assert len(composition._pending_scheduled) == 1 - assert composition._pending_scheduled[0].fn is my_task - assert composition._pending_scheduled[0].cycle_beats == 16 + composition._build_pattern_from_pending(pending) - -def test_data_accessible_from_builder (patch_midi: None) -> None: - - """Builder functions should be able to read composition.data via closure.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition.data["test_key"] = 42 - read_values = [] - - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - read_values.append(composition.data.get("test_key")) - - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) - - composition._build_pattern_from_pending(pending) - - assert read_values == [42] - - -def test_data_default_when_not_set (patch_midi: None) -> None: - - """Data store get() should return the default when key is not set.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - - assert composition.data.get("missing", 0.5) == 0.5 - - -def test_p_data_is_composition_data (patch_midi: None) -> None: - - """p.data inside a pattern builder should be the same object as composition.data.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition.data["sentinel"] = "hello" - captured = [] - - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - captured.append(p.data.get("sentinel")) - p.data["from_pattern"] = "world" - - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) - - composition._build_pattern_from_pending(pending) - - assert captured == ["hello"] - assert composition.data.get("from_pattern") == "world" + assert captured == ["hello"] + assert composition.data.get("from_pattern") == "world" # --- Seed and RNG --- -def test_composition_seed_constructor (patch_midi: None) -> None: - - """Composition should store a seed set via the constructor.""" +def test_composition_seed_constructor(patch_midi: None) -> None: + """Composition should store a seed set via the constructor.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, seed=42) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, seed=42) - assert composition._seed == 42 + assert composition._seed == 42 -def test_composition_seed_property (patch_midi: None) -> None: +def test_composition_seed_property(patch_midi: None) -> None: + """Composition.seed is a readable/assignable property; the old call form raises.""" - """Composition.seed is a readable/assignable property; the old call form raises.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + assert composition.seed is None - assert composition.seed is None + composition.seed = 99 - composition.seed = 99 + assert composition.seed == 99 + assert composition._seed == 99 - assert composition.seed == 99 - assert composition._seed == 99 + with pytest.raises(TypeError): + composition.seed(42) # type: ignore[operator] # hard break: formerly a method - with pytest.raises(TypeError): - composition.seed(42) # type: ignore[operator] # hard break: formerly a method +def test_builder_receives_rng_from_seed(patch_midi: None) -> None: + """When a seed is set, pattern builders should receive a deterministic rng.""" -def test_builder_receives_rng_from_seed (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, seed=42) + received_rngs = [] - """When a seed is set, pattern builders should receive a deterministic rng.""" + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + received_rngs.append(p.rng) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, seed=42) - received_rngs = [] + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - received_rngs.append(p.rng) + composition._pending_patterns.append(pending) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + # Streams are dealt inside _build_pattern_from_pending, keyed by name. + pattern = composition._build_pattern_from_pending(pending) - composition._pending_patterns.append(pending) + assert len(received_rngs) == 1 + assert isinstance(received_rngs[0], random.Random) - # Streams are dealt inside _build_pattern_from_pending, keyed by name. - pattern = composition._build_pattern_from_pending(pending) - assert len(received_rngs) == 1 - assert isinstance(received_rngs[0], random.Random) +def test_seed_produces_deterministic_patterns(patch_midi: None) -> None: + """Two builds with the same seed should produce identical pattern content.""" + def build_steps(seed: int) -> set: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, seed=seed + ) -def test_seed_produces_deterministic_patterns (patch_midi: None) -> None: + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + # Use p.rng to make a stochastic pattern. + p.repeat(60, spacing=0.25, velocity=100) + p.dropout(probability=0.4) - """Two builds with the same seed should produce identical pattern content.""" + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - def build_steps (seed: int) -> set: + # Streams are dealt inside _build_pattern_from_pending, keyed by name. + pattern = composition._build_pattern_from_pending(pending) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, seed=seed) + return set(pattern.steps.keys()) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - # Use p.rng to make a stochastic pattern. - p.repeat(60, spacing=0.25, velocity=100) - p.dropout(probability=0.4) + run_1 = build_steps(42) + run_2 = build_steps(42) + run_3 = build_steps(99) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + assert run_1 == run_2 + assert run_1 != run_3 - # Streams are dealt inside _build_pattern_from_pending, keyed by name. - pattern = composition._build_pattern_from_pending(pending) - return set(pattern.steps.keys()) +def test_no_seed_builder_has_rng() -> None: + """Even without a seed, the builder should have an rng attribute.""" - run_1 = build_steps(42) - run_2 = build_steps(42) - run_3 = build_steps(99) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - assert run_1 == run_2 - assert run_1 != run_3 + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) - -def test_no_seed_builder_has_rng () -> None: - - """Even without a seed, the builder should have an rng attribute.""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4) - - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) - - assert isinstance(builder.rng, random.Random) + assert isinstance(builder.rng, random.Random) # --- Float length --- -def test_pattern_decorator_float_length (patch_midi: None) -> None: - - """The pattern decorator should accept a float length.""" +def test_pattern_decorator_float_length(patch_midi: None) -> None: + """The pattern decorator should accept a float length.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - @composition.pattern(channel=1, beats=10.5) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + @composition.pattern(channel=1, beats=10.5) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - assert composition._pending_patterns[0].length == 10.5 + assert composition._pending_patterns[0].length == 10.5 -def test_build_pattern_float_length (patch_midi: None) -> None: +def test_build_pattern_float_length(patch_midi: None) -> None: + """Building a pattern with float length should produce the correct Pattern.""" - """Building a pattern with float length should produce the correct Pattern.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + calls = [] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - calls = [] + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + calls.append(p._pattern.length) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - calls.append(p._pattern.length) + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=10.5, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=42, + ) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 10.5, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 42 - ) + pattern = composition._build_pattern_from_pending(pending) - pattern = composition._build_pattern_from_pending(pending) + assert pattern.length == 10.5 + assert calls == [10.5] - assert pattern.length == 10.5 - assert calls == [10.5] +def test_different_pattern_lengths_coexist(patch_midi: None) -> None: + """Multiple patterns with different lengths should all register correctly.""" -def test_different_pattern_lengths_coexist (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - """Multiple patterns with different lengths should all register correctly.""" + @composition.pattern(channel=1, beats=4) + def short(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + @composition.pattern(channel=1, beats=9) + def medium(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - @composition.pattern(channel=1, beats=4) - def short (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + @composition.pattern(channel=2, beats=10.5) + def long(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - @composition.pattern(channel=1, beats=9) - def medium (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - - @composition.pattern(channel=2, beats=10.5) - def long (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - - assert len(composition._pending_patterns) == 3 - assert composition._pending_patterns[0].length == 4 - assert composition._pending_patterns[1].length == 9 - assert composition._pending_patterns[2].length == 10.5 + assert len(composition._pending_patterns) == 3 + assert composition._pending_patterns[0].length == 4 + assert composition._pending_patterns[1].length == 9 + assert composition._pending_patterns[2].length == 10.5 # --- Layer --- -def test_layer_registers_pending (patch_midi: None) -> None: - - """layer() should register a single pending pattern.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") +def test_layer_registers_pending(patch_midi: None) -> None: + """layer() should register a single pending pattern.""" - def kick (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - def hats (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + def kick(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition.layer(kick, hats, channel=10, beats=4) + def hats(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - assert len(composition._pending_patterns) == 1 - assert composition._pending_patterns[0].channel == 9 # 10 - 1, 1-indexed default - assert composition._pending_patterns[0].length == 4 + composition.layer(kick, hats, channel=10, beats=4) + assert len(composition._pending_patterns) == 1 + assert composition._pending_patterns[0].channel == 9 # 10 - 1, 1-indexed default + assert composition._pending_patterns[0].length == 4 -def test_layer_merges_notes (patch_midi: None) -> None: - """layer() should merge notes from all builder functions into one pattern.""" +def test_layer_merges_notes(patch_midi: None) -> None: + """layer() should merge notes from all builder functions into one pattern.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - def kick (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(36, beat=0, velocity=127) + def kick(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(36, beat=0, velocity=127) - def snare (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(38, beat=1, velocity=100) + def snare(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(38, beat=1, velocity=100) - composition.layer(kick, snare, channel=10, beats=4) + composition.layer(kick, snare, channel=10, beats=4) - pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) + pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) - # Both notes should be present. - pulse_0 = 0 - pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + # Both notes should be present. + pulse_0 = 0 + pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert pulse_0 in pattern.steps - assert pulse_1 in pattern.steps - assert pattern.steps[pulse_0].notes[0].pitch == 36 - assert pattern.steps[pulse_1].notes[0].pitch == 38 + assert pulse_0 in pattern.steps + assert pulse_1 in pattern.steps + assert pattern.steps[pulse_0].notes[0].pitch == 36 + assert pattern.steps[pulse_1].notes[0].pitch == 38 -def test_layer_with_chord_injection (patch_midi: None) -> None: +def test_layer_with_chord_injection(patch_midi: None) -> None: + """layer() should inject chord into builders that accept it.""" - """layer() should inject chord into builders that accept it.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition.harmony(style="diatonic_major", cycle_beats=4) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition.harmony(style="diatonic_major", cycle_beats=4) + def bass( + p: "subsequence.pattern_builder.PatternBuilder", + chord: "subsequence.chords.Chord", + ) -> None: + # Just verify chord is received by placing the root. + root = chord.root_note(36) + p.note(root, beat=0, velocity=100) - def bass (p: "subsequence.pattern_builder.PatternBuilder", chord: "subsequence.chords.Chord") -> None: - # Just verify chord is received by placing the root. - root = chord.root_note(36) - p.note(root, beat=0, velocity=100) + def rhythm(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=1, velocity=80) - def rhythm (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=1, velocity=80) + composition.layer(bass, rhythm, channel=1, beats=4) - composition.layer(bass, rhythm, channel=1, beats=4) + # Build with a harmony state active - chord injection should work. + pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) - # Build with a harmony state active - chord injection should work. - pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) + # Both builders should have contributed notes. + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - # Both builders should have contributed notes. - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - - assert total_notes == 2 + assert total_notes == 2 # --- Tweak --- -def test_tweak_updates_running_pattern (patch_midi: None) -> None: - - """tweak() should store values in the pattern's _tweaks dict.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") +def test_tweak_updates_running_pattern(patch_midi: None) -> None: + """tweak() should store values in the pattern's _tweaks dict.""" - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - pattern = composition._build_pattern_from_pending(pending) - composition._running_patterns["my_builder"] = pattern + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - composition.tweak("my_builder", pitches=[48, 52]) + pattern = composition._build_pattern_from_pending(pending) + composition._running_patterns["my_builder"] = pattern - assert pattern._tweaks == {"pitches": [48, 52]} + composition.tweak("my_builder", pitches=[48, 52]) + assert pattern._tweaks == {"pitches": [48, 52]} -def test_tweak_unknown_pattern_raises (patch_midi: None) -> None: - """tweak() should raise ValueError for a nonexistent pattern name.""" +def test_tweak_unknown_pattern_raises(patch_midi: None) -> None: + """tweak() should raise ValueError for a nonexistent pattern name.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - with pytest.raises(ValueError): - composition.tweak("nonexistent", pitches=[60]) + with pytest.raises(ValueError): + composition.tweak("nonexistent", pitches=[60]) -def test_clear_tweak_removes_all (patch_midi: None) -> None: +def test_clear_tweak_removes_all(patch_midi: None) -> None: + """clear_tweak() with no param names should remove all tweaks.""" - """clear_tweak() with no param names should remove all tweaks.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pattern = composition._build_pattern_from_pending(pending) + composition._running_patterns["my_builder"] = pattern - pattern = composition._build_pattern_from_pending(pending) - composition._running_patterns["my_builder"] = pattern + composition.tweak("my_builder", pitches=[48], velocity=80) + composition.clear_tweak("my_builder") - composition.tweak("my_builder", pitches=[48], velocity=80) - composition.clear_tweak("my_builder") + assert pattern._tweaks == {} - assert pattern._tweaks == {} +def test_clear_tweak_removes_specific(patch_midi: None) -> None: + """clear_tweak() with a name should remove only that param.""" -def test_clear_tweak_removes_specific (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - """clear_tweak() with a name should remove only that param.""" + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + pattern = composition._build_pattern_from_pending(pending) + composition._running_patterns["my_builder"] = pattern - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + composition.tweak("my_builder", pitches=[48], velocity=80) + composition.clear_tweak("my_builder", "pitches") - pattern = composition._build_pattern_from_pending(pending) - composition._running_patterns["my_builder"] = pattern + assert pattern._tweaks == {"velocity": 80} - composition.tweak("my_builder", pitches=[48], velocity=80) - composition.clear_tweak("my_builder", "pitches") - assert pattern._tweaks == {"velocity": 80} +def test_get_tweaks_returns_copy(patch_midi: None) -> None: + """get_tweaks() should return a copy, not a reference.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") -def test_get_tweaks_returns_copy (patch_midi: None) -> None: + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """get_tweaks() should return a copy, not a reference.""" + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + pattern = composition._build_pattern_from_pending(pending) + composition._running_patterns["my_builder"] = pattern - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition.tweak("my_builder", pitches=[48]) + result = composition.get_tweaks("my_builder") + result["pitches"] = [99] - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + assert pattern._tweaks["pitches"] == [48] - pattern = composition._build_pattern_from_pending(pending) - composition._running_patterns["my_builder"] = pattern - composition.tweak("my_builder", pitches=[48]) - result = composition.get_tweaks("my_builder") - result["pitches"] = [99] +def test_tweaks_in_live_info(patch_midi: None) -> None: + """live_info() should include tweaks for each pattern.""" - assert pattern._tweaks["pitches"] == [48] + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_tweaks_in_live_info (patch_midi: None) -> None: + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - """live_info() should include tweaks for each pattern.""" + pattern = composition._build_pattern_from_pending(pending) + composition._running_patterns["my_builder"] = pattern - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition.tweak("my_builder", pitches=[48]) + info = composition.live_info() - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + assert info["patterns"][0]["tweaks"] == {"pitches": [48]} - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) - pattern = composition._build_pattern_from_pending(pending) - composition._running_patterns["my_builder"] = pattern +def test_param_reads_tweak_on_rebuild(patch_midi: None) -> None: + """p.param() should return the tweaked value after a rebuild.""" - composition.tweak("my_builder", pitches=[48]) - info = composition.live_info() + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + captured = {} - assert info["patterns"][0]["tweaks"] == {"pitches": [48]} + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + captured["pitches"] = p.param("pitches", [60, 64]) + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) -def test_param_reads_tweak_on_rebuild (patch_midi: None) -> None: + pattern = composition._build_pattern_from_pending(pending) - """p.param() should return the tweaked value after a rebuild.""" + # First build uses default. + assert captured["pitches"] == [60, 64] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - captured = {} + # Tweak and rebuild. + pattern._tweaks["pitches"] = [48, 52] + pattern.on_reschedule() - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - captured["pitches"] = p.param("pitches", [60, 64]) - - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) - - pattern = composition._build_pattern_from_pending(pending) - - # First build uses default. - assert captured["pitches"] == [60, 64] - - # Tweak and rebuild. - pattern._tweaks["pitches"] = [48, 52] - pattern.on_reschedule() - - assert captured["pitches"] == [48, 52] + assert captured["pitches"] == [48, 52] # --- Unit parameter --- -def test_pattern_unit_sets_beat_length (patch_midi: None) -> None: - - """beats=6, step_duration=SIXTEENTH should produce a pattern with 1.5 beats.""" +def test_pattern_unit_sets_beat_length(patch_midi: None) -> None: + """beats=6, step_duration=SIXTEENTH should produce a pattern with 1.5 beats.""" - import subsequence.constants.durations as dur + import subsequence.constants.durations as dur - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - @composition.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + @composition.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - pending = composition._pending_patterns[0] + pending = composition._pending_patterns[0] - assert pending.length == pytest.approx(1.5) + assert pending.length == pytest.approx(1.5) -def test_pattern_unit_sets_default_grid (patch_midi: None) -> None: +def test_pattern_unit_sets_default_grid(patch_midi: None) -> None: + """beats=6, step_duration=SIXTEENTH should set default_grid to 6.""" - """beats=6, step_duration=SIXTEENTH should set default_grid to 6.""" + import subsequence.constants.durations as dur - import subsequence.constants.durations as dur + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + @composition.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - @composition.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + pending = composition._pending_patterns[0] - pending = composition._pending_patterns[0] + assert pending.default_grid == 6 - assert pending.default_grid == 6 +def test_pattern_no_unit_defaults_to_sixteenth_grid(patch_midi: None) -> None: + """beats=4 without unit should produce default_grid=16 (4 / SIXTEENTH).""" -def test_pattern_no_unit_defaults_to_sixteenth_grid (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - """beats=4 without unit should produce default_grid=16 (4 / SIXTEENTH).""" + @composition.pattern(channel=1, beats=4) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + pending = composition._pending_patterns[0] - @composition.pattern(channel=1, beats=4) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + assert pending.default_grid == 16 - pending = composition._pending_patterns[0] - assert pending.default_grid == 16 +def test_pattern_unit_triplet_grid(patch_midi: None) -> None: + """beats=4, step_duration=TRIPLET_EIGHTH should produce default_grid=4.""" + import subsequence.constants.durations as dur -def test_pattern_unit_triplet_grid (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - """beats=4, step_duration=TRIPLET_EIGHTH should produce default_grid=4.""" + @composition.pattern(channel=1, steps=4, step_duration=dur.TRIPLET_EIGHTH) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - import subsequence.constants.durations as dur + pending = composition._pending_patterns[0] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + assert pending.length == pytest.approx(4 * dur.TRIPLET_EIGHTH) + assert pending.default_grid == 4 - @composition.pattern(channel=1, steps=4, step_duration=dur.TRIPLET_EIGHTH) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - pending = composition._pending_patterns[0] +def test_layer_unit_sets_beat_length(patch_midi: None) -> None: + """layer() with unit should compute beat_length correctly.""" - assert pending.length == pytest.approx(4 * dur.TRIPLET_EIGHTH) - assert pending.default_grid == 4 + import subsequence.constants.durations as dur + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) -def test_layer_unit_sets_beat_length (patch_midi: None) -> None: + def kick(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """layer() with unit should compute beat_length correctly.""" + composition.layer(kick, channel=10, steps=8, step_duration=dur.SIXTEENTH) - import subsequence.constants.durations as dur + pending = composition._pending_patterns[0] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + assert pending.length == pytest.approx(2.0) + assert pending.default_grid == 8 - def kick (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - composition.layer(kick, channel=10, steps=8, step_duration=dur.SIXTEENTH) +def test_schedule_wait_for_initial_flag(patch_midi: None) -> None: + """schedule(wait_for_initial=True) should store the flag on _PendingScheduled.""" - pending = composition._pending_patterns[0] + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - assert pending.length == pytest.approx(2.0) - assert pending.default_grid == 8 + def my_task() -> None: + pass + composition.schedule(my_task, cycle_beats=16, wait_for_initial=True) -def test_schedule_wait_for_initial_flag (patch_midi: None) -> None: + assert composition._pending_scheduled[0].wait_for_initial is True + assert composition._pending_scheduled[0].defer is False - """schedule(wait_for_initial=True) should store the flag on _PendingScheduled.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") +def test_schedule_defer_flag(patch_midi: None) -> None: + """schedule(defer=True) should store the flag on _PendingScheduled.""" - def my_task () -> None: - pass + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition.schedule(my_task, cycle_beats=16, wait_for_initial=True) + def my_task() -> None: + pass - assert composition._pending_scheduled[0].wait_for_initial is True - assert composition._pending_scheduled[0].defer is False + composition.schedule(my_task, cycle_beats=16, defer=True) + assert composition._pending_scheduled[0].defer is True + assert composition._pending_scheduled[0].wait_for_initial is False -def test_schedule_defer_flag (patch_midi: None) -> None: - """schedule(defer=True) should store the flag on _PendingScheduled.""" +def test_schedule_defaults_no_wait_for_initial_no_defer(patch_midi: None) -> None: + """schedule() without flags should default to wait_for_initial=False, defer=False.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - def my_task () -> None: - pass + def my_task() -> None: + pass - composition.schedule(my_task, cycle_beats=16, defer=True) + composition.schedule(my_task, cycle_beats=16) - assert composition._pending_scheduled[0].defer is True - assert composition._pending_scheduled[0].wait_for_initial is False + assert composition._pending_scheduled[0].wait_for_initial is False + assert composition._pending_scheduled[0].defer is False -def test_schedule_defaults_no_wait_for_initial_no_defer (patch_midi: None) -> None: +def test_pattern_lookahead_is_never_clamped(patch_midi: None) -> None: + """Patterns keep their own lookahead — the CLOCKS are raised to match instead. - """schedule() without flags should default to wait_for_initial=False, defer=False.""" + The old behaviour clamped every pattern to the harmony lookahead + (punishing a pattern that legitimately needs more); the fix raises the + form/harmony clocks to the maximum pattern lookahead at _run() time, so + the harmony window always covers a pattern's next cycle when it rebuilds. + """ - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony( + style="diatonic_major", cycle_beats=4, reschedule_lookahead=0.25 + ) - def my_task () -> None: - pass + @composition.pattern(channel=1, beats=4, reschedule_lookahead=2) + def pad( + p: "subsequence.pattern_builder.PatternBuilder", + chord: "subsequence.chords.Chord", + ) -> None: + pass - composition.schedule(my_task, cycle_beats=16) - - assert composition._pending_scheduled[0].wait_for_initial is False - assert composition._pending_scheduled[0].defer is False - - -def test_pattern_lookahead_is_never_clamped (patch_midi: None) -> None: - - """Patterns keep their own lookahead — the CLOCKS are raised to match instead. - - The old behaviour clamped every pattern to the harmony lookahead - (punishing a pattern that legitimately needs more); the fix raises the - form/harmony clocks to the maximum pattern lookahead at _run() time, so - the harmony window always covers a pattern's next cycle when it rebuilds. - """ - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="diatonic_major", cycle_beats=4, reschedule_lookahead=0.25) - - @composition.pattern(channel=1, beats=4, reschedule_lookahead=2) - def pad (p: "subsequence.pattern_builder.PatternBuilder", chord: "subsequence.chords.Chord") -> None: - pass - - pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) - assert pattern.reschedule_lookahead == pytest.approx(2) + pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) + assert pattern.reschedule_lookahead == pytest.approx(2) # ── freeze() ────────────────────────────────────────────────────────────────── -def test_freeze_requires_harmony (patch_midi: None) -> None: - - """freeze() raises ValueError when harmony() has not been called.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - - with pytest.raises(ValueError, match="harmony()"): - composition.freeze(4) - - -def test_freeze_requires_positive_bars (patch_midi: None) -> None: +def test_freeze_requires_harmony(patch_midi: None) -> None: + """freeze() raises ValueError when harmony() has not been called.""" - """freeze() raises ValueError for bars < 1.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) + with pytest.raises(ValueError, match="harmony()"): + composition.freeze(4) - with pytest.raises(ValueError, match="bars"): - composition.freeze(0) +def test_freeze_requires_positive_bars(patch_midi: None) -> None: + """freeze() raises ValueError for bars < 1.""" -def test_freeze_returns_progression (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) - """freeze() should return a Progression instance.""" + with pytest.raises(ValueError, match="bars"): + composition.freeze(0) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) - prog = composition.freeze(4) +def test_freeze_returns_progression(patch_midi: None) -> None: + """freeze() should return a Progression instance.""" - assert isinstance(prog, subsequence.composition.Progression) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) + prog = composition.freeze(4) -def test_freeze_chord_count (patch_midi: None) -> None: + assert isinstance(prog, subsequence.composition.Progression) - """freeze(bars) returns exactly *bars* chords.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) +def test_freeze_chord_count(patch_midi: None) -> None: + """freeze(bars) returns exactly *bars* chords.""" - for bars in (1, 4, 8): - prog = composition.freeze(bars) - assert len(prog.chords) == bars, f"Expected {bars} chords, got {len(prog.chords)}" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) + for bars in (1, 4, 8): + prog = composition.freeze(bars) + assert len(prog.chords) == bars, ( + f"Expected {bars} chords, got {len(prog.chords)}" + ) -def test_freeze_captures_current_chord_as_first (patch_midi: None) -> None: - """The first chord in the progression is the engine's current chord before freezing.""" +def test_freeze_captures_current_chord_as_first(patch_midi: None) -> None: + """The first chord in the progression is the engine's current chord before freezing.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) - first_chord = composition._harmonic_state.current_chord # type: ignore[union-attr] - prog = composition.freeze(4) + first_chord = composition._harmonic_state.current_chord # type: ignore[union-attr] + prog = composition.freeze(4) - assert prog.chords[0] is first_chord + assert prog.chords[0] is first_chord -def test_freeze_advances_engine (patch_midi: None) -> None: +def test_freeze_advances_engine(patch_midi: None) -> None: + """Successive freeze() calls continue from where the previous one left off.""" - """Successive freeze() calls continue from where the previous one left off.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) + composition.freeze(4) - composition.freeze(4) + # After freeze(), the engine has stepped past the last captured chord. + # The next freeze() must start with whatever chord the engine is currently on. + chord_between = composition._harmonic_state.current_chord # type: ignore[union-attr] + prog2 = composition.freeze(4) - # After freeze(), the engine has stepped past the last captured chord. - # The next freeze() must start with whatever chord the engine is currently on. - chord_between = composition._harmonic_state.current_chord # type: ignore[union-attr] - prog2 = composition.freeze(4) + assert prog2.chords[0] is chord_between - assert prog2.chords[0] is chord_between +def test_freeze_extra_step_avoids_duplication(patch_midi: None) -> None: + """freeze() takes an extra step so the next freeze starts on a fresh chord.""" -def test_freeze_extra_step_avoids_duplication (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) - """freeze() takes an extra step so the next freeze starts on a fresh chord.""" + prog1 = composition.freeze(4) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) + # The engine is now on the chord AFTER prog1.chords[-1] (the extra step). + # prog2 should start there — not at prog1.chords[-1]. + chord_after_extra_step = composition._harmonic_state.current_chord # type: ignore[union-attr] + prog2 = composition.freeze(4) - prog1 = composition.freeze(4) + assert prog2.chords[0] is chord_after_extra_step - # The engine is now on the chord AFTER prog1.chords[-1] (the extra step). - # prog2 should start there — not at prog1.chords[-1]. - chord_after_extra_step = composition._harmonic_state.current_chord # type: ignore[union-attr] - prog2 = composition.freeze(4) - assert prog2.chords[0] is chord_after_extra_step +def test_freeze_trailing_history_max_length(patch_midi: None) -> None: + """trailing_history has at most 4 entries (same cap as HarmonicState.history).""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) -def test_freeze_trailing_history_max_length (patch_midi: None) -> None: + prog = composition.freeze(8) - """trailing_history has at most 4 entries (same cap as HarmonicState.history).""" + assert len(prog.trailing_history) <= 4 - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) - prog = composition.freeze(8) +def test_freeze_deterministic_with_seed(patch_midi: None) -> None: + """freeze() with a seeded RNG produces the same progression each run. - assert len(prog.trailing_history) <= 4 + The composition-level seed is applied in _run() (async), so for this + unit test we seed the harmonic state's RNG directly — the property under + test is that freeze() is deterministic given identical initial conditions. + """ + def _run() -> subsequence.composition.Progression: + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + comp.harmony(style="functional_major", cycle_beats=4) + comp._harmonic_state.rng = random.Random(42) # type: ignore[union-attr] + return comp.freeze(8) -def test_freeze_deterministic_with_seed (patch_midi: None) -> None: + prog1 = _run() + prog2 = _run() - """freeze() with a seeded RNG produces the same progression each run. + assert [c.name() for c in prog1.chords] == [c.name() for c in prog2.chords] - The composition-level seed is applied in _run() (async), so for this - unit test we seed the harmonic state's RNG directly — the property under - test is that freeze() is deterministic given identical initial conditions. - """ - def _run () -> subsequence.composition.Progression: - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - comp.harmony(style="functional_major", cycle_beats=4) - comp._harmonic_state.rng = random.Random(42) # type: ignore[union-attr] - return comp.freeze(8) +def test_freeze_progression_is_immutable(patch_midi: None) -> None: + """Progression is a frozen dataclass — attempts to mutate it raise FrozenInstanceError.""" - prog1 = _run() - prog2 = _run() + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) + prog = composition.freeze(4) - assert [c.name() for c in prog1.chords] == [c.name() for c in prog2.chords] + import dataclasses - -def test_freeze_progression_is_immutable (patch_midi: None) -> None: - - """Progression is a frozen dataclass — attempts to mutate it raise FrozenInstanceError.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) - prog = composition.freeze(4) - - import dataclasses - with pytest.raises(dataclasses.FrozenInstanceError): - prog.chords = () # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + prog.chords = () # type: ignore[misc] # ── section_chords() ────────────────────────────────────────────────────────── -def test_section_chords_stores_progression (patch_midi: None) -> None: - - """section_chords() stores the progression under the section name.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) - prog = composition.freeze(4) - - composition.section_chords("verse", prog) +def test_section_chords_stores_progression(patch_midi: None) -> None: + """section_chords() stores the progression under the section name.""" - assert composition._section_progressions["verse"] is prog + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) + prog = composition.freeze(4) + composition.section_chords("verse", prog) -def test_section_chords_without_form_succeeds (patch_midi: None) -> None: + assert composition._section_progressions["verse"] is prog - """section_chords() succeeds even when form() has not yet been called.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) - prog = composition.freeze(4) +def test_section_chords_without_form_succeeds(patch_midi: None) -> None: + """section_chords() succeeds even when form() has not yet been called.""" - # Should not raise — form may be configured later or not at all. - composition.section_chords("verse", prog) - assert "verse" in composition._section_progressions + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) + prog = composition.freeze(4) + # Should not raise — form may be configured later or not at all. + composition.section_chords("verse", prog) + assert "verse" in composition._section_progressions -def test_section_chords_unknown_section_raises (patch_midi: None) -> None: - """section_chords() raises ValueError for a section not defined in the form.""" +def test_section_chords_unknown_section_raises(patch_midi: None) -> None: + """section_chords() raises ValueError for a section not defined in the form.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) - composition.form({ - "verse": (8, [("chorus", 1)]), - "chorus": (4, [("verse", 1)]), - }, start="verse") - prog = composition.freeze(4) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) + composition.form( + { + "verse": (8, [("chorus", 1)]), + "chorus": (4, [("verse", 1)]), + }, + start="verse", + ) + prog = composition.freeze(4) - with pytest.raises(ValueError, match="bridge"): - composition.section_chords("bridge", prog) + with pytest.raises(ValueError, match="bridge"): + composition.section_chords("bridge", prog) -def test_section_chords_multiple_sections (patch_midi: None) -> None: +def test_section_chords_multiple_sections(patch_midi: None) -> None: + """Multiple progressions can be bound to different sections independently.""" - """Multiple progressions can be bound to different sections independently.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", cycle_beats=4) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", cycle_beats=4) + verse = composition.freeze(8) + chorus = composition.freeze(4) - verse = composition.freeze(8) - chorus = composition.freeze(4) + composition.section_chords("verse", verse) + composition.section_chords("chorus", chorus) - composition.section_chords("verse", verse) - composition.section_chords("chorus", chorus) - - assert composition._section_progressions["verse"] is verse - assert composition._section_progressions["chorus"] is chorus + assert composition._section_progressions["verse"] is verse + assert composition._section_progressions["chorus"] is chorus # ── schedule_harmonic_clock() — the span-walking clock and the harmony window ── -def _progression_of (*names: str, beats: float = 4.0) -> subsequence.progressions.Progression: - - """A concrete progression from chord names, equal spans.""" +def _progression_of( + *names: str, beats: float = 4.0 +) -> subsequence.progressions.Progression: + """A concrete progression from chord names, equal spans.""" - return subsequence.progressions.progression(list(names), beats=beats) + return subsequence.progressions.progression(list(names), beats=beats) -async def _capture_clock (**kwargs: typing.Any) -> typing.Tuple[typing.Callable[[int], typing.Optional[float]], "subsequence.composition._HarmonyHorizon", typing.Optional[float]]: +async def _capture_clock( + **kwargs: typing.Any, +) -> typing.Tuple[ + typing.Callable[[int], typing.Optional[float]], + "subsequence.composition._HarmonyHorizon", + typing.Optional[float], +]: + """Schedule the clock against a mock sequencer; return (callback, horizon, first_interval_beats). - """Schedule the clock against a mock sequencer; return (callback, horizon, first_interval_beats). + The clock populates the window for beat 0 synchronously at schedule time, + then registers a callback sequence — we capture it and drive it by hand. + """ - The clock populates the window for beat 0 synchronously at schedule time, - then registers a callback sequence — we capture it and drive it by hand. - """ + captured: typing.Dict[str, typing.Any] = {} - captured: typing.Dict[str, typing.Any] = {} + mock_seq = unittest.mock.MagicMock() + mock_seq.pulses_per_beat = 24 - mock_seq = unittest.mock.MagicMock() - mock_seq.pulses_per_beat = 24 + async def capture( + callback: typing.Callable, start_pulse: int = 0, reschedule_lookahead: float = 1 + ) -> None: + captured["callback"] = callback + captured["start_pulse"] = start_pulse - async def capture (callback: typing.Callable, start_pulse: int = 0, reschedule_lookahead: float = 1) -> None: - captured["callback"] = callback - captured["start_pulse"] = start_pulse + mock_seq.schedule_callback_sequence = capture - mock_seq.schedule_callback_sequence = capture + horizon = kwargs.pop("horizon", None) or subsequence.composition._HarmonyHorizon() - horizon = kwargs.pop("horizon", None) or subsequence.composition._HarmonyHorizon() + await subsequence.composition.schedule_harmonic_clock( + sequencer=mock_seq, + horizon=horizon, + bar_beats=4.0, + **kwargs, + ) - await subsequence.composition.schedule_harmonic_clock( - sequencer = mock_seq, - horizon = horizon, - bar_beats = 4.0, - **kwargs, - ) + first_interval = captured["start_pulse"] / 24 if "callback" in captured else None - first_interval = captured["start_pulse"] / 24 if "callback" in captured else None - - return captured.get("callback"), horizon, first_interval + return captured.get("callback"), horizon, first_interval @pytest.mark.asyncio -async def test_section_progression_walks_spans (patch_midi: None) -> None: - - """A bound section progression's chords sound span by span, from beat 0.""" +async def test_section_progression_walks_spans(patch_midi: None) -> None: + """A bound section progression's chords sound span by span, from beat 0.""" - hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="functional_major") - prog = _progression_of("Am", "F", "C") + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major" + ) + prog = _progression_of("Am", "F", "C") - cb, horizon, first = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_section_progression = lambda: ("verse", 0, 4, prog), - ) + cb, horizon, first = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_section_progression=lambda: ("verse", 0, 4, prog), + ) - # Beat 0 was populated at schedule time: the section's FIRST chord sounds - # at the section's first beat (no tonic placeholder bar). - assert hs.current_chord.name() == "Am" - assert horizon.chord_at(0.0).name() == "Am" - assert first == 4.0 + # Beat 0 was populated at schedule time: the section's FIRST chord sounds + # at the section's first beat (no tonic placeholder bar). + assert hs.current_chord.name() == "Am" + assert horizon.chord_at(0.0).name() == "Am" + assert first == 4.0 - assert cb(4 * 24) is not None - assert hs.current_chord.name() == "F" - assert horizon.chord_at(4.0).name() == "F" + assert cb(4 * 24) is not None + assert hs.current_chord.name() == "F" + assert horizon.chord_at(4.0).name() == "F" - cb(8 * 24) - assert hs.current_chord.name() == "C" + cb(8 * 24) + assert hs.current_chord.name() == "C" @pytest.mark.asyncio -async def test_section_change_restarts_the_progression (patch_midi: None) -> None: - - """A new section index restarts the bound progression at that boundary.""" - - hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="functional_major") - prog = _progression_of("Am", "F") - current_section = ["verse", 0] - - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_section_progression = lambda: (current_section[0], current_section[1], 4, prog), - ) - - cb(4 * 24) - assert hs.current_chord.name() == "F" - - # Re-entry (verse → verse) bumps the index — the walk restarts. - current_section[1] = 1 - cb(8 * 24) - assert hs.current_chord.name() == "Am" +async def test_section_change_restarts_the_progression(patch_midi: None) -> None: + """A new section index restarts the bound progression at that boundary.""" + + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major" + ) + prog = _progression_of("Am", "F") + current_section = ["verse", 0] + + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_section_progression=lambda: ( + current_section[0], + current_section[1], + 4, + prog, + ), + ) + + cb(4 * 24) + assert hs.current_chord.name() == "F" + + # Re-entry (verse → verse) bumps the index — the walk restarts. + current_section[1] = 1 + cb(8 * 24) + assert hs.current_chord.name() == "Am" @pytest.mark.asyncio -async def test_unbound_section_steps_live_with_planned_window (patch_midi: None) -> None: +async def test_unbound_section_steps_live_with_planned_window(patch_midi: None) -> None: + """Sections without a progression step the live engine; the window holds [current, next].""" - """Sections without a progression step the live engine; the window holds [current, next].""" + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(7) + ) + tonic = hs.current_chord - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(7) - ) - tonic = hs.current_chord + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_section_progression=lambda: ("bridge", 0, 4, None), + ) - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_section_progression = lambda: ("bridge", 0, 4, None), - ) + # Beat 0 sounds the tonic (no step at start), and one step is pre-committed. + assert hs.current_chord is tonic + assert horizon.chord_at(0.0) is tonic + planned = horizon.chord_at(4.0) + assert planned is not None - # Beat 0 sounds the tonic (no step at start), and one step is pre-committed. - assert hs.current_chord is tonic - assert horizon.chord_at(0.0) is tonic - planned = horizon.chord_at(4.0) - assert planned is not None - - # At the boundary the planned chord commits — the window told the truth. - cb(4 * 24) - assert hs.current_chord is planned - assert len(hs.history) == 1 and hs.history[-1] is tonic + # At the boundary the planned chord commits — the window told the truth. + cb(4 * 24) + assert hs.current_chord is planned + assert len(hs.history) == 1 and hs.history[-1] is tonic @pytest.mark.asyncio -async def test_section_replay_restores_trailing_history (patch_midi: None) -> None: - - """Entering a section with trailing_history restores the frozen NIR context.""" - - hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="functional_major") - context_chord = subsequence.chords.parse_chord("G") - - prog = subsequence.progressions.Progression( - spans = (subsequence.progressions.ChordSpan(chord=subsequence.chords.parse_chord("Am"), beats=4.0),), - trailing_history = (context_chord,), - ) - - cb, _, _ = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_section_progression = lambda: ("verse", 0, 4, prog), - ) - - # The schedule-time beat-0 fire entered the section and restored history - # before walking, then the replay commit recorded the outgoing chord. - assert context_chord in hs.history +async def test_section_replay_restores_trailing_history(patch_midi: None) -> None: + """Entering a section with trailing_history restores the frozen NIR context.""" + + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major" + ) + context_chord = subsequence.chords.parse_chord("G") + + prog = subsequence.progressions.Progression( + spans=( + subsequence.progressions.ChordSpan( + chord=subsequence.chords.parse_chord("Am"), beats=4.0 + ), + ), + trailing_history=(context_chord,), + ) + + cb, _, _ = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_section_progression=lambda: ("verse", 0, 4, prog), + ) + + # The schedule-time beat-0 fire entered the section and restored history + # before walking, then the replay commit recorded the outgoing chord. + assert context_chord in hs.history @pytest.mark.asyncio -async def test_exhausted_section_falls_through_to_live_with_style (patch_midi: None) -> None: - - """With a live engine configured, an exhausted section progression falls through to stepping.""" +async def test_exhausted_section_falls_through_to_live_with_style( + patch_midi: None, +) -> None: + """With a live engine configured, an exhausted section progression falls through to stepping.""" - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(3) - ) - prog = _progression_of("Am") + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(3) + ) + prog = _progression_of("Am") - cb, _, _ = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_section_progression = lambda: ("verse", 0, 4, prog), - ) + cb, _, _ = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_section_progression=lambda: ("verse", 0, 4, prog), + ) - assert hs.current_chord.name() == "Am" + assert hs.current_chord.name() == "Am" - history_before = len(hs.history) - cb(4 * 24) # exhausted — the live engine takes over - assert len(hs.history) > history_before + history_before = len(hs.history) + cb(4 * 24) # exhausted — the live engine takes over + assert len(hs.history) > history_before @pytest.mark.asyncio -async def test_bound_progression_loops_without_a_live_engine (patch_midi: None) -> None: - - """harmony(progression=) with no style loops on exhaustion — manual harmony forever.""" +async def test_bound_progression_loops_without_a_live_engine(patch_midi: None) -> None: + """harmony(progression=) with no style loops on exhaustion — manual harmony forever.""" - prog = _progression_of("Am", "F") + prog = _progression_of("Am", "F") - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: None, - cycle_beats = 4, - get_bound_progression = lambda: prog, - ) + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: None, + cycle_beats=4, + get_bound_progression=lambda: prog, + ) - assert horizon.chord_at(0.0).name() == "Am" + assert horizon.chord_at(0.0).name() == "Am" - cb(4 * 24) - assert horizon.chord_at(4.0).name() == "F" + cb(4 * 24) + assert horizon.chord_at(4.0).name() == "F" - cb(8 * 24) # wrapped — the loop, not a fall-through (there is nothing to fall to) - assert horizon.chord_at(8.0).name() == "Am" + cb(8 * 24) # wrapped — the loop, not a fall-through (there is nothing to fall to) + assert horizon.chord_at(8.0).name() == "Am" - # The future is data: arbitrary beats answer without any fire. - assert horizon.chord_at(101.0).name() == "F" # beat 101 → offset 5 in the 8-beat loop + # The future is data: arbitrary beats answer without any fire. + assert ( + horizon.chord_at(101.0).name() == "F" + ) # beat 101 → offset 5 in the 8-beat loop @pytest.mark.asyncio -async def test_no_sources_schedules_nothing (patch_midi: None) -> None: +async def test_no_sources_schedules_nothing(patch_midi: None) -> None: + """With no engine and no progressions the clock declines to run.""" - """With no engine and no progressions the clock declines to run.""" + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: None, + cycle_beats=4, + ) - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: None, - cycle_beats = 4, - ) - - assert cb is None - assert horizon.is_empty + assert cb is None + assert horizon.is_empty @pytest.mark.asyncio -async def test_variable_spans_fire_at_span_and_bar_boundaries (patch_midi: None) -> None: - - """The clock fires at min(next span boundary, next bar boundary) — bar-aligned bookkeeping.""" +async def test_variable_spans_fire_at_span_and_bar_boundaries(patch_midi: None) -> None: + """The clock fires at min(next span boundary, next bar boundary) — bar-aligned bookkeeping.""" - prog = subsequence.progressions.progression([("Am", 2), ("F", 6)]) + prog = subsequence.progressions.progression([("Am", 2), ("F", 6)]) - cb, horizon, first = await _capture_clock( - get_harmonic_state = lambda: None, - cycle_beats = 4, - get_bound_progression = lambda: prog, - ) + cb, horizon, first = await _capture_clock( + get_harmonic_state=lambda: None, + cycle_beats=4, + get_bound_progression=lambda: prog, + ) - # Span Am ends at 2 — before the bar line at 4. - assert first == 2.0 + # Span Am ends at 2 — before the bar line at 4. + assert first == 2.0 - # Chord boundary at 2: F begins, lasting to 8; next fire is the BAR at 4. - assert cb(2 * 24) == 2.0 - assert horizon.chord_at(2.0).name() == "F" + # Chord boundary at 2: F begins, lasting to 8; next fire is the BAR at 4. + assert cb(2 * 24) == 2.0 + assert horizon.chord_at(2.0).name() == "F" - # Bar fire at 4: no chord change; next fire is the bar at 8 (== span end). - assert cb(4 * 24) == 4.0 - assert horizon.chord_at(5.0).name() == "F" + # Bar fire at 4: no chord change; next fire is the bar at 8 (== span end). + assert cb(4 * 24) == 4.0 + assert horizon.chord_at(5.0).name() == "F" - # Boundary at 8 wraps to Am. - cb(8 * 24) - assert horizon.chord_at(8.0).name() == "Am" + # Boundary at 8 wraps to Am. + cb(8 * 24) + assert horizon.chord_at(8.0).name() == "Am" @pytest.mark.asyncio -async def test_pinned_chord_overrides_the_source (patch_midi: None) -> None: - - """pin_chord is fiat — whatever the source produced, the pin sounds.""" +async def test_pinned_chord_overrides_the_source(patch_midi: None) -> None: + """pin_chord is fiat — whatever the source produced, the pin sounds.""" - prog = _progression_of("Am", "F") - pinned = subsequence.chords.parse_chord("E7") + prog = _progression_of("Am", "F") + pinned = subsequence.chords.parse_chord("E7") - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: None, - cycle_beats = 4, - get_bound_progression = lambda: prog, - get_pinned = {2: pinned}.get, # bar 2 = beats 4..8 - ) + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: None, + cycle_beats=4, + get_bound_progression=lambda: prog, + get_pinned={2: pinned}.get, # bar 2 = beats 4..8 + ) - cb(4 * 24) - assert horizon.chord_at(4.0) is pinned + cb(4 * 24) + assert horizon.chord_at(4.0) is pinned - # The data future honours pins too (bar 2 of any query window). - assert horizon.chord_at(0.0).name() == "Am" + # The data future honours pins too (bar 2 of any query window). + assert horizon.chord_at(0.0).name() == "Am" @pytest.mark.asyncio -async def test_live_window_clamps_beyond_planned_with_one_warning (patch_midi: None) -> None: +async def test_live_window_clamps_beyond_planned_with_one_warning( + patch_midi: None, +) -> None: + """In live mode chord_at beyond [current, next] clamps to the last known chord.""" - """In live mode chord_at beyond [current, next] clamps to the last known chord.""" + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=random.Random(1) + ) - hs = subsequence.harmonic_state.HarmonicState( - key_name="C", graph_style="functional_major", rng=random.Random(1) - ) + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + ) - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - ) + planned = horizon.chord_at(4.0) # the pre-committed step — real data + beyond = horizon.chord_at(40.0) # far beyond the window — clamped - planned = horizon.chord_at(4.0) # the pre-committed step — real data - beyond = horizon.chord_at(40.0) # far beyond the window — clamped - - assert planned is not None - assert beyond is planned + assert planned is not None + assert beyond is planned @pytest.mark.asyncio -async def test_decorated_spans_reach_the_window_engine_keeps_bare_triads (patch_midi: None) -> None: - - """Patterns hear the decorated chord; engine state stays the bare triad (§8.11).""" +async def test_decorated_spans_reach_the_window_engine_keeps_bare_triads( + patch_midi: None, +) -> None: + """Patterns hear the decorated chord; engine state stays the bare triad (§8.11).""" - hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="functional_major") - prog = _progression_of("Am", "F").extend(9, only=[1]) + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major" + ) + prog = _progression_of("Am", "F").extend(9, only=[1]) - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: hs, - cycle_beats = 4, - get_bound_progression = lambda: prog, - ) + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: hs, + cycle_beats=4, + get_bound_progression=lambda: prog, + ) - sounding = horizon.chord_at(0.0) + sounding = horizon.chord_at(0.0) - assert isinstance(sounding, subsequence.progressions.DecoratedChord) - assert sounding.name() == "Am9" - assert hs.current_chord == subsequence.chords.parse_chord("Am") # bare in the engine + assert isinstance(sounding, subsequence.progressions.DecoratedChord) + assert sounding.name() == "Am9" + assert hs.current_chord == subsequence.chords.parse_chord( + "Am" + ) # bare in the engine # ── zero_indexed_channels ───────────────────────────────────────────────────── -def test_zero_indexed_channels_default_is_false (patch_midi: None) -> None: - - """Default zero_indexed_channels=False uses 1-based channel numbering.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - - @composition.pattern(channel=10, beats=4) - def drums (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - - # Internal channel should be 9 (10 - 1, converted from 1-indexed) - assert composition._pending_patterns[0].channel == 9 - +def test_zero_indexed_channels_default_is_false(patch_midi: None) -> None: + """Default zero_indexed_channels=False uses 1-based channel numbering.""" -def test_one_indexed_channels_subtracts_one (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - """zero_indexed_channels=False converts channel 10 → internal 9.""" + @composition.pattern(channel=10, beats=4) + def drums(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False) + # Internal channel should be 9 (10 - 1, converted from 1-indexed) + assert composition._pending_patterns[0].channel == 9 - @composition.pattern(channel=10, beats=4) - def drums (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - assert composition._pending_patterns[0].channel == 9 +def test_one_indexed_channels_subtracts_one(patch_midi: None) -> None: + """zero_indexed_channels=False converts channel 10 → internal 9.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False + ) -def test_one_indexed_channels_channel_1 (patch_midi: None) -> None: + @composition.pattern(channel=10, beats=4) + def drums(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """zero_indexed_channels=False converts channel 1 → internal 0.""" + assert composition._pending_patterns[0].channel == 9 - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False) - @composition.pattern(channel=1, beats=4) - def bass (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass +def test_one_indexed_channels_channel_1(patch_midi: None) -> None: + """zero_indexed_channels=False converts channel 1 → internal 0.""" - assert composition._pending_patterns[0].channel == 0 + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False + ) + @composition.pattern(channel=1, beats=4) + def bass(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_one_indexed_channels_rejects_zero (patch_midi: None) -> None: + assert composition._pending_patterns[0].channel == 0 - """zero_indexed_channels=False rejects channel=0.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False) +def test_one_indexed_channels_rejects_zero(patch_midi: None) -> None: + """zero_indexed_channels=False rejects channel=0.""" - with pytest.raises(ValueError, match="1-16"): - @composition.pattern(channel=0, beats=4) - def bad (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False + ) + with pytest.raises(ValueError, match="1-16"): -def test_one_indexed_channels_rejects_17 (patch_midi: None) -> None: + @composition.pattern(channel=0, beats=4) + def bad(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """zero_indexed_channels=False rejects channel=17.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False) +def test_one_indexed_channels_rejects_17(patch_midi: None) -> None: + """zero_indexed_channels=False rejects channel=17.""" - with pytest.raises(ValueError, match="1-16"): - @composition.pattern(channel=17, beats=4) - def bad (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False + ) + with pytest.raises(ValueError, match="1-16"): -def test_zero_indexed_channels_rejects_16 (patch_midi: None) -> None: + @composition.pattern(channel=17, beats=4) + def bad(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """zero_indexed_channels=True rejects channel=16.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, zero_indexed_channels=True) +def test_zero_indexed_channels_rejects_16(patch_midi: None) -> None: + """zero_indexed_channels=True rejects channel=16.""" - with pytest.raises(ValueError, match="0-15"): - @composition.pattern(channel=16, beats=4) - def bad (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, zero_indexed_channels=True + ) + with pytest.raises(ValueError, match="0-15"): -def test_one_indexed_layer (patch_midi: None) -> None: + @composition.pattern(channel=16, beats=4) + def bad(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """layer() also resolves channels when 1-indexed.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False) +def test_one_indexed_layer(patch_midi: None) -> None: + """layer() also resolves channels when 1-indexed.""" - def kick (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False + ) - composition.layer(kick, channel=10, beats=4) + def kick(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - assert composition._pending_patterns[0].channel == 9 + composition.layer(kick, channel=10, beats=4) + assert composition._pending_patterns[0].channel == 9 -def test_live_info_reports_user_convention (patch_midi: None) -> None: - """live_info() adds 1 back to channels when 1-indexed.""" +def test_live_info_reports_user_convention(patch_midi: None) -> None: + """live_info() adds 1 back to channels when 1-indexed.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, zero_indexed_channels=False + ) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 9, # internally 0-indexed - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=9, # internally 0-indexed + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pattern = composition._build_pattern_from_pending(pending) - composition._running_patterns["my_builder"] = pattern + pattern = composition._build_pattern_from_pending(pending) + composition._running_patterns["my_builder"] = pattern - info = composition.live_info() + info = composition.live_info() - # Should report channel 10 (9 + 1) to the user - assert info["patterns"][0]["channel"] == 10 + # Should report channel 10 (9 + 1) to the user + assert info["patterns"][0]["channel"] == 10 # --- beats= / bars= / length= aliases --- -def test_pattern_beats_alias (patch_midi: None) -> None: - - """beats= should produce the same beat_length as length=.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - - @composition.pattern(channel=1, beats=8) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - - assert composition._pending_patterns[0].length == 8 - - -def test_pattern_bars_alias (patch_midi: None) -> None: +def test_pattern_beats_alias(patch_midi: None) -> None: + """beats= should produce the same beat_length as length=.""" - """bars=2 should produce beat_length=8 (2 bars × 4 beats).""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + @composition.pattern(channel=1, beats=8) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - @composition.pattern(channel=1, bars=2) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + assert composition._pending_patterns[0].length == 8 - assert composition._pending_patterns[0].length == 8 +def test_pattern_bars_alias(patch_midi: None) -> None: + """bars=2 should produce beat_length=8 (2 bars × 4 beats).""" -def test_pattern_default_is_four_beats (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - """Omitting beats/bars should default to 4 beats.""" + @composition.pattern(channel=1, bars=2) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + assert composition._pending_patterns[0].length == 8 - @composition.pattern(channel=1) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - assert composition._pending_patterns[0].length == 4 +def test_pattern_default_is_four_beats(patch_midi: None) -> None: + """Omitting beats/bars should default to 4 beats.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) -def test_pattern_multiple_length_params_raises (patch_midi: None) -> None: + @composition.pattern(channel=1) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """Specifying both beats= and bars= should raise ValueError.""" + assert composition._pending_patterns[0].length == 4 - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - with pytest.raises(ValueError, match="Specify only one"): +def test_pattern_multiple_length_params_raises(patch_midi: None) -> None: + """Specifying both beats= and bars= should raise ValueError.""" - @composition.pattern(channel=1, beats=4, bars=1) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + with pytest.raises(ValueError, match="Specify only one"): -def test_resolve_length_bars_one (patch_midi: None) -> None: + @composition.pattern(channel=1, beats=4, bars=1) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - """bars=1 should give beat_length=4 and default_grid=16.""" - beat_length, default_grid = subsequence.Composition._resolve_length(None, 1, None, None) - assert beat_length == 4 - assert default_grid == 16 +def test_resolve_length_bars_one(patch_midi: None) -> None: + """bars=1 should give beat_length=4 and default_grid=16.""" + beat_length, default_grid = subsequence.Composition._resolve_length( + None, 1, None, None + ) + assert beat_length == 4 + assert default_grid == 16 -def test_resolve_length_beats_eight (patch_midi: None) -> None: - """beats=8 should give beat_length=8 and default_grid=32.""" +def test_resolve_length_beats_eight(patch_midi: None) -> None: + """beats=8 should give beat_length=8 and default_grid=32.""" - beat_length, default_grid = subsequence.Composition._resolve_length(8, None, None, None) - assert beat_length == 8 - assert default_grid == 32 + beat_length, default_grid = subsequence.Composition._resolve_length( + 8, None, None, None + ) + assert beat_length == 8 + assert default_grid == 32 -def test_pattern_steps_unit (patch_midi: None) -> None: +def test_pattern_steps_unit(patch_midi: None) -> None: + """steps=6, step_duration=SIXTEENTH should give beat_length=1.5 and default_grid=6.""" - """steps=6, step_duration=SIXTEENTH should give beat_length=1.5 and default_grid=6.""" + import subsequence.constants.durations as dur - import subsequence.constants.durations as dur + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + @composition.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - @composition.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + pending = composition._pending_patterns[0] + assert pending.length == pytest.approx(1.5) + assert pending.default_grid == 6 - pending = composition._pending_patterns[0] - assert pending.length == pytest.approx(1.5) - assert pending.default_grid == 6 +def test_steps_without_unit_raises(patch_midi: None) -> None: + """steps= without step_duration= should raise ValueError.""" -def test_steps_without_unit_raises (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - """steps= without step_duration= should raise ValueError.""" + with pytest.raises(ValueError, match="steps= requires step_duration="): - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + @composition.pattern(channel=1, steps=6) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - with pytest.raises(ValueError, match="steps= requires step_duration="): - @composition.pattern(channel=1, steps=6) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass +def test_unit_without_steps_raises(patch_midi: None) -> None: + """step_duration= without steps= should raise ValueError.""" + import subsequence.constants.durations as dur -def test_unit_without_steps_raises (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - """step_duration= without steps= should raise ValueError.""" + with pytest.raises(ValueError, match="step_duration= requires steps="): - import subsequence.constants.durations as dur + @composition.pattern(channel=1, beats=4, step_duration=dur.SIXTEENTH) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - with pytest.raises(ValueError, match="step_duration= requires steps="): +def test_steps_with_beats_raises(patch_midi: None) -> None: + """steps= combined with beats= should raise ValueError.""" - @composition.pattern(channel=1, beats=4, step_duration=dur.SIXTEENTH) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + import subsequence.constants.durations as dur + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) -def test_steps_with_beats_raises (patch_midi: None) -> None: + with pytest.raises(ValueError, match="steps= cannot be combined"): - """steps= combined with beats= should raise ValueError.""" + @composition.pattern(channel=1, steps=6, beats=4, step_duration=dur.SIXTEENTH) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - import subsequence.constants.durations as dur - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) +def test_layer_beats_alias(patch_midi: None) -> None: + """layer() should accept beats= and produce correct beat_length.""" - with pytest.raises(ValueError, match="steps= cannot be combined"): + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - @composition.pattern(channel=1, steps=6, beats=4, step_duration=dur.SIXTEENTH) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + def kick(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass + composition.layer(kick, channel=1, beats=8) -def test_layer_beats_alias (patch_midi: None) -> None: + assert composition._pending_patterns[0].length == 8 - """layer() should accept beats= and produce correct beat_length.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) +def test_layer_bars_alias(patch_midi: None) -> None: + """layer() should accept bars= and convert to beat_length.""" - def kick (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition.layer(kick, channel=1, beats=8) + def kick(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - assert composition._pending_patterns[0].length == 8 + composition.layer(kick, channel=1, bars=2) + assert composition._pending_patterns[0].length == 8 -def test_layer_bars_alias (patch_midi: None) -> None: - """layer() should accept bars= and convert to beat_length.""" +def test_section_chords_replay_aligns_with_section_boundaries( + tmp_path, patch_midi: None +) -> None: + """Frozen section chords must start on the section's FIRST bar. - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + Regression: the harmonic clock was registered before the form clock, so on + every section-boundary bar it read the outgoing section and the frozen + progression played one bar late, bleeding its last chord into the next + section. + """ - def kick (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition(bpm=960, key="C", seed=11) + composition.harmony(style="functional_major", cycle_beats=4) + composition.form([("verse", 2), ("chorus", 2)], loop=True) - composition.layer(kick, channel=1, bars=2) + prog = composition.freeze(bars=2) + composition.section_chords("chorus", prog) - assert composition._pending_patterns[0].length == 8 + observed = [] -def test_section_chords_replay_aligns_with_section_boundaries (tmp_path, patch_midi: None) -> None: + @composition.pattern(channel=1, beats=4) + def watcher(p, chord) -> None: + if p.section is not None: + observed.append((p.section.name, chord.name())) - """Frozen section chords must start on the section's FIRST bar. + composition.render(bars=8, filename=str(tmp_path / "align.mid")) - Regression: the harmonic clock was registered before the form clock, so on - every section-boundary bar it read the outgoing section and the frozen - progression played one bar late, bleeding its last chord into the next - section. - """ + frozen_names = [c.name() for c in prog.chords] + chorus_chords = [name for section, name in observed if section == "chorus"] + verse_chords = [name for section, name in observed if section == "verse"] - composition = subsequence.Composition(bpm=960, key="C", seed=11) - composition.harmony(style="functional_major", cycle_beats=4) - composition.form([("verse", 2), ("chorus", 2)], loop=True) + # Every chorus bar must play the frozen chords in order from bar one. + assert chorus_chords[:2] == frozen_names - prog = composition.freeze(bars=2) - composition.section_chords("chorus", prog) + # The frozen progression must not bleed into the verse following a chorus. + # (Live verse harmony could coincide by chance with seed-dependent chords, + # so pin the structural property: chorus bars exactly cycle the frozen pair.) + assert all( + chorus_chords[i] == frozen_names[i % 2] for i in range(len(chorus_chords)) + ) + assert len(verse_chords) >= 2 - observed = [] - - @composition.pattern(channel=1, beats=4) - def watcher (p, chord) -> None: - if p.section is not None: - observed.append((p.section.name, chord.name())) - - composition.render(bars=8, filename=str(tmp_path / "align.mid")) - - frozen_names = [c.name() for c in prog.chords] - chorus_chords = [name for section, name in observed if section == "chorus"] - verse_chords = [name for section, name in observed if section == "verse"] - - # Every chorus bar must play the frozen chords in order from bar one. - assert chorus_chords[:2] == frozen_names - - # The frozen progression must not bleed into the verse following a chorus. - # (Live verse harmony could coincide by chance with seed-dependent chords, - # so pin the structural property: chorus bars exactly cycle the frozen pair.) - assert all( - chorus_chords[i] == frozen_names[i % 2] - for i in range(len(chorus_chords)) - ) - assert len(verse_chords) >= 2 @pytest.mark.asyncio -async def test_unregistered_pattern_is_not_resurrected_by_later_graduation (patch_midi: None) -> None: - - """A pattern deleted via unregister() must not come back on a later reload pass. - - Regression: pending declarations were never pruned, so every subsequent - live-reload graduation re-scheduled patterns that had been deleted. - """ +async def test_unregistered_pattern_is_not_resurrected_by_later_graduation( + patch_midi: None, +) -> None: + """A pattern deleted via unregister() must not come back on a later reload pass. - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + Regression: pending declarations were never pruned, so every subsequent + live-reload graduation re-scheduled patterns that had been deleted. + """ - @composition.pattern(channel=1, beats=4) - def ghost (p) -> None: - p.note(60, beat=0) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - await composition._activate_new_pending_patterns() + @composition.pattern(channel=1, beats=4) + def ghost(p) -> None: + p.note(60, beat=0) - assert "ghost" in composition._running_patterns - assert composition._pending_patterns == [] + await composition._activate_new_pending_patterns() - composition.unregister("ghost") + assert "ghost" in composition._running_patterns + assert composition._pending_patterns == [] - assert "ghost" not in composition._running_patterns + composition.unregister("ghost") - # A later reload's graduation pass must not bring it back. - await composition._activate_new_pending_patterns() + assert "ghost" not in composition._running_patterns - assert "ghost" not in composition._running_patterns + # A later reload's graduation pass must not bring it back. + await composition._activate_new_pending_patterns() + assert "ghost" not in composition._running_patterns -def test_schedule_after_play_raises (patch_midi: None) -> None: - """schedule() after playback starts must fail loudly, not register silently into the void.""" +def test_schedule_after_play_raises(patch_midi: None) -> None: + """schedule() after playback starts must fail loudly, not register silently into the void.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition._sequencer.running = True + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition._sequencer.running = True - with pytest.raises(RuntimeError, match="before play"): - composition.schedule(lambda: None, cycle_beats=4) + with pytest.raises(RuntimeError, match="before play"): + composition.schedule(lambda: None, cycle_beats=4) @pytest.mark.asyncio -async def test_pin_inside_a_long_span_forces_its_bar (patch_midi: None) -> None: +async def test_pin_inside_a_long_span_forces_its_bar(patch_midi: None) -> None: + """A pin on a bar mid-span takes effect at that bar line — fiat is fiat.""" - """A pin on a bar mid-span takes effect at that bar line — fiat is fiat.""" + prog = subsequence.progressions.progression([("Am", 8)]) # one span, two bars + pinned = subsequence.chords.parse_chord("E7") - prog = subsequence.progressions.progression([("Am", 8)]) # one span, two bars - pinned = subsequence.chords.parse_chord("E7") + cb, horizon, _ = await _capture_clock( + get_harmonic_state=lambda: None, + cycle_beats=4, + get_bound_progression=lambda: prog, + get_pinned={2: pinned}.get, + ) - cb, horizon, _ = await _capture_clock( - get_harmonic_state = lambda: None, - cycle_beats = 4, - get_bound_progression = lambda: prog, - get_pinned = {2: pinned}.get, - ) + assert horizon.chord_at(0.0).name() == "Am" - assert horizon.chord_at(0.0).name() == "Am" - - # The bar fire at beat 4 is NOT a chord boundary — the pin still lands. - cb(4 * 24) - assert horizon.chord_at(4.0) is pinned - assert horizon.chord_at(2.0).name() == "Am" # the first bar is untouched + # The bar fire at beat 4 is NOT a chord boundary — the pin still lands. + cb(4 * 24) + assert horizon.chord_at(4.0) is pinned + assert horizon.chord_at(2.0).name() == "Am" # the first bar is untouched # ── freeze() hybrid constraints (stage 3) ───────────────────────────────────── -def test_freeze_end_lands_the_final_bar (patch_midi: None) -> None: - - """freeze(end=...) — "end on V at bar 8" — completes sketch (a)'s missing line.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=42) - composition.harmony(cycle_beats=4) - - frozen = composition.freeze(8, end="V") - - assert len(frozen) == 8 - assert frozen.chords[-1] == subsequence.chords.parse_chord("G") - +def test_freeze_end_lands_the_final_bar(patch_midi: None) -> None: + """freeze(end=...) — "end on V at bar 8" — completes sketch (a)'s missing line.""" -def test_freeze_pins_and_avoid (patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=42 + ) + composition.harmony(cycle_beats=4) - """Interior pins land; avoided chords never sound.""" + frozen = composition.freeze(8, end="V") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=7) - composition.harmony(cycle_beats=4) + assert len(frozen) == 8 + assert frozen.chords[-1] == subsequence.chords.parse_chord("G") - frozen = composition.freeze(8, pins={4: "F"}, avoid=["vi"]) - assert frozen.chords[3] == subsequence.chords.parse_chord("F") - assert subsequence.chords.parse_chord("Am") not in frozen.chords +def test_freeze_pins_and_avoid(patch_midi: None) -> None: + """Interior pins land; avoided chords never sound.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=7 + ) + composition.harmony(cycle_beats=4) -def test_freeze_bar_one_is_the_journey (patch_midi: None) -> None: + frozen = composition.freeze(8, pins={4: "F"}, avoid=["vi"]) - """pins={1: ...} that contradicts the current chord refuses — continuity is freeze's identity.""" + assert frozen.chords[3] == subsequence.chords.parse_chord("F") + assert subsequence.chords.parse_chord("Am") not in frozen.chords - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.harmony(cycle_beats=4) - with pytest.raises(ValueError, match="journey"): - composition.freeze(4, pins={1: "F"}) +def test_freeze_bar_one_is_the_journey(patch_midi: None) -> None: + """pins={1: ...} that contradicts the current chord refuses — continuity is freeze's identity.""" - # Naming the current chord redundantly is fine. - frozen = composition.freeze(4, pins={1: "C"}) - assert frozen.chords[0] == subsequence.chords.parse_chord("C") + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.harmony(cycle_beats=4) + with pytest.raises(ValueError, match="journey"): + composition.freeze(4, pins={1: "F"}) -def test_freeze_constrained_is_reproducible_without_play (patch_midi: None) -> None: + # Naming the current chord redundantly is fine. + frozen = composition.freeze(4, pins={1: "C"}) + assert frozen.chords[0] == subsequence.chords.parse_chord("C") - """The salted freeze streams cover the constrained path too.""" - def journey () -> typing.Tuple[typing.Any, ...]: - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=42) - composition.harmony(cycle_beats=4) - return composition.freeze(8, end="V").chords + composition.freeze(4).chords +def test_freeze_constrained_is_reproducible_without_play(patch_midi: None) -> None: + """The salted freeze streams cover the constrained path too.""" - assert journey() == journey() + def journey() -> typing.Tuple[typing.Any, ...]: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=42 + ) + composition.harmony(cycle_beats=4) + return composition.freeze(8, end="V").chords + composition.freeze(4).chords + assert journey() == journey() -def test_freeze_unsatisfiable_raises_and_engine_state_survives (patch_midi: None) -> None: - """Contradictory constraints fail before any draw — the engine is untouched.""" +def test_freeze_unsatisfiable_raises_and_engine_state_survives( + patch_midi: None, +) -> None: + """Contradictory constraints fail before any draw — the engine is untouched.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=42) - composition.harmony(cycle_beats=4) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=42 + ) + composition.harmony(cycle_beats=4) - hs = composition.harmonic_state - chord_before = hs.current_chord - history_before = list(hs.history) + hs = composition.harmonic_state + chord_before = hs.current_chord + history_before = list(hs.history) - with pytest.raises(ValueError): - composition.freeze(4, end="V", avoid=["V"]) + with pytest.raises(ValueError): + composition.freeze(4, end="V", avoid=["V"]) - assert hs.current_chord is chord_before - assert hs.history == history_before + assert hs.current_chord is chord_before + assert hs.history == history_before diff --git a/tests/test_conductor.py b/tests/test_conductor.py index 05502db..7dbbfdf 100644 --- a/tests/test_conductor.py +++ b/tests/test_conductor.py @@ -1,4 +1,3 @@ - import warnings import pytest @@ -7,292 +6,283 @@ import subsequence.pattern_builder -def test_lfo_sine () -> None: - - """Test sine wave LFO output.""" - - conductor = subsequence.conductor.Conductor() - conductor.lfo("sine", shape="sine", cycle_beats=4.0, min_val=0.0, max_val=1.0) - - # Start (phase 0) -> sin(0) = 0 -> mapped to 0.5 - assert conductor.get("sine", 0.0) == 0.5 - - # 1 beat (0.25 cycle) -> sin(pi/2) = 1 -> mapped to 1.0 - assert conductor.get("sine", 1.0) == 1.0 - - # 2 beats (0.5 cycle) -> sin(pi) = 0 -> mapped to 0.5 - assert conductor.get("sine", 2.0) == pytest.approx(0.5) - - # 3 beats (0.75 cycle) -> sin(3pi/2) = -1 -> mapped to 0.0 - assert conductor.get("sine", 3.0) == pytest.approx(0.0) - - # 4 beats (1.0 cycle) -> sin(2pi) = 0 -> mapped to 0.5 - assert conductor.get("sine", 4.0) == pytest.approx(0.5) - +def test_lfo_sine() -> None: + """Test sine wave LFO output.""" + + conductor = subsequence.conductor.Conductor() + conductor.lfo("sine", shape="sine", cycle_beats=4.0, min_val=0.0, max_val=1.0) + + # Start (phase 0) -> sin(0) = 0 -> mapped to 0.5 + assert conductor.get("sine", 0.0) == 0.5 + + # 1 beat (0.25 cycle) -> sin(pi/2) = 1 -> mapped to 1.0 + assert conductor.get("sine", 1.0) == 1.0 -def test_lfo_triangle () -> None: + # 2 beats (0.5 cycle) -> sin(pi) = 0 -> mapped to 0.5 + assert conductor.get("sine", 2.0) == pytest.approx(0.5) - """Test triangle wave LFO output.""" - - conductor = subsequence.conductor.Conductor() - conductor.lfo("tri", shape="triangle", cycle_beats=4.0) - - # 0.0 -> 0.0 - assert conductor.get("tri", 0.0) == 0.0 - - # 1.0 (0.25 cycle) -> 0.5 - assert conductor.get("tri", 1.0) == 0.5 - - # 2.0 (0.5 cycle) -> 1.0 - assert conductor.get("tri", 2.0) == 1.0 - - # 3.0 (0.75 cycle) -> 0.5 - assert conductor.get("tri", 3.0) == 0.5 + # 3 beats (0.75 cycle) -> sin(3pi/2) = -1 -> mapped to 0.0 + assert conductor.get("sine", 3.0) == pytest.approx(0.0) + # 4 beats (1.0 cycle) -> sin(2pi) = 0 -> mapped to 0.5 + assert conductor.get("sine", 4.0) == pytest.approx(0.5) -def test_line_ramp () -> None: - """Test linear ramp.""" - - conductor = subsequence.conductor.Conductor() - conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=4.0) - - # Start - assert conductor.get("ramp", 0.0) == 0.0 - - # Midpoint - assert conductor.get("ramp", 2.0) == 0.5 - - # End - assert conductor.get("ramp", 4.0) == 1.0 - - # Past end (clamped) - assert conductor.get("ramp", 5.0) == 1.0 +def test_lfo_triangle() -> None: + """Test triangle wave LFO output.""" + conductor = subsequence.conductor.Conductor() + conductor.lfo("tri", shape="triangle", cycle_beats=4.0) -def test_line_loop () -> None: + # 0.0 -> 0.0 + assert conductor.get("tri", 0.0) == 0.0 - """Test looping ramp.""" - - conductor = subsequence.conductor.Conductor() - conductor.line("loop", start_val=0.0, end_val=1.0, duration_beats=4.0, loop=True) - - # Start - assert conductor.get("loop", 0.0) == 0.0 - - # End of first loop is start of second - assert conductor.get("loop", 4.0) == 0.0 - - # 1.5 loops - assert conductor.get("loop", 6.0) == 0.5 + # 1.0 (0.25 cycle) -> 0.5 + assert conductor.get("tri", 1.0) == 0.5 + # 2.0 (0.5 cycle) -> 1.0 + assert conductor.get("tri", 2.0) == 1.0 -def test_lfo_saw () -> None: + # 3.0 (0.75 cycle) -> 0.5 + assert conductor.get("tri", 3.0) == 0.5 - """Test saw wave LFO output.""" - conductor = subsequence.conductor.Conductor() - conductor.lfo("saw", shape="saw", cycle_beats=4.0) +def test_line_ramp() -> None: + """Test linear ramp.""" - # Start of cycle -> 0.0 - assert conductor.get("saw", 0.0) == 0.0 + conductor = subsequence.conductor.Conductor() + conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=4.0) - # Quarter cycle -> 0.25 - assert conductor.get("saw", 1.0) == 0.25 + # Start + assert conductor.get("ramp", 0.0) == 0.0 - # Half cycle -> 0.5 - assert conductor.get("saw", 2.0) == 0.5 + # Midpoint + assert conductor.get("ramp", 2.0) == 0.5 - # Three-quarter cycle -> 0.75 - assert conductor.get("saw", 3.0) == 0.75 + # End + assert conductor.get("ramp", 4.0) == 1.0 + # Past end (clamped) + assert conductor.get("ramp", 5.0) == 1.0 -def test_lfo_square () -> None: - """Test square wave LFO output.""" +def test_line_loop() -> None: + """Test looping ramp.""" - conductor = subsequence.conductor.Conductor() - conductor.lfo("sq", shape="square", cycle_beats=4.0) + conductor = subsequence.conductor.Conductor() + conductor.line("loop", start_val=0.0, end_val=1.0, duration_beats=4.0, loop=True) - # First half of cycle -> 1.0 - assert conductor.get("sq", 0.0) == 1.0 - assert conductor.get("sq", 1.0) == 1.0 + # Start + assert conductor.get("loop", 0.0) == 0.0 - # Second half of cycle -> 0.0 - assert conductor.get("sq", 2.0) == 0.0 - assert conductor.get("sq", 3.0) == 0.0 + # End of first loop is start of second + assert conductor.get("loop", 4.0) == 0.0 + # 1.5 loops + assert conductor.get("loop", 6.0) == 0.5 -def test_lfo_phase_offset () -> None: - """Test LFO with phase offset.""" +def test_lfo_saw() -> None: + """Test saw wave LFO output.""" - conductor = subsequence.conductor.Conductor() - conductor.lfo("sine", shape="sine", cycle_beats=4.0, phase=0.25) + conductor = subsequence.conductor.Conductor() + conductor.lfo("saw", shape="saw", cycle_beats=4.0) - # phase=0.25 shifts by quarter cycle, so beat 0 is at sin(pi/2) = 1 -> mapped to 1.0 - assert conductor.get("sine", 0.0) == pytest.approx(1.0) + # Start of cycle -> 0.0 + assert conductor.get("saw", 0.0) == 0.0 - # beat 1 is at sin(pi) = 0 -> mapped to 0.5 - assert conductor.get("sine", 1.0) == pytest.approx(0.5) + # Quarter cycle -> 0.25 + assert conductor.get("saw", 1.0) == 0.25 + # Half cycle -> 0.5 + assert conductor.get("saw", 2.0) == 0.5 -def test_lfo_custom_range () -> None: + # Three-quarter cycle -> 0.75 + assert conductor.get("saw", 3.0) == 0.75 - """Test LFO with custom min/max values.""" - conductor = subsequence.conductor.Conductor() - conductor.lfo("vel", shape="triangle", cycle_beats=4.0, min_val=50.0, max_val=100.0) +def test_lfo_square() -> None: + """Test square wave LFO output.""" - # Triangle at 0.0 -> 0.0 -> mapped to 50.0 - assert conductor.get("vel", 0.0) == 50.0 + conductor = subsequence.conductor.Conductor() + conductor.lfo("sq", shape="square", cycle_beats=4.0) - # Triangle peak at 2.0 -> 1.0 -> mapped to 100.0 - assert conductor.get("vel", 2.0) == 100.0 + # First half of cycle -> 1.0 + assert conductor.get("sq", 0.0) == 1.0 + assert conductor.get("sq", 1.0) == 1.0 - # Triangle at 1.0 -> 0.5 -> mapped to 75.0 - assert conductor.get("vel", 1.0) == 75.0 + # Second half of cycle -> 0.0 + assert conductor.get("sq", 2.0) == 0.0 + assert conductor.get("sq", 3.0) == 0.0 -def test_lfo_invalid_cycle_beats () -> None: +def test_lfo_phase_offset() -> None: + """Test LFO with phase offset.""" - """Test that zero or negative cycle_beats raises ValueError.""" + conductor = subsequence.conductor.Conductor() + conductor.lfo("sine", shape="sine", cycle_beats=4.0, phase=0.25) - conductor = subsequence.conductor.Conductor() + # phase=0.25 shifts by quarter cycle, so beat 0 is at sin(pi/2) = 1 -> mapped to 1.0 + assert conductor.get("sine", 0.0) == pytest.approx(1.0) - with pytest.raises(ValueError): - conductor.lfo("bad", cycle_beats=0) + # beat 1 is at sin(pi) = 0 -> mapped to 0.5 + assert conductor.get("sine", 1.0) == pytest.approx(0.5) - with pytest.raises(ValueError): - conductor.lfo("bad", cycle_beats=-1.0) +def test_lfo_custom_range() -> None: + """Test LFO with custom min/max values.""" -def test_line_invalid_duration () -> None: + conductor = subsequence.conductor.Conductor() + conductor.lfo("vel", shape="triangle", cycle_beats=4.0, min_val=50.0, max_val=100.0) - """Test that zero or negative duration_beats raises ValueError.""" + # Triangle at 0.0 -> 0.0 -> mapped to 50.0 + assert conductor.get("vel", 0.0) == 50.0 - conductor = subsequence.conductor.Conductor() + # Triangle peak at 2.0 -> 1.0 -> mapped to 100.0 + assert conductor.get("vel", 2.0) == 100.0 - with pytest.raises(ValueError): - conductor.line("bad", start_val=0, end_val=1, duration_beats=0) + # Triangle at 1.0 -> 0.5 -> mapped to 75.0 + assert conductor.get("vel", 1.0) == 75.0 - with pytest.raises(ValueError): - conductor.line("bad", start_val=0, end_val=1, duration_beats=-4.0) +def test_lfo_invalid_cycle_beats() -> None: + """Test that zero or negative cycle_beats raises ValueError.""" -def test_missing_signal () -> None: + conductor = subsequence.conductor.Conductor() - """Querying an unregistered signal returns 0.0 and issues a warning.""" + with pytest.raises(ValueError): + conductor.lfo("bad", cycle_beats=0) - conductor = subsequence.conductor.Conductor() + with pytest.raises(ValueError): + conductor.lfo("bad", cycle_beats=-1.0) - with pytest.warns(UserWarning, match="ghost"): - result = conductor.get("ghost", 0.0) - assert result == 0.0 +def test_line_invalid_duration() -> None: + """Test that zero or negative duration_beats raises ValueError.""" + conductor = subsequence.conductor.Conductor() -def test_missing_signal_warns_once () -> None: + with pytest.raises(ValueError): + conductor.line("bad", start_val=0, end_val=1, duration_beats=0) - """The missing-signal warning is issued only once per signal name.""" + with pytest.raises(ValueError): + conductor.line("bad", start_val=0, end_val=1, duration_beats=-4.0) - conductor = subsequence.conductor.Conductor() - with pytest.warns(UserWarning): - conductor.get("ghost", 0.0) +def test_missing_signal() -> None: + """Querying an unregistered signal returns 0.0 and issues a warning.""" - # Second call must not issue another warning. - with warnings.catch_warnings(): - warnings.simplefilter("error") - assert conductor.get("ghost", 1.0) == 0.0 + conductor = subsequence.conductor.Conductor() + with pytest.warns(UserWarning, match="ghost"): + result = conductor.get("ghost", 0.0) -def test_lfo_invalid_shape () -> None: + assert result == 0.0 - """An unrecognised LFO shape raises ValueError at construction time.""" - conductor = subsequence.conductor.Conductor() +def test_missing_signal_warns_once() -> None: + """The missing-signal warning is issued only once per signal name.""" - with pytest.raises(ValueError, match="wobbly"): - conductor.lfo("bad", shape="wobbly") + conductor = subsequence.conductor.Conductor() + with pytest.warns(UserWarning): + conductor.get("ghost", 0.0) -def test_signal_helper () -> None: + # Second call must not issue another warning. + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert conductor.get("ghost", 1.0) == 0.0 - """Test p.signal() reads the conductor at the current bar.""" - conductor = subsequence.conductor.Conductor() - conductor.lfo("tri", shape="triangle", cycle_beats=16.0) +def test_lfo_invalid_shape() -> None: + """An unrecognised LFO shape raises ValueError at construction time.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4.0) + conductor = subsequence.conductor.Conductor() - # bar=2 -> beat=8 -> progress 8/16=0.5 -> triangle peak = 1.0 - builder = subsequence.pattern_builder.PatternBuilder(pattern, cycle=0, conductor=conductor, bar=2) + with pytest.raises(ValueError, match="wobbly"): + conductor.lfo("bad", shape="wobbly") - assert builder.signal("tri") == 1.0 - # bar=0 -> beat=0 -> triangle start = 0.0 - builder_zero = subsequence.pattern_builder.PatternBuilder(pattern, cycle=0, conductor=conductor, bar=0) +def test_signal_helper() -> None: + """Test p.signal() reads the conductor at the current bar.""" - assert builder_zero.signal("tri") == 0.0 + conductor = subsequence.conductor.Conductor() + conductor.lfo("tri", shape="triangle", cycle_beats=16.0) + pattern = subsequence.pattern.Pattern(channel=0, length=4.0) -def test_line_with_shape_ease_in () -> None: + # bar=2 -> beat=8 -> progress 8/16=0.5 -> triangle peak = 1.0 + builder = subsequence.pattern_builder.PatternBuilder( + pattern, cycle=0, conductor=conductor, bar=2 + ) - """Line with shape='ease_in' returns non-linear values.""" + assert builder.signal("tri") == 1.0 - conductor = subsequence.conductor.Conductor() - conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=4.0, shape="ease_in") + # bar=0 -> beat=0 -> triangle start = 0.0 + builder_zero = subsequence.pattern_builder.PatternBuilder( + pattern, cycle=0, conductor=conductor, bar=0 + ) - # ease_in(0.5) = 0.25, so the midpoint value should be 0.25, not 0.5 - assert conductor.get("ramp", 2.0) == pytest.approx(0.25) + assert builder_zero.signal("tri") == 0.0 - # Endpoints should still clamp correctly - assert conductor.get("ramp", 0.0) == pytest.approx(0.0) - assert conductor.get("ramp", 4.0) == pytest.approx(1.0) +def test_line_with_shape_ease_in() -> None: + """Line with shape='ease_in' returns non-linear values.""" -def test_line_with_shape_ease_in_out () -> None: + conductor = subsequence.conductor.Conductor() + conductor.line( + "ramp", start_val=0.0, end_val=1.0, duration_beats=4.0, shape="ease_in" + ) - """Line with shape='ease_in_out' is symmetric around the midpoint.""" + # ease_in(0.5) = 0.25, so the midpoint value should be 0.25, not 0.5 + assert conductor.get("ramp", 2.0) == pytest.approx(0.25) - conductor = subsequence.conductor.Conductor() - conductor.line("ramp", start_val=0.0, end_val=100.0, duration_beats=4.0, shape="ease_in_out") + # Endpoints should still clamp correctly + assert conductor.get("ramp", 0.0) == pytest.approx(0.0) + assert conductor.get("ramp", 4.0) == pytest.approx(1.0) - # ease_in_out is symmetric: midpoint value is the midpoint of start/end - assert conductor.get("ramp", 2.0) == pytest.approx(50.0) - # The value at 1/4 duration should be less than 25 (slow start) - assert conductor.get("ramp", 1.0) < 25.0 +def test_line_with_shape_ease_in_out() -> None: + """Line with shape='ease_in_out' is symmetric around the midpoint.""" + conductor = subsequence.conductor.Conductor() + conductor.line( + "ramp", start_val=0.0, end_val=100.0, duration_beats=4.0, shape="ease_in_out" + ) -def test_line_with_callable_shape () -> None: + # ease_in_out is symmetric: midpoint value is the midpoint of start/end + assert conductor.get("ramp", 2.0) == pytest.approx(50.0) - """Line accepts a raw callable as the shape parameter.""" + # The value at 1/4 duration should be less than 25 (slow start) + assert conductor.get("ramp", 1.0) < 25.0 - conductor = subsequence.conductor.Conductor() - # Custom square-root easing - conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=4.0, shape=lambda t: t ** 0.5) - # At midpoint, sqrt(0.5) ≈ 0.707 - assert conductor.get("ramp", 2.0) == pytest.approx(0.5 ** 0.5) +def test_line_with_callable_shape() -> None: + """Line accepts a raw callable as the shape parameter.""" + conductor = subsequence.conductor.Conductor() + # Custom square-root easing + conductor.line( + "ramp", start_val=0.0, end_val=1.0, duration_beats=4.0, shape=lambda t: t**0.5 + ) -def test_line_default_shape_is_linear () -> None: + # At midpoint, sqrt(0.5) ≈ 0.707 + assert conductor.get("ramp", 2.0) == pytest.approx(0.5**0.5) - """The default Line shape remains linear (regression test).""" - conductor = subsequence.conductor.Conductor() - conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=4.0) +def test_line_default_shape_is_linear() -> None: + """The default Line shape remains linear (regression test).""" - assert conductor.get("ramp", 2.0) == pytest.approx(0.5) + conductor = subsequence.conductor.Conductor() + conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=4.0) + assert conductor.get("ramp", 2.0) == pytest.approx(0.5) -def test_signal_helper_no_conductor () -> None: - """Test p.signal() returns 0.0 when no conductor is attached.""" +def test_signal_helper_no_conductor() -> None: + """Test p.signal() returns 0.0 when no conductor is attached.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern, cycle=0) + pattern = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern, cycle=0) - assert builder.signal("anything") == 0.0 + assert builder.signal("anything") == 0.0 diff --git a/tests/test_custom_scales.py b/tests/test_custom_scales.py index 2824dee..ae818c4 100644 --- a/tests/test_custom_scales.py +++ b/tests/test_custom_scales.py @@ -8,298 +8,303 @@ @contextlib.contextmanager -def _scratch_scale (name: str) -> typing.Iterator[None]: +def _scratch_scale(name: str) -> typing.Iterator[None]: + """Unregister a test-registered scale on exit, even when an assertion fails. - """Unregister a test-registered scale on exit, even when an assertion fails. + Keeps the module-global INTERVAL_DEFINITIONS / SCALE_MODE_MAP registries + clean so one failing test cannot leak state into its neighbours. + """ - Keeps the module-global INTERVAL_DEFINITIONS / SCALE_MODE_MAP registries - clean so one failing test cannot leak state into its neighbours. - """ - - try: - yield - finally: - subsequence.intervals.INTERVAL_DEFINITIONS.pop(name, None) - subsequence.intervals.SCALE_MODE_MAP.pop(name, None) + try: + yield + finally: + subsequence.intervals.INTERVAL_DEFINITIONS.pop(name, None) + subsequence.intervals.SCALE_MODE_MAP.pop(name, None) # ── Built-in non-western scales ───────────────────────────────────── -def test_hirajoshi_pitch_classes () -> None: - - """Hirajōshi scale produces 5 pitch classes.""" - - pcs = subsequence.intervals.scale_pitch_classes(2, "hirajoshi") # D Hirajōshi - assert len(pcs) == 5 - assert pcs == [2, 4, 5, 9, 10] # D, E, F, A, Bb - - -def test_in_sen_pitch_classes () -> None: - - """In-Sen scale produces 5 pitch classes.""" - pcs = subsequence.intervals.scale_pitch_classes(0, "in_sen") # C In-Sen - assert len(pcs) == 5 - assert pcs == [0, 1, 5, 7, 10] +def test_hirajoshi_pitch_classes() -> None: + """Hirajōshi scale produces 5 pitch classes.""" + pcs = subsequence.intervals.scale_pitch_classes(2, "hirajoshi") # D Hirajōshi + assert len(pcs) == 5 + assert pcs == [2, 4, 5, 9, 10] # D, E, F, A, Bb -def test_iwato_pitch_classes () -> None: - """Iwato scale produces 5 pitch classes.""" +def test_in_sen_pitch_classes() -> None: + """In-Sen scale produces 5 pitch classes.""" - pcs = subsequence.intervals.scale_pitch_classes(0, "iwato") - assert len(pcs) == 5 - assert pcs == [0, 1, 5, 6, 10] + pcs = subsequence.intervals.scale_pitch_classes(0, "in_sen") # C In-Sen + assert len(pcs) == 5 + assert pcs == [0, 1, 5, 7, 10] -def test_yo_pitch_classes () -> None: +def test_iwato_pitch_classes() -> None: + """Iwato scale produces 5 pitch classes.""" - """Yo scale produces 5 pitch classes.""" + pcs = subsequence.intervals.scale_pitch_classes(0, "iwato") + assert len(pcs) == 5 + assert pcs == [0, 1, 5, 6, 10] - pcs = subsequence.intervals.scale_pitch_classes(0, "yo") - assert len(pcs) == 5 - assert pcs == [0, 2, 5, 7, 9] +def test_yo_pitch_classes() -> None: + """Yo scale produces 5 pitch classes.""" -def test_egyptian_pitch_classes () -> None: + pcs = subsequence.intervals.scale_pitch_classes(0, "yo") + assert len(pcs) == 5 + assert pcs == [0, 2, 5, 7, 9] - """Egyptian scale produces 5 pitch classes.""" - pcs = subsequence.intervals.scale_pitch_classes(0, "egyptian") - assert len(pcs) == 5 - assert pcs == [0, 2, 5, 7, 10] +def test_egyptian_pitch_classes() -> None: + """Egyptian scale produces 5 pitch classes.""" + pcs = subsequence.intervals.scale_pitch_classes(0, "egyptian") + assert len(pcs) == 5 + assert pcs == [0, 2, 5, 7, 10] -def test_major_pentatonic_pitch_classes () -> None: - """Major pentatonic via SCALE_MODE_MAP.""" +def test_major_pentatonic_pitch_classes() -> None: + """Major pentatonic via SCALE_MODE_MAP.""" - pcs = subsequence.intervals.scale_pitch_classes(0, "major_pentatonic") - assert len(pcs) == 5 - assert pcs == [0, 2, 4, 7, 9] + pcs = subsequence.intervals.scale_pitch_classes(0, "major_pentatonic") + assert len(pcs) == 5 + assert pcs == [0, 2, 4, 7, 9] -def test_minor_pentatonic_pitch_classes () -> None: +def test_minor_pentatonic_pitch_classes() -> None: + """Minor pentatonic via SCALE_MODE_MAP.""" - """Minor pentatonic via SCALE_MODE_MAP.""" - - pcs = subsequence.intervals.scale_pitch_classes(0, "minor_pentatonic") - assert len(pcs) == 5 - assert pcs == [0, 3, 5, 7, 10] + pcs = subsequence.intervals.scale_pitch_classes(0, "minor_pentatonic") + assert len(pcs) == 5 + assert pcs == [0, 3, 5, 7, 10] # ── quantize_pitch with 5-note scales ─────────────────────────────── -def test_quantize_hirajoshi_snaps_correctly () -> None: - - """Notes outside a 5-note scale snap to the nearest scale tone.""" - pcs = subsequence.intervals.scale_pitch_classes(2, "hirajoshi") # D: [2,4,5,9,10] +def test_quantize_hirajoshi_snaps_correctly() -> None: + """Notes outside a 5-note scale snap to the nearest scale tone.""" - # D (62) is in scale → unchanged - assert subsequence.intervals.quantize_pitch(62, pcs) == 62 + pcs = subsequence.intervals.scale_pitch_classes(2, "hirajoshi") # D: [2,4,5,9,10] - # C# (61) is not in scale → snaps to D (62, +1 preferred) - assert subsequence.intervals.quantize_pitch(61, pcs) == 62 + # D (62) is in scale → unchanged + assert subsequence.intervals.quantize_pitch(62, pcs) == 62 - # G (67, pc 7) is equidistant between F (pc 5, -2) and A (pc 9, +2); ties snap up → A (69) - assert subsequence.intervals.quantize_pitch(67, pcs) == 69 + # C# (61) is not in scale → snaps to D (62, +1 preferred) + assert subsequence.intervals.quantize_pitch(61, pcs) == 62 + # G (67, pc 7) is equidistant between F (pc 5, -2) and A (pc 9, +2); ties snap up → A (69) + assert subsequence.intervals.quantize_pitch(67, pcs) == 69 -def test_quantize_handles_large_gap () -> None: - """Hirajōshi has a 4-semitone gap (F→A). Notes in the gap still snap.""" +def test_quantize_handles_large_gap() -> None: + """Hirajōshi has a 4-semitone gap (F→A). Notes in the gap still snap.""" - pcs = subsequence.intervals.scale_pitch_classes(0, "hirajoshi") # C: [0,2,3,7,8] + pcs = subsequence.intervals.scale_pitch_classes(0, "hirajoshi") # C: [0,2,3,7,8] - # E (64) is pc 4, not in scale. Nearest: 3 (Eb, -1) or 7 (G, +3). Snaps down to Eb. - assert subsequence.intervals.quantize_pitch(64, pcs) == 63 + # E (64) is pc 4, not in scale. Nearest: 3 (Eb, -1) or 7 (G, +3). Snaps down to Eb. + assert subsequence.intervals.quantize_pitch(64, pcs) == 63 - # F (65) is pc 5. Nearest: 3 (Eb, -2) or 7 (G, +2). Up preferred → G (67). - assert subsequence.intervals.quantize_pitch(65, pcs) == 67 + # F (65) is pc 5. Nearest: 3 (Eb, -2) or 7 (G, +2). Up preferred → G (67). + assert subsequence.intervals.quantize_pitch(65, pcs) == 67 # ── register_scale ────────────────────────────────────────────────── -def test_register_scale_basic () -> None: - - """A registered custom scale works with scale_pitch_classes.""" - with _scratch_scale("test_custom"): - subsequence.intervals.register_scale("test_custom", [0, 3, 6, 9]) +def test_register_scale_basic() -> None: + """A registered custom scale works with scale_pitch_classes.""" - pcs = subsequence.intervals.scale_pitch_classes(0, "test_custom") - assert pcs == [0, 3, 6, 9] + with _scratch_scale("test_custom"): + subsequence.intervals.register_scale("test_custom", [0, 3, 6, 9]) + pcs = subsequence.intervals.scale_pitch_classes(0, "test_custom") + assert pcs == [0, 3, 6, 9] -def test_register_scale_with_qualities () -> None: - """A registered scale with qualities works with diatonic_chords.""" +def test_register_scale_with_qualities() -> None: + """A registered scale with qualities works with diatonic_chords.""" - with _scratch_scale("test_with_quals"): - subsequence.intervals.register_scale( - "test_with_quals", - [0, 2, 4, 7, 9], - qualities=["major", "minor", "minor", "major", "minor"] - ) + with _scratch_scale("test_with_quals"): + subsequence.intervals.register_scale( + "test_with_quals", + [0, 2, 4, 7, 9], + qualities=["major", "minor", "minor", "major", "minor"], + ) - chords = subsequence.harmony.diatonic_chords("C", mode="test_with_quals") - assert len(chords) == 5 + chords = subsequence.harmony.diatonic_chords("C", mode="test_with_quals") + assert len(chords) == 5 -def test_register_scale_quantize () -> None: +def test_register_scale_quantize() -> None: + """A registered custom scale works with quantize_pitch.""" - """A registered custom scale works with quantize_pitch.""" + with _scratch_scale("test_quant"): + subsequence.intervals.register_scale("test_quant", [0, 4, 7]) - with _scratch_scale("test_quant"): - subsequence.intervals.register_scale("test_quant", [0, 4, 7]) + pcs = subsequence.intervals.scale_pitch_classes(0, "test_quant") + # C (0), E (4), G (7) — a major triad + assert subsequence.intervals.quantize_pitch(61, pcs) == 60 # C# → C + assert ( + subsequence.intervals.quantize_pitch(62, pcs) == 64 + ) # D → E (up preferred at distance 2) - pcs = subsequence.intervals.scale_pitch_classes(0, "test_quant") - # C (0), E (4), G (7) — a major triad - assert subsequence.intervals.quantize_pitch(61, pcs) == 60 # C# → C - assert subsequence.intervals.quantize_pitch(62, pcs) == 64 # D → E (up preferred at distance 2) +def test_register_scale_via_package() -> None: + """register_scale is accessible from the package level.""" -def test_register_scale_via_package () -> None: + import subsequence - """register_scale is accessible from the package level.""" - - import subsequence - - with _scratch_scale("test_pkg"): - subsequence.register_scale("test_pkg", [0, 5, 7]) - pcs = subsequence.intervals.scale_pitch_classes(0, "test_pkg") - assert pcs == [0, 5, 7] + with _scratch_scale("test_pkg"): + subsequence.register_scale("test_pkg", [0, 5, 7]) + pcs = subsequence.intervals.scale_pitch_classes(0, "test_pkg") + assert pcs == [0, 5, 7] # ── register_scale validation ─────────────────────────────────────── -def test_register_scale_must_start_with_zero () -> None: - - """intervals must start with 0.""" - with pytest.raises(ValueError, match="start with 0"): - subsequence.intervals.register_scale("bad", [1, 3, 5]) +def test_register_scale_must_start_with_zero() -> None: + """intervals must start with 0.""" + with pytest.raises(ValueError, match="start with 0"): + subsequence.intervals.register_scale("bad", [1, 3, 5]) -def test_register_scale_values_in_range () -> None: - """intervals must be 0-11.""" +def test_register_scale_values_in_range() -> None: + """intervals must be 0-11.""" - with pytest.raises(ValueError, match="between 0 and 11"): - subsequence.intervals.register_scale("bad", [0, 3, 14]) + with pytest.raises(ValueError, match="between 0 and 11"): + subsequence.intervals.register_scale("bad", [0, 3, 14]) -def test_register_scale_qualities_length_mismatch () -> None: +def test_register_scale_qualities_length_mismatch() -> None: + """qualities length must match intervals length.""" - """qualities length must match intervals length.""" + with pytest.raises(ValueError, match="qualities length"): + subsequence.intervals.register_scale( + "bad", [0, 3, 7], qualities=["major", "minor"] + ) - with pytest.raises(ValueError, match="qualities length"): - subsequence.intervals.register_scale("bad", [0, 3, 7], qualities=["major", "minor"]) +def test_register_scale_rejects_builtin_name() -> None: + """Overwriting a built-in scale name raises and leaves the built-in intact.""" -def test_register_scale_rejects_builtin_name () -> None: + with pytest.raises(ValueError, match="built-in"): + subsequence.intervals.register_scale("minor", [0, 1, 2]) - """Overwriting a built-in scale name raises and leaves the built-in intact.""" + with pytest.raises(ValueError, match="built-in"): + subsequence.intervals.register_scale("hirajoshi", [0, 1, 2]) - with pytest.raises(ValueError, match="built-in"): - subsequence.intervals.register_scale("minor", [0, 1, 2]) + # The built-in definition is unchanged. + assert subsequence.intervals.scale_pitch_classes(0, "minor") == [ + 0, + 2, + 3, + 5, + 7, + 8, + 10, + ] - with pytest.raises(ValueError, match="built-in"): - subsequence.intervals.register_scale("hirajoshi", [0, 1, 2]) - # The built-in definition is unchanged. - assert subsequence.intervals.scale_pitch_classes(0, "minor") == [0, 2, 3, 5, 7, 8, 10] +def test_register_scale_custom_name_reregisters() -> None: + """Re-registering a custom name works — live reload re-runs registration on every save.""" + with _scratch_scale("test_rereg"): + subsequence.intervals.register_scale("test_rereg", [0, 3, 7]) + subsequence.intervals.register_scale("test_rereg", [0, 4, 7]) -def test_register_scale_custom_name_reregisters () -> None: + # The second registration wins. + assert subsequence.intervals.scale_pitch_classes(0, "test_rereg") == [0, 4, 7] - """Re-registering a custom name works — live reload re-runs registration on every save.""" - with _scratch_scale("test_rereg"): - subsequence.intervals.register_scale("test_rereg", [0, 3, 7]) - subsequence.intervals.register_scale("test_rereg", [0, 4, 7]) +def test_register_scale_empty_intervals() -> None: + """intervals must not be empty.""" - # The second registration wins. - assert subsequence.intervals.scale_pitch_classes(0, "test_rereg") == [0, 4, 7] + with pytest.raises(ValueError, match="empty"): + subsequence.intervals.register_scale("bad", []) -def test_register_scale_empty_intervals () -> None: +def test_register_scale_non_integer_intervals() -> None: + """intervals must be whole numbers.""" - """intervals must not be empty.""" + with pytest.raises(ValueError, match="whole numbers"): + subsequence.intervals.register_scale("bad", [0, 2.5, 7]) # type: ignore[list-item] - with pytest.raises(ValueError, match="empty"): - subsequence.intervals.register_scale("bad", []) +def test_register_scale_not_strictly_ascending() -> None: + """intervals must ascend strictly — descents and duplicates raise.""" -def test_register_scale_non_integer_intervals () -> None: + with pytest.raises(ValueError, match="ascending"): + subsequence.intervals.register_scale("bad", [0, 7, 3]) - """intervals must be whole numbers.""" - - with pytest.raises(ValueError, match="whole numbers"): - subsequence.intervals.register_scale("bad", [0, 2.5, 7]) # type: ignore[list-item] - - -def test_register_scale_not_strictly_ascending () -> None: - - """intervals must ascend strictly — descents and duplicates raise.""" - - with pytest.raises(ValueError, match="ascending"): - subsequence.intervals.register_scale("bad", [0, 7, 3]) - - with pytest.raises(ValueError, match="ascending"): - subsequence.intervals.register_scale("bad", [0, 3, 3, 7]) + with pytest.raises(ValueError, match="ascending"): + subsequence.intervals.register_scale("bad", [0, 3, 3, 7]) # ── diatonic_chords guard for no-qualities modes ──────────────────── -def test_diatonic_chords_raises_for_no_qualities () -> None: - """Modes without chord qualities raise a clear error.""" +def test_diatonic_chords_raises_for_no_qualities() -> None: + """Modes without chord qualities raise a clear error.""" - with pytest.raises(ValueError, match="no chord qualities"): - subsequence.harmony.diatonic_chords("C", mode="hirajoshi") + with pytest.raises(ValueError, match="no chord qualities"): + subsequence.harmony.diatonic_chords("C", mode="hirajoshi") # ── diatonic_chord_sequence with non-7-note scales ────────────────── -def test_diatonic_chord_sequence_5_note () -> None: - """diatonic_chord_sequence works with a 5-note scale that has qualities.""" +def test_diatonic_chord_sequence_5_note() -> None: + """diatonic_chord_sequence works with a 5-note scale that has qualities.""" - with _scratch_scale("test_seq_5"): - subsequence.intervals.register_scale( - "test_seq_5", - [0, 2, 4, 7, 9], - qualities=["major", "minor", "minor", "major", "minor"] - ) + with _scratch_scale("test_seq_5"): + subsequence.intervals.register_scale( + "test_seq_5", + [0, 2, 4, 7, 9], + qualities=["major", "minor", "minor", "major", "minor"], + ) - seq = subsequence.harmony.diatonic_chord_sequence("C", root_midi=60, count=10, mode="test_seq_5") + seq = subsequence.harmony.diatonic_chord_sequence( + "C", root_midi=60, count=10, mode="test_seq_5" + ) - # Should wrap after 5 degrees into the next octave - assert len(seq) == 10 - assert seq[0][1] == 60 # C4 - assert seq[5][1] == 72 # C5 (one octave up) + # Should wrap after 5 degrees into the next octave + assert len(seq) == 10 + assert seq[0][1] == 60 # C4 + assert seq[5][1] == 72 # C5 (one octave up) # ── Backwards compatibility ───────────────────────────────────────── -def test_diatonic_mode_map_alias () -> None: - - """DIATONIC_MODE_MAP is a backwards-compatible alias for SCALE_MODE_MAP.""" - - assert subsequence.intervals.DIATONIC_MODE_MAP is subsequence.intervals.SCALE_MODE_MAP - - -def test_western_modes_unchanged () -> None: - - """All original western modes still return the expected chord counts.""" - western_modes = [ - "ionian", "major", "dorian", "phrygian", "lydian", - "mixolydian", "aeolian", "minor", "locrian", - "harmonic_minor", "melodic_minor" - ] - for mode in western_modes: - chords = subsequence.harmony.diatonic_chords("C", mode=mode) - assert len(chords) == 7, f"{mode} should still return 7 chords" +def test_diatonic_mode_map_alias() -> None: + """DIATONIC_MODE_MAP is a backwards-compatible alias for SCALE_MODE_MAP.""" + + assert ( + subsequence.intervals.DIATONIC_MODE_MAP is subsequence.intervals.SCALE_MODE_MAP + ) + + +def test_western_modes_unchanged() -> None: + """All original western modes still return the expected chord counts.""" + + western_modes = [ + "ionian", + "major", + "dorian", + "phrygian", + "lydian", + "mixolydian", + "aeolian", + "minor", + "locrian", + "harmonic_minor", + "melodic_minor", + ] + for mode in western_modes: + chords = subsequence.harmony.diatonic_chords("C", mode=mode) + assert len(chords) == 7, f"{mode} should still return 7 chords" diff --git a/tests/test_detached.py b/tests/test_detached.py index c9e3497..e32d797 100644 --- a/tests/test_detached.py +++ b/tests/test_detached.py @@ -1,141 +1,132 @@ - import pytest import subsequence.pattern import subsequence.pattern_builder -def test_detached_uniform_gap () -> None: - - """Test detached transform with uniform note spacing.""" - - # 4-beat pattern (96 pulses) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - - # Notes at beat 0 and 2 (pulses 0 and 48) - builder.note(pitch=60, beat=0.0, duration=0.1) - builder.note(pitch=62, beat=2.0, duration=0.1) - - # 0.05 beats = 1 pulse at 24 PPQN (int truncation: int(0.05 * 24) = 1) - builder.detached() - - # Pulse 0 -> 48 gap is 48; minus 1 pulse silence = 47 - # Pulse 48 -> 96 (wrap to 0) gap is 48; minus 1 pulse silence = 47 - assert pat.steps[0].notes[0].duration == 47 - assert pat.steps[48].notes[0].duration == 47 - - -def test_detached_varying_gaps () -> None: - - """Test detached transform with irregular spacing and wrap-around.""" +def test_detached_uniform_gap() -> None: + """Test detached transform with uniform note spacing.""" - # 4-beat pattern (96 pulses) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # 4-beat pattern (96 pulses) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - # Notes at 0, 1.0 (24), 3.0 (72) - builder.note(pitch=60, beat=0.0, duration=0.1) - builder.note(pitch=60, beat=1.0, duration=0.1) - builder.note(pitch=60, beat=3.0, duration=0.1) + # Notes at beat 0 and 2 (pulses 0 and 48) + builder.note(pitch=60, beat=0.0, duration=0.1) + builder.note(pitch=62, beat=2.0, duration=0.1) - # Use 0.25 beats (6 pulses) as the silence - builder.detached(beats=0.25) + # 0.05 beats = 1 pulse at 24 PPQN (int truncation: int(0.05 * 24) = 1) + builder.detached() - # 0 -> 24 (gap 24) - 6 = 18 - assert pat.steps[0].notes[0].duration == 18 - # 24 -> 72 (gap 48) - 6 = 42 - assert pat.steps[24].notes[0].duration == 42 - # 72 -> 0 (wrap: 96-72 = 24) - 6 = 18 - assert pat.steps[72].notes[0].duration == 18 + # Pulse 0 -> 48 gap is 48; minus 1 pulse silence = 47 + # Pulse 48 -> 96 (wrap to 0) gap is 48; minus 1 pulse silence = 47 + assert pat.steps[0].notes[0].duration == 47 + assert pat.steps[48].notes[0].duration == 47 -def test_detached_explicit_beats () -> None: +def test_detached_varying_gaps() -> None: + """Test detached transform with irregular spacing and wrap-around.""" - """Test detached with an explicit beats value larger than the default.""" + # 4-beat pattern (96 pulses) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # Notes at 0, 1.0 (24), 3.0 (72) + builder.note(pitch=60, beat=0.0, duration=0.1) + builder.note(pitch=60, beat=1.0, duration=0.1) + builder.note(pitch=60, beat=3.0, duration=0.1) - # Notes at 0 and 2.0 (48) - builder.note(pitch=60, beat=0.0, duration=0.1) - builder.note(pitch=60, beat=2.0, duration=0.1) + # Use 0.25 beats (6 pulses) as the silence + builder.detached(beats=0.25) - # 0.5-beat silence = 12 pulses - builder.detached(beats=0.5) + # 0 -> 24 (gap 24) - 6 = 18 + assert pat.steps[0].notes[0].duration == 18 + # 24 -> 72 (gap 48) - 6 = 42 + assert pat.steps[24].notes[0].duration == 42 + # 72 -> 0 (wrap: 96-72 = 24) - 6 = 18 + assert pat.steps[72].notes[0].duration == 18 - # Gap 48 - 12 = 36 - assert pat.steps[0].notes[0].duration == 36 - assert pat.steps[48].notes[0].duration == 36 +def test_detached_explicit_beats() -> None: + """Test detached with an explicit beats value larger than the default.""" -def test_detached_single_note () -> None: + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - """Test detached with a single note — wraps to itself.""" + # Notes at 0 and 2.0 (48) + builder.note(pitch=60, beat=0.0, duration=0.1) + builder.note(pitch=60, beat=2.0, duration=0.1) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # 0.5-beat silence = 12 pulses + builder.detached(beats=0.5) - # Note at beat 1.0 (24) - builder.note(pitch=60, beat=1.0, duration=0.1) + # Gap 48 - 12 = 36 + assert pat.steps[0].notes[0].duration == 36 + assert pat.steps[48].notes[0].duration == 36 - # 0.25-beat silence = 6 pulses - builder.detached(beats=0.25) - # Wrap-around: gap = 96 (full cycle) - 6 = 90 - assert pat.steps[24].notes[0].duration == 90 +def test_detached_single_note() -> None: + """Test detached with a single note — wraps to itself.""" + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) -def test_detached_empty () -> None: + # Note at beat 1.0 (24) + builder.note(pitch=60, beat=1.0, duration=0.1) - """Test detached on empty pattern (no-op).""" + # 0.25-beat silence = 6 pulses + builder.detached(beats=0.25) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # Wrap-around: gap = 96 (full cycle) - 6 = 90 + assert pat.steps[24].notes[0].duration == 90 - builder.detached() - assert len(pat.steps) == 0 +def test_detached_empty() -> None: + """Test detached on empty pattern (no-op).""" -def test_detached_minimum_duration () -> None: + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - """Ensure duration is at least 1 pulse when the gap is fully consumed.""" + builder.detached() + assert len(pat.steps) == 0 - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - # Two notes very close together: gap = 24 pulses (1 beat). - # A detached value of 2 beats (48 pulses) would push duration negative. - builder.note(pitch=60, beat=0.0) - builder.note(pitch=60, beat=1.0) +def test_detached_minimum_duration() -> None: + """Ensure duration is at least 1 pulse when the gap is fully consumed.""" - builder.detached(beats=2.0) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - # Must clamp to 1 pulse minimum. - assert pat.steps[0].notes[0].duration == 1 + # Two notes very close together: gap = 24 pulses (1 beat). + # A detached value of 2 beats (48 pulses) would push duration negative. + builder.note(pitch=60, beat=0.0) + builder.note(pitch=60, beat=1.0) + builder.detached(beats=2.0) -def test_detached_invalid_beats () -> None: + # Must clamp to 1 pulse minimum. + assert pat.steps[0].notes[0].duration == 1 - """beats <= 0 must raise ValueError.""" - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) +def test_detached_invalid_beats() -> None: + """beats <= 0 must raise ValueError.""" - with pytest.raises(ValueError, match="detached beats must be positive"): - builder.detached(beats=0) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - with pytest.raises(ValueError, match="detached beats must be positive"): - builder.detached(beats=-0.1) + with pytest.raises(ValueError, match="detached beats must be positive"): + builder.detached(beats=0) + with pytest.raises(ValueError, match="detached beats must be positive"): + builder.detached(beats=-0.1) -def test_detached_returns_self () -> None: - """detached() must return self for chaining.""" +def test_detached_returns_self() -> None: + """detached() must return self for chaining.""" - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - builder.note(pitch=60, beat=0.0) - result = builder.detached() - assert result is builder + builder.note(pitch=60, beat=0.0) + result = builder.detached() + assert result is builder diff --git a/tests/test_determinism.py b/tests/test_determinism.py index 5384ef6..5f8d97d 100644 --- a/tests/test_determinism.py +++ b/tests/test_determinism.py @@ -18,319 +18,318 @@ import subsequence.composition -def _make (seed: int = 42, key: str = "A") -> "subsequence.Composition": +def _make(seed: int = 42, key: str = "A") -> "subsequence.Composition": + """A keyed, seeded composition against the dummy MIDI device.""" - """A keyed, seeded composition against the dummy MIDI device.""" + return subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key=key, seed=seed + ) - return subsequence.Composition(output_device="Dummy MIDI", bpm=120, key=key, seed=seed) +def _pending(fn, channel: int = 1) -> "subsequence.composition._PendingPattern": + """Wrap a builder function as a pending pattern registration.""" -def _pending (fn, channel: int = 1) -> "subsequence.composition._PendingPattern": - - """Wrap a builder function as a pending pattern registration.""" - - return subsequence.composition._PendingPattern( - builder_fn = fn, - channel = channel, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + return subsequence.composition._PendingPattern( + builder_fn=fn, + channel=channel, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) # ── seed property ─────────────────────────────────────────────────────────── -def test_seed_property_get_set (patch_midi: None) -> None: - """comp.seed reads and assigns; the former call form raises TypeError.""" +def test_seed_property_get_set(patch_midi: None) -> None: + """comp.seed reads and assigns; the former call form raises TypeError.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - assert composition.seed is None + assert composition.seed is None - composition.seed = 7 - assert composition.seed == 7 + composition.seed = 7 + assert composition.seed == 7 - with pytest.raises(TypeError): - composition.seed(7) # type: ignore[operator] + with pytest.raises(TypeError): + composition.seed(7) # type: ignore[operator] # ── seed_for derivation ───────────────────────────────────────────────────── -def test_seed_for_is_crc32_of_seed_and_name (patch_midi: None) -> None: - """seed_for(name) == crc32("seed:name") — process-stable, order-free.""" +def test_seed_for_is_crc32_of_seed_and_name(patch_midi: None) -> None: + """seed_for(name) == crc32("seed:name") — process-stable, order-free.""" - composition = _make(seed=42) + composition = _make(seed=42) - assert composition.seed_for("drums") == zlib.crc32(b"42:drums") - assert composition.seed_for("hook") == zlib.crc32(b"42:hook") + assert composition.seed_for("drums") == zlib.crc32(b"42:drums") + assert composition.seed_for("hook") == zlib.crc32(b"42:hook") -def test_seed_for_unseeded_returns_none (patch_midi: None) -> None: +def test_seed_for_unseeded_returns_none(patch_midi: None) -> None: + """Without a composition seed there is no derivation.""" - """Without a composition seed there is no derivation.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - - assert composition.seed_for("drums") is None + assert composition.seed_for("drums") is None # ── freeze(): reproducible without play() ─────────────────────────────────── -def test_freeze_deterministic_without_play (patch_midi: None) -> None: - - """Two same-seed compositions freeze identical progressions pre-play.""" - first = _make() - first.harmony(style="functional_major") +def test_freeze_deterministic_without_play(patch_midi: None) -> None: + """Two same-seed compositions freeze identical progressions pre-play.""" - second = _make() - second.harmony(style="functional_major") + first = _make() + first.harmony(style="functional_major") - assert first.freeze(8).chords == second.freeze(8).chords + second = _make() + second.harmony(style="functional_major") + assert first.freeze(8).chords == second.freeze(8).chords -def test_freeze_differs_across_seeds (patch_midi: None) -> None: - """Different seeds produce different frozen journeys.""" +def test_freeze_differs_across_seeds(patch_midi: None) -> None: + """Different seeds produce different frozen journeys.""" - runs = [] + runs = [] - for seed in (42, 99): - composition = _make(seed=seed) - composition.harmony(style="functional_major") - runs.append(tuple(composition.freeze(8).chords)) + for seed in (42, 99): + composition = _make(seed=seed) + composition.harmony(style="functional_major") + runs.append(tuple(composition.freeze(8).chords)) - assert runs[0] != runs[1] + assert runs[0] != runs[1] -def test_freeze_stream_independent_of_other_consumers (patch_midi: None) -> None: +def test_freeze_stream_independent_of_other_consumers(patch_midi: None) -> None: + """A foreign draw on the engine's rng cannot shift freeze() output.""" - """A foreign draw on the engine's rng cannot shift freeze() output.""" + clean = _make() + clean.harmony(style="functional_major") + expected = clean.freeze(4).chords - clean = _make() - clean.harmony(style="functional_major") - expected = clean.freeze(4).chords + disturbed = _make() + disturbed.harmony(style="functional_major") + disturbed._harmonic_state.rng.random() # someone else consumes the engine stream - disturbed = _make() - disturbed.harmony(style="functional_major") - disturbed._harmonic_state.rng.random() # someone else consumes the engine stream + assert disturbed.freeze(4).chords == expected - assert disturbed.freeze(4).chords == expected +def test_successive_freezes_are_a_continuing_deterministic_journey( + patch_midi: None, +) -> None: + """freeze:1 then freeze:2 reproduce as a pair (state continues, salts differ).""" -def test_successive_freezes_are_a_continuing_deterministic_journey (patch_midi: None) -> None: + first = _make() + first.harmony(style="functional_major") + journey_a = (first.freeze(4).chords, first.freeze(4).chords) - """freeze:1 then freeze:2 reproduce as a pair (state continues, salts differ).""" + second = _make() + second.harmony(style="functional_major") + journey_b = (second.freeze(4).chords, second.freeze(4).chords) - first = _make() - first.harmony(style="functional_major") - journey_a = (first.freeze(4).chords, first.freeze(4).chords) + assert journey_a == journey_b - second = _make() - second.harmony(style="functional_major") - journey_b = (second.freeze(4).chords, second.freeze(4).chords) - assert journey_a == journey_b +def test_freeze_restores_engine_rng(patch_midi: None) -> None: + """Swap-and-restore: the engine keeps its own stream for play.""" + composition = _make() + composition.harmony(style="functional_major") -def test_freeze_restores_engine_rng (patch_midi: None) -> None: + engine_rng = composition._harmonic_state.rng + composition.freeze(4) - """Swap-and-restore: the engine keeps its own stream for play.""" - - composition = _make() - composition.harmony(style="functional_major") - - engine_rng = composition._harmonic_state.rng - composition.freeze(4) - - assert composition._harmonic_state.rng is engine_rng + assert composition._harmonic_state.rng is engine_rng # ── build-time seeding of harmony() and form() ────────────────────────────── -def test_harmony_rng_seeded_at_call_time (patch_midi: None) -> None: - - """harmony() installs a deterministic per-call stream (harmony:1).""" - draws = [] +def test_harmony_rng_seeded_at_call_time(patch_midi: None) -> None: + """harmony() installs a deterministic per-call stream (harmony:1).""" - for _ in range(2): - composition = _make() - composition.harmony(style="functional_major") - draws.append(composition._harmonic_state.rng.random()) + draws = [] - assert draws[0] == draws[1] + for _ in range(2): + composition = _make() + composition.harmony(style="functional_major") + draws.append(composition._harmonic_state.rng.random()) + assert draws[0] == draws[1] -def test_form_state_seeded_at_call_time (patch_midi: None) -> None: - """form() installs a deterministic per-call stream (form:1).""" +def test_form_state_seeded_at_call_time(patch_midi: None) -> None: + """form() installs a deterministic per-call stream (form:1).""" - graph = { - "a": (4, [("b", 1)]), - "b": (4, [("a", 1)]), - } - draws = [] + graph = { + "a": (4, [("b", 1)]), + "b": (4, [("a", 1)]), + } + draws = [] - for _ in range(2): - composition = _make() - composition.form(dict(graph), start="a") - draws.append(composition._form_state._rng.random()) + for _ in range(2): + composition = _make() + composition.form(dict(graph), start="a") + draws.append(composition._form_state._rng.random()) - assert draws[0] == draws[1] + assert draws[0] == draws[1] # ── name-keyed pattern streams ────────────────────────────────────────────── -def test_pattern_stream_keyed_by_name_not_order (patch_midi: None) -> None: - """A pattern's stream derives from its name; registration order is irrelevant.""" +def test_pattern_stream_keyed_by_name_not_order(patch_midi: None) -> None: + """A pattern's stream derives from its name; registration order is irrelevant.""" - def observe (name: str, build_first: bool) -> float: + def observe(name: str, build_first: bool) -> float: + composition = _make(seed=42) + seen = [] - composition = _make(seed=42) - seen = [] + def lead(p) -> None: + seen.append(p.rng.random()) - def lead (p) -> None: - seen.append(p.rng.random()) + def drums(p) -> None: + p.rng.random() - def drums (p) -> None: - p.rng.random() + order = [lead, drums] if build_first else [drums, lead] - order = [lead, drums] if build_first else [drums, lead] + for fn in order: + composition._build_pattern_from_pending(_pending(fn)) - for fn in order: - composition._build_pattern_from_pending(_pending(fn)) + return seen[0] - return seen[0] + expected = random.Random(zlib.crc32(b"42:lead")).random() - expected = random.Random(zlib.crc32(b"42:lead")).random() + assert observe("lead", build_first=True) == expected + assert observe("lead", build_first=False) == expected - assert observe("lead", build_first=True) == expected - assert observe("lead", build_first=False) == expected +def test_live_added_pattern_gets_seeded_stream(patch_midi: None) -> None: + """Building a pending pattern at any time deals its seeded stream (live-add fix).""" -def test_live_added_pattern_gets_seeded_stream (patch_midi: None) -> None: + composition = _make(seed=42) - """Building a pending pattern at any time deals its seeded stream (live-add fix).""" + def late(p) -> None: + pass - composition = _make(seed=42) + pattern = composition._build_pattern_from_pending(_pending(late)) - def late (p) -> None: - pass + assert pattern._rng is not None - pattern = composition._build_pattern_from_pending(_pending(late)) + # The pattern's dealt stream draws the name-keyed sequence for "late" — + # and NOT the stream a different name would have been dealt. + draw = pattern._rng.random() - assert pattern._rng is not None + assert draw == random.Random(zlib.crc32(b"42:late")).random() + assert draw != random.Random(zlib.crc32(b"42:other")).random() - # The pattern's dealt stream draws the name-keyed sequence for "late" — - # and NOT the stream a different name would have been dealt. - draw = pattern._rng.random() - assert draw == random.Random(zlib.crc32(b"42:late")).random() - assert draw != random.Random(zlib.crc32(b"42:other")).random() +def test_unseeded_pattern_stream_is_none(patch_midi: None) -> None: + """No composition seed → patterns keep unseeded randomness (existing behaviour).""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) -def test_unseeded_pattern_stream_is_none (patch_midi: None) -> None: + def free(p) -> None: + pass - """No composition seed → patterns keep unseeded randomness (existing behaviour).""" + pattern = composition._build_pattern_from_pending(_pending(free)) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + assert pattern._rng is None - def free (p) -> None: - pass - pattern = composition._build_pattern_from_pending(_pending(free)) +def test_duplicate_pattern_name_warns( + patch_midi: None, caplog: pytest.LogCaptureFixture +) -> None: + """Registering two patterns with one name warns at registration.""" - assert pattern._rng is None + composition = _make() + def clash(p) -> None: + pass -def test_duplicate_pattern_name_warns (patch_midi: None, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + composition.pattern(channel=1, beats=4)(clash) + composition.pattern(channel=2, beats=4)(clash) - """Registering two patterns with one name warns at registration.""" - - composition = _make() - - def clash (p) -> None: - pass - - with caplog.at_level(logging.WARNING): - composition.pattern(channel=1, beats=4)(clash) - composition.pattern(channel=2, beats=4)(clash) - - assert any("Duplicate pattern name 'clash'" in record.message for record in caplog.records) + assert any( + "Duplicate pattern name 'clash'" in record.message for record in caplog.records + ) # ── reroll / lock / unlock ────────────────────────────────────────────────── -def test_reroll_bumps_nonce_and_prints_effective_seed (patch_midi: None, capsys: pytest.CaptureFixture) -> None: - - """reroll() changes the derived seed and prints the new effective value.""" - - composition = _make(seed=42) - original = composition.seed_for("lead") - - composition.reroll("lead") - bumped = composition.seed_for("lead") - assert bumped != original - assert bumped == zlib.crc32(b"42:lead:1") - assert f"effective seed {bumped}" in capsys.readouterr().out +def test_reroll_bumps_nonce_and_prints_effective_seed( + patch_midi: None, capsys: pytest.CaptureFixture +) -> None: + """reroll() changes the derived seed and prints the new effective value.""" - composition.reroll("lead") - assert composition.seed_for("lead") == zlib.crc32(b"42:lead:2") + composition = _make(seed=42) + original = composition.seed_for("lead") + composition.reroll("lead") + bumped = composition.seed_for("lead") -def test_reroll_unseeded_explains (patch_midi: None, capsys: pytest.CaptureFixture) -> None: + assert bumped != original + assert bumped == zlib.crc32(b"42:lead:1") + assert f"effective seed {bumped}" in capsys.readouterr().out - """reroll() on an unseeded composition says so instead of pretending.""" + composition.reroll("lead") + assert composition.seed_for("lead") == zlib.crc32(b"42:lead:2") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition.reroll("lead") - assert "no seed" in capsys.readouterr().out +def test_reroll_unseeded_explains( + patch_midi: None, capsys: pytest.CaptureFixture +) -> None: + """reroll() on an unseeded composition says so instead of pretending.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition.reroll("lead") -def test_lock_refuses_reroll_until_unlock (patch_midi: None, capsys: pytest.CaptureFixture) -> None: + assert "no seed" in capsys.readouterr().out - """A locked name keeps its effective seed; unlock() restores reroll().""" - composition = _make(seed=42) - composition.lock("lead") +def test_lock_refuses_reroll_until_unlock( + patch_midi: None, capsys: pytest.CaptureFixture +) -> None: + """A locked name keeps its effective seed; unlock() restores reroll().""" - before = composition.seed_for("lead") - composition.reroll("lead") + composition = _make(seed=42) + composition.lock("lead") - assert composition.seed_for("lead") == before - assert "refused" in capsys.readouterr().out + before = composition.seed_for("lead") + composition.reroll("lead") - composition.unlock("lead") - composition.reroll("lead") + assert composition.seed_for("lead") == before + assert "refused" in capsys.readouterr().out - assert composition.seed_for("lead") != before + composition.unlock("lead") + composition.reroll("lead") + assert composition.seed_for("lead") != before -def test_locked_pattern_realizes_identically_each_rebuild (patch_midi: None) -> None: - """lock() re-deals the stream per rebuild: every cycle realizes the same.""" +def test_locked_pattern_realizes_identically_each_rebuild(patch_midi: None) -> None: + """lock() re-deals the stream per rebuild: every cycle realizes the same.""" - composition = _make(seed=42) - draws = [] + composition = _make(seed=42) + draws = [] - def wobble (p) -> None: - draws.append(p.rng.random()) + def wobble(p) -> None: + draws.append(p.rng.random()) - pattern = composition._build_pattern_from_pending(_pending(wobble)) + pattern = composition._build_pattern_from_pending(_pending(wobble)) - pattern._rebuild() - assert draws[0] != draws[1] # free-running: the stream continues across rebuilds + pattern._rebuild() + assert draws[0] != draws[1] # free-running: the stream continues across rebuilds - composition.lock("wobble") - pattern._rebuild() - pattern._rebuild() + composition.lock("wobble") + pattern._rebuild() + pattern._rebuild() - assert draws[2] == draws[3] # locked: identical realization every rebuild - assert draws[2] == random.Random(composition.seed_for("wobble")).random() + assert draws[2] == draws[3] # locked: identical realization every rebuild + assert draws[2] == random.Random(composition.seed_for("wobble")).random() diff --git a/tests/test_display.py b/tests/test_display.py index dc3e15d..fbcec10 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -10,361 +10,361 @@ import subsequence.pattern -def _make_composition (patch_midi: None) -> subsequence.Composition: +def _make_composition(patch_midi: None) -> subsequence.Composition: + """Create a minimal composition for display testing.""" - """Create a minimal composition for display testing.""" + return subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") - return subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") +def test_format_status_minimal(patch_midi: None) -> None: + """Status line should show BPM, key, and bar when no form or harmony is configured.""" -def test_format_status_minimal (patch_midi: None) -> None: + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) - """Status line should show BPM, key, and bar when no form or harmony is configured.""" + status = display._format_status() - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) + assert "125.00 BPM" in status + assert "Key: E" in status + assert "Bar: 1.1" in status + assert "Chord" not in status + assert "[" not in status - status = display._format_status() - assert "125.00 BPM" in status - assert "Key: E" in status - assert "Bar: 1.1" in status - assert "Chord" not in status - assert "[" not in status +def test_format_status_with_harmony(patch_midi: None) -> None: + """Status line should include the current chord when harmony is configured.""" + comp = _make_composition(patch_midi) + comp.harmony(style="aeolian_minor", cycle_beats=4) -def test_format_status_with_harmony (patch_midi: None) -> None: + display = subsequence.display.Display(comp) + status = display._format_status() - """Status line should include the current chord when harmony is configured.""" + assert "125.00 BPM" in status + assert "Chord:" in status - comp = _make_composition(patch_midi) - comp.harmony(style="aeolian_minor", cycle_beats=4) - display = subsequence.display.Display(comp) - status = display._format_status() +def test_format_status_with_form(patch_midi: None) -> None: + """Status line should include section info when form is configured.""" - assert "125.00 BPM" in status - assert "Chord:" in status + comp = _make_composition(patch_midi) + comp.form( + { + "intro": (4, [("verse", 1)]), + "verse": (8, []), + }, + start="intro", + ) -def test_format_status_with_form (patch_midi: None) -> None: + display = subsequence.display.Display(comp) + status = display._format_status() - """Status line should include section info when form is configured.""" + assert "[intro 1/4 \u2192 verse]" in status - comp = _make_composition(patch_midi) - comp.form({ - "intro": (4, [("verse", 1)]), - "verse": (8, []), - }, start="intro") +def test_format_status_full(patch_midi: None) -> None: + """Status line should include all components when form and harmony are configured.""" - display = subsequence.display.Display(comp) - status = display._format_status() + comp = _make_composition(patch_midi) + comp.harmony(style="aeolian_minor", cycle_beats=4) - assert "[intro 1/4 \u2192 verse]" in status + comp.form( + { + "intro": (4, [("verse", 1)]), + "verse": (8, []), + }, + start="intro", + ) + display = subsequence.display.Display(comp) + status = display._format_status() -def test_format_status_full (patch_midi: None) -> None: + assert "125.00 BPM" in status + assert "Key: E" in status + assert "Bar: 1.1" in status + assert "[intro 1/4 \u2192 verse]" in status + assert "Chord:" in status - """Status line should include all components when form and harmony are configured.""" - comp = _make_composition(patch_midi) - comp.harmony(style="aeolian_minor", cycle_beats=4) +def test_format_status_no_key(patch_midi: None) -> None: + """Status line should omit key when none is configured.""" - comp.form({ - "intro": (4, [("verse", 1)]), - "verse": (8, []), - }, start="intro") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + display = subsequence.display.Display(comp) - display = subsequence.display.Display(comp) - status = display._format_status() + status = display._format_status() - assert "125.00 BPM" in status - assert "Key: E" in status - assert "Bar: 1.1" in status - assert "[intro 1/4 \u2192 verse]" in status - assert "Chord:" in status + assert "120.00 BPM" in status + assert "Key:" not in status -def test_format_status_no_key (patch_midi: None) -> None: +def test_draw_writes_to_stderr(patch_midi: None) -> None: + """draw() should write the status line with ANSI clear codes.""" - """Status line should omit key when none is configured.""" + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) + display._active = True + display._last_line = "test status" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - display = subsequence.display.Display(comp) + stream = io.StringIO() - status = display._format_status() + # Temporarily redirect stderr to capture output. + import sys - assert "120.00 BPM" in status - assert "Key:" not in status + original_stderr = sys.stderr + sys.stderr = stream + try: + display.draw() + finally: + sys.stderr = original_stderr -def test_draw_writes_to_stderr (patch_midi: None) -> None: + output = stream.getvalue() - """draw() should write the status line with ANSI clear codes.""" + assert "\r\033[K" in output + assert "test status" in output - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) - display._active = True - display._last_line = "test status" - stream = io.StringIO() +def test_clear_line_writes_ansi(patch_midi: None) -> None: + """clear_line() should write carriage return and clear-to-end-of-line.""" - # Temporarily redirect stderr to capture output. - import sys - original_stderr = sys.stderr - sys.stderr = stream + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) + display._active = True - try: - display.draw() - finally: - sys.stderr = original_stderr + stream = io.StringIO() - output = stream.getvalue() + import sys - assert "\r\033[K" in output - assert "test status" in output + original_stderr = sys.stderr + sys.stderr = stream + try: + display.clear_line() + finally: + sys.stderr = original_stderr -def test_clear_line_writes_ansi (patch_midi: None) -> None: + assert stream.getvalue() == "\r\033[K" - """clear_line() should write carriage return and clear-to-end-of-line.""" - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) - display._active = True +def test_update_rebuilds_status(patch_midi: None) -> None: + """update() should refresh the stored status line.""" - stream = io.StringIO() + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) + display._active = True - import sys - original_stderr = sys.stderr - sys.stderr = stream + # Redirect stderr to avoid terminal noise during tests. + import sys - try: - display.clear_line() - finally: - sys.stderr = original_stderr + original_stderr = sys.stderr + sys.stderr = io.StringIO() - assert stream.getvalue() == "\r\033[K" + try: + display.update(0) + finally: + sys.stderr = original_stderr + assert "125.00 BPM" in display._last_line -def test_update_rebuilds_status (patch_midi: None) -> None: - """update() should refresh the stored status line.""" +def test_update_inactive_is_noop(patch_midi: None) -> None: + """update() should do nothing when the display is not active.""" - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) - display._active = True + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) - # Redirect stderr to avoid terminal noise during tests. - import sys - original_stderr = sys.stderr - sys.stderr = io.StringIO() + display.update(0) - try: - display.update(0) - finally: - sys.stderr = original_stderr + assert display._last_line == "" - assert "125.00 BPM" in display._last_line +def test_start_installs_handler(patch_midi: None) -> None: + """start() should replace root logger handlers with DisplayLogHandler.""" -def test_update_inactive_is_noop (patch_midi: None) -> None: + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) - """update() should do nothing when the display is not active.""" + root_logger = logging.getLogger() + original_handlers = list(root_logger.handlers) - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) + display.start() - display.update(0) + try: + assert len(root_logger.handlers) == 1 + assert isinstance( + root_logger.handlers[0], subsequence.display.DisplayLogHandler + ) + finally: + display.stop() - assert display._last_line == "" +def test_stop_restores_handlers(patch_midi: None) -> None: + """stop() should restore the original root logger handlers.""" -def test_start_installs_handler (patch_midi: None) -> None: + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) - """start() should replace root logger handlers with DisplayLogHandler.""" + root_logger = logging.getLogger() + original_handlers = list(root_logger.handlers) - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) + display.start() + display.stop() - root_logger = logging.getLogger() - original_handlers = list(root_logger.handlers) + assert root_logger.handlers == original_handlers - display.start() - try: - assert len(root_logger.handlers) == 1 - assert isinstance(root_logger.handlers[0], subsequence.display.DisplayLogHandler) - finally: - display.stop() +def test_log_handler_clears_and_redraws(patch_midi: None) -> None: + """DisplayLogHandler should clear the status line before logging and redraw after.""" + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) + display._active = True + display._last_line = "test status" -def test_stop_restores_handlers (patch_midi: None) -> None: + stream = io.StringIO() - """stop() should restore the original root logger handlers.""" + import sys - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) + original_stderr = sys.stderr + sys.stderr = stream - root_logger = logging.getLogger() - original_handlers = list(root_logger.handlers) + try: + handler = subsequence.display.DisplayLogHandler(display) + handler.setFormatter(logging.Formatter("%(message)s")) - display.start() - display.stop() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="hello", + args=(), + exc_info=None, + ) - assert root_logger.handlers == original_handlers + handler.emit(record) + finally: + sys.stderr = original_stderr + output = stream.getvalue() -def test_log_handler_clears_and_redraws (patch_midi: None) -> None: + # Should clear, write message, then redraw. + assert output.count("\r\033[K") >= 2 + assert "hello\n" in output + assert "test status" in output - """DisplayLogHandler should clear the status line before logging and redraw after.""" - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) - display._active = True - display._last_line = "test status" +def test_composition_display_creates_display(patch_midi: None) -> None: + """composition.display() should create a Display instance.""" - stream = io.StringIO() + comp = _make_composition(patch_midi) - import sys - original_stderr = sys.stderr - sys.stderr = stream + assert comp._display is None - try: - handler = subsequence.display.DisplayLogHandler(display) - handler.setFormatter(logging.Formatter("%(message)s")) + comp.display() - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="", - lineno=0, - msg="hello", - args=(), - exc_info=None - ) + assert comp._display is not None + assert isinstance(comp._display, subsequence.display.Display) - handler.emit(record) - finally: - sys.stderr = original_stderr - output = stream.getvalue() +def test_composition_display_disable(patch_midi: None) -> None: + """composition.display(enabled=False) should clear the display.""" - # Should clear, write message, then redraw. - assert output.count("\r\033[K") >= 2 - assert "hello\n" in output - assert "test status" in output + comp = _make_composition(patch_midi) + comp.display() + assert comp._display is not None -def test_composition_display_creates_display (patch_midi: None) -> None: + comp.display(enabled=False) - """composition.display() should create a Display instance.""" + assert comp._display is None - comp = _make_composition(patch_midi) - assert comp._display is None +def test_format_status_with_conductor_signals(patch_midi: None) -> None: + """Status line should include conductor signal names and formatted values.""" - comp.display() + comp = _make_composition(patch_midi) + comp.conductor.lfo("swell", shape="triangle", cycle_beats=16.0) + comp.conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=32.0) - assert comp._display is not None - assert isinstance(comp._display, subsequence.display.Display) + display = subsequence.display.Display(comp) + status = display._format_status() + assert "Swell:" in status + assert "Ramp:" in status -def test_composition_display_disable (patch_midi: None) -> None: - """composition.display(enabled=False) should clear the display.""" +def test_format_status_no_conductor_signals(patch_midi: None) -> None: + """Status line should not contain signal info when none are registered.""" - comp = _make_composition(patch_midi) - comp.display() + comp = _make_composition(patch_midi) + display = subsequence.display.Display(comp) + status = display._format_status() - assert comp._display is not None + # Should contain standard parts but no signal formatting. + assert "125.00 BPM" in status + assert ":" not in status.split("Key:")[-1].split("Bar:")[0].strip() - comp.display(enabled=False) - assert comp._display is None +def test_section_display_syncs_with_bar_counter(patch_midi: None) -> None: + """Section info should only update when the bar counter changes. + The form state advances 1 beat early (due to reschedule_lookahead), but the + display should not reflect that until the bar counter also advances. + """ -def test_format_status_with_conductor_signals (patch_midi: None) -> None: + comp = _make_composition(patch_midi) - """Status line should include conductor signal names and formatted values.""" + comp.form( + { + "intro": (4, [("verse", 1)]), + "verse": (8, []), + }, + start="intro", + ) - comp = _make_composition(patch_midi) - comp.conductor.lfo("swell", shape="triangle", cycle_beats=16.0) - comp.conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=32.0) + display = subsequence.display.Display(comp) - display = subsequence.display.Display(comp) - status = display._format_status() + # First render at bar 0 (displayed as "Bar: 1.1") — section should initialise. + status_1 = display._format_status() + assert "[intro 1/4 \u2192 verse]" in status_1 - assert "Swell:" in status - assert "Ramp:" in status + # Simulate what happens when form advances early (lookahead fires before + # the bar counter increments): advance the form state, but leave + # current_bar unchanged. + comp._form_state.advance() + # The form state now says bar 2, but the sequencer bar hasn't changed. + status_2 = display._format_status() + assert "[intro 1/4 \u2192 verse]" in status_2, ( + "Section should NOT update before bar counter changes" + ) -def test_format_status_no_conductor_signals (patch_midi: None) -> None: + # Now simulate the bar counter advancing. + comp._sequencer.current_bar = 1 - """Status line should not contain signal info when none are registered.""" + status_3 = display._format_status() + assert "[intro 2/4 \u2192 verse]" in status_3, ( + "Section should update when bar counter changes" + ) - comp = _make_composition(patch_midi) - display = subsequence.display.Display(comp) - status = display._format_status() - # Should contain standard parts but no signal formatting. - assert "125.00 BPM" in status - assert ":" not in status.split("Key:")[-1].split("Bar:")[0].strip() +def test_conductor_signals_sorted(patch_midi: None) -> None: + """Conductor signals should appear in alphabetical order.""" + comp = _make_composition(patch_midi) + comp.conductor.lfo("zebra", shape="sine", cycle_beats=16.0) + comp.conductor.lfo("alpha", shape="sine", cycle_beats=16.0) -def test_section_display_syncs_with_bar_counter (patch_midi: None) -> None: + display = subsequence.display.Display(comp) + status = display._format_status() - """Section info should only update when the bar counter changes. + alpha_pos = status.index("Alpha:") + zebra_pos = status.index("Zebra:") - The form state advances 1 beat early (due to reschedule_lookahead), but the - display should not reflect that until the bar counter also advances. - """ - - comp = _make_composition(patch_midi) - - comp.form({ - "intro": (4, [("verse", 1)]), - "verse": (8, []), - }, start="intro") - - display = subsequence.display.Display(comp) - - # First render at bar 0 (displayed as "Bar: 1.1") — section should initialise. - status_1 = display._format_status() - assert "[intro 1/4 \u2192 verse]" in status_1 - - # Simulate what happens when form advances early (lookahead fires before - # the bar counter increments): advance the form state, but leave - # current_bar unchanged. - comp._form_state.advance() - - # The form state now says bar 2, but the sequencer bar hasn't changed. - status_2 = display._format_status() - assert "[intro 1/4 \u2192 verse]" in status_2, "Section should NOT update before bar counter changes" - - # Now simulate the bar counter advancing. - comp._sequencer.current_bar = 1 - - status_3 = display._format_status() - assert "[intro 2/4 \u2192 verse]" in status_3, "Section should update when bar counter changes" - - -def test_conductor_signals_sorted (patch_midi: None) -> None: - - """Conductor signals should appear in alphabetical order.""" - - comp = _make_composition(patch_midi) - comp.conductor.lfo("zebra", shape="sine", cycle_beats=16.0) - comp.conductor.lfo("alpha", shape="sine", cycle_beats=16.0) - - display = subsequence.display.Display(comp) - status = display._format_status() - - alpha_pos = status.index("Alpha:") - zebra_pos = status.index("Zebra:") - - assert alpha_pos < zebra_pos + assert alpha_pos < zebra_pos # --------------------------------------------------------------------------- @@ -372,335 +372,336 @@ def test_conductor_signals_sorted (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_grid_velocity_char () -> None: - - """Velocity characters should map to the correct glyphs at all thresholds.""" - - vc = subsequence.display.GridDisplay._velocity_char - assert vc(-1) == ">" - assert vc(0) == "·" - assert vc(1) == "░" - assert vc(31) == "░" - assert vc(32) == "▒" - assert vc(63) == "▒" - assert vc(64) == "▓" - assert vc(95) == "▓" - assert vc(96) == "█" - assert vc(127) == "█" - - -def test_grid_midi_note_name () -> None: +def test_grid_velocity_char() -> None: + """Velocity characters should map to the correct glyphs at all thresholds.""" - """MIDI note name should return correct names for common pitches.""" + vc = subsequence.display.GridDisplay._velocity_char + assert vc(-1) == ">" + assert vc(0) == "·" + assert vc(1) == "░" + assert vc(31) == "░" + assert vc(32) == "▒" + assert vc(63) == "▒" + assert vc(64) == "▓" + assert vc(95) == "▓" + assert vc(96) == "█" + assert vc(127) == "█" - nn = subsequence.display.GridDisplay._midi_note_name - assert nn(60) == "C4" - assert nn(36) == "C2" - assert nn(42) == "F#2" - assert nn(69) == "A4" - assert nn(127) == "G9" +def test_grid_midi_note_name() -> None: + """MIDI note name should return correct names for common pitches.""" -def test_grid_build_empty (patch_midi: None) -> None: + nn = subsequence.display.GridDisplay._midi_note_name + assert nn(60) == "C4" + assert nn(36) == "C2" + assert nn(42) == "F#2" + assert nn(69) == "A4" + assert nn(127) == "G9" - """Grid should produce no lines when no patterns are running.""" - comp = _make_composition(patch_midi) - grid = subsequence.display.GridDisplay(comp) - grid.build() +def test_grid_build_empty(patch_midi: None) -> None: + """Grid should produce no lines when no patterns are running.""" - assert grid._lines == [] - assert grid.line_count == 0 + comp = _make_composition(patch_midi) + grid = subsequence.display.GridDisplay(comp) + grid.build() + assert grid._lines == [] + assert grid.line_count == 0 -def _make_drum_pattern () -> subsequence.pattern.Pattern: - """Create a 4-beat drum pattern with kick and snare hits.""" +def _make_drum_pattern() -> subsequence.pattern.Pattern: + """Create a 4-beat drum pattern with kick and snare hits.""" - pat = subsequence.pattern.Pattern(channel=9, length=4) - # Kick at steps 0 and 8 (pulses 0 and 48 in 96-pulse bar). - pat.add_note(position=0, pitch=36, velocity=120, duration=6) - pat.add_note(position=48, pitch=36, velocity=100, duration=6) - # Snare at step 4 (pulse 24). - pat.add_note(position=24, pitch=38, velocity=90, duration=6) - return pat + pat = subsequence.pattern.Pattern(channel=9, length=4) + # Kick at steps 0 and 8 (pulses 0 and 48 in 96-pulse bar). + pat.add_note(position=0, pitch=36, velocity=120, duration=6) + pat.add_note(position=48, pitch=36, velocity=100, duration=6) + # Snare at step 4 (pulse 24). + pat.add_note(position=24, pitch=38, velocity=90, duration=6) + return pat -def _make_pitched_pattern () -> subsequence.pattern.Pattern: +def _make_pitched_pattern() -> subsequence.pattern.Pattern: + """Create a 4-beat pitched pattern with notes at various steps.""" - """Create a 4-beat pitched pattern with notes at various steps.""" + pat = subsequence.pattern.Pattern(channel=5, length=4) + # E1 at steps 0, 8, 12 (pulses 0, 48, 72). + pat.add_note(position=0, pitch=28, velocity=100, duration=12) + pat.add_note(position=48, pitch=28, velocity=80, duration=12) + pat.add_note(position=72, pitch=28, velocity=60, duration=12) + return pat - pat = subsequence.pattern.Pattern(channel=5, length=4) - # E1 at steps 0, 8, 12 (pulses 0, 48, 72). - pat.add_note(position=0, pitch=28, velocity=100, duration=12) - pat.add_note(position=48, pitch=28, velocity=80, duration=12) - pat.add_note(position=72, pitch=28, velocity=60, duration=12) - return pat +def test_grid_build_drum_pattern( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Drum patterns should produce one row per distinct drum sound.""" -def test_grid_build_drum_pattern (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: + # Ensure terminal width is wide enough for 16 columns. + import os - """Drum patterns should produce one row per distinct drum sound.""" + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - # Ensure terminal width is wide enough for 16 columns. - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + comp = _make_composition(patch_midi) + drum_map = {"kick_1": 36, "snare_1": 38} - comp = _make_composition(patch_midi) - drum_map = {"kick_1": 36, "snare_1": 38} + pat = _make_drum_pattern() + pat._drum_note_map = drum_map # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - pat = _make_drum_pattern() - pat._drum_note_map = drum_map # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp._running_patterns["drums"] = pat - comp._running_patterns["drums"] = pat + grid = subsequence.display.GridDisplay(comp) + grid.build() - grid = subsequence.display.GridDisplay(comp) - grid.build() + assert grid.line_count >= 2 # kick row + snare row - assert grid.line_count >= 2 # kick row + snare row + # Find the kick row and snare row. + kick_line = next(l for l in grid._lines if "kick_1" in l) + snare_line = next(l for l in grid._lines if "snare_1" in l) - # Find the kick row and snare row. - kick_line = next(l for l in grid._lines if "kick_1" in l) - snare_line = next(l for l in grid._lines if "snare_1" in l) + # Kick at step 0 (vel 120 → █) and step 8 (vel 100 → █). + assert "|" in kick_line + parts = kick_line.split("|")[1] # content between pipes + chars = parts.split() + assert chars[0] == "█" # step 0, velocity 120 + assert chars[8] == "█" # step 8, velocity 100 - # Kick at step 0 (vel 120 → █) and step 8 (vel 100 → █). - assert "|" in kick_line - parts = kick_line.split("|")[1] # content between pipes - chars = parts.split() - assert chars[0] == "█" # step 0, velocity 120 - assert chars[8] == "█" # step 8, velocity 100 + # Snare at step 4 (vel 90 → ▓). + parts = snare_line.split("|")[1] + chars = parts.split() + assert chars[4] == "▓" # step 4, velocity 90 - # Snare at step 4 (vel 90 → ▓). - parts = snare_line.split("|")[1] - chars = parts.split() - assert chars[4] == "▓" # step 4, velocity 90 +def test_grid_build_pitched_pattern( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pitched patterns should produce a single summary row.""" -def test_grid_build_pitched_pattern (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: + # Ensure terminal width is wide enough for 16 columns. + import os - """Pitched patterns should produce a single summary row.""" + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - # Ensure terminal width is wide enough for 16 columns. - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + comp = _make_composition(patch_midi) - comp = _make_composition(patch_midi) + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp._running_patterns["bass"] = pat - comp._running_patterns["bass"] = pat + grid = subsequence.display.GridDisplay(comp) + grid.build() - grid = subsequence.display.GridDisplay(comp) - grid.build() + # Pitched pattern: 1 line (name + summary row combined). + assert grid.line_count == 1 - # Pitched pattern: 1 line (name + summary row combined). - assert grid.line_count == 1 + line = grid._lines[0] + assert "bass" in line + assert "|" in line - line = grid._lines[0] - assert "bass" in line - assert "|" in line + # Check velocity chars at known positions. + parts = line.split("|")[1] + chars = parts.split() + assert chars[0] == "█" # step 0, velocity 100 (100/127=78% -> █) + assert chars[8] == "▓" # step 8, velocity 80 (80/127=63% -> ▓) + assert chars[12] == "▒" # step 12, velocity 60 (60/127=47% -> ▒) - # Check velocity chars at known positions. - parts = line.split("|")[1] - chars = parts.split() - assert chars[0] == "█" # step 0, velocity 100 (100/127=78% -> █) - assert chars[8] == "▓" # step 8, velocity 80 (80/127=63% -> ▓) - assert chars[12] == "▒" # step 12, velocity 60 (60/127=47% -> ▒) +def test_grid_build_muted_pattern(patch_midi: None) -> None: + """Muted patterns should show parenthesised name and dashes.""" -def test_grid_build_muted_pattern (patch_midi: None) -> None: + comp = _make_composition(patch_midi) - """Muted patterns should show parenthesised name and dashes.""" + pat = _make_drum_pattern() + pat._drum_note_map = {"kick_1": 36, "snare_1": 38} # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = True # type: ignore[attr-defined] - comp = _make_composition(patch_midi) + comp._running_patterns["drums"] = pat - pat = _make_drum_pattern() - pat._drum_note_map = {"kick_1": 36, "snare_1": 38} # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = True # type: ignore[attr-defined] + grid = subsequence.display.GridDisplay(comp) + grid.build() - comp._running_patterns["drums"] = pat + # Muted: single line with dashes. + assert grid.line_count == 1 - grid = subsequence.display.GridDisplay(comp) - grid.build() + line = grid._lines[0] + assert "(drums)" in line + assert "-" in line + # No velocity chars. + assert "█" not in line + assert "▓" not in line + assert "▒" not in line + assert "░" not in line - # Muted: single line with dashes. - assert grid.line_count == 1 - line = grid._lines[0] - assert "(drums)" in line - assert "-" in line - # No velocity chars. - assert "█" not in line - assert "▓" not in line - assert "▒" not in line - assert "░" not in line +def test_display_grid_flag(patch_midi: None) -> None: + """composition.display(grid=True) should create a Display with a GridDisplay.""" + comp = _make_composition(patch_midi) + comp.display(grid=True) -def test_display_grid_flag (patch_midi: None) -> None: + assert comp._display is not None + assert comp._display._grid is not None + assert isinstance(comp._display._grid, subsequence.display.GridDisplay) - """composition.display(grid=True) should create a Display with a GridDisplay.""" - comp = _make_composition(patch_midi) - comp.display(grid=True) +def test_display_no_grid_by_default(patch_midi: None) -> None: + """composition.display() should not create a GridDisplay by default.""" - assert comp._display is not None - assert comp._display._grid is not None - assert isinstance(comp._display._grid, subsequence.display.GridDisplay) + comp = _make_composition(patch_midi) + comp.display() + assert comp._display is not None + assert comp._display._grid is None -def test_display_no_grid_by_default (patch_midi: None) -> None: - """composition.display() should not create a GridDisplay by default.""" +def test_draw_multiline(patch_midi: None) -> None: + """draw() with grid enabled should write multiple lines with ANSI cursor movement.""" - comp = _make_composition(patch_midi) - comp.display() + comp = _make_composition(patch_midi) - assert comp._display is not None - assert comp._display._grid is None + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] + comp._running_patterns["bass"] = pat + display = subsequence.display.Display(comp, grid=True) + display._active = True + display._last_line = "test status" + display._grid.build() # type: ignore[union-attr] -def test_draw_multiline (patch_midi: None) -> None: + # Simulate a previous draw so drawn_line_count is set. + stream = io.StringIO() + import sys - """draw() with grid enabled should write multiple lines with ANSI cursor movement.""" + original_stderr = sys.stderr + sys.stderr = stream - comp = _make_composition(patch_midi) + try: + display.draw() + # Second draw should include cursor-up escape. + display.draw() + finally: + sys.stderr = original_stderr - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] - comp._running_patterns["bass"] = pat + output = stream.getvalue() - display = subsequence.display.Display(comp, grid=True) - display._active = True - display._last_line = "test status" - display._grid.build() # type: ignore[union-attr] + # Should contain grid content and status line. + assert "bass" in output + assert "test status" in output + # Second draw should have cursor-up ANSI code. + assert "\033[" in output - # Simulate a previous draw so drawn_line_count is set. - stream = io.StringIO() - import sys - original_stderr = sys.stderr - sys.stderr = stream - try: - display.draw() - # Second draw should include cursor-up escape. - display.draw() - finally: - sys.stderr = original_stderr +def test_clear_multiline(patch_midi: None) -> None: + """clear_line() with grid should clear the full multi-line region.""" - output = stream.getvalue() + comp = _make_composition(patch_midi) - # Should contain grid content and status line. - assert "bass" in output - assert "test status" in output - # Second draw should have cursor-up ANSI code. - assert "\033[" in output + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] + comp._running_patterns["bass"] = pat + display = subsequence.display.Display(comp, grid=True) + display._active = True + display._last_line = "test status" + display._grid.build() # type: ignore[union-attr] -def test_clear_multiline (patch_midi: None) -> None: + stream = io.StringIO() + import sys - """clear_line() with grid should clear the full multi-line region.""" + original_stderr = sys.stderr + sys.stderr = stream - comp = _make_composition(patch_midi) + try: + # First draw to set drawn_line_count. + display.draw() - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] - comp._running_patterns["bass"] = pat + # Reset stream to capture only the clear. + stream.truncate(0) + stream.seek(0) - display = subsequence.display.Display(comp, grid=True) - display._active = True - display._last_line = "test status" - display._grid.build() # type: ignore[union-attr] + display.clear_line() + finally: + sys.stderr = original_stderr - stream = io.StringIO() - import sys - original_stderr = sys.stderr - sys.stderr = stream + output = stream.getvalue() - try: - # First draw to set drawn_line_count. - display.draw() + # Should contain cursor-up ANSI code for multi-line clear. + assert "\033[" in output + # Should have cleared multiple lines. + assert output.count("\r\033[K") >= 2 - # Reset stream to capture only the clear. - stream.truncate(0) - stream.seek(0) - display.clear_line() - finally: - sys.stderr = original_stderr +def test_grid_fit_columns() -> None: + """_fit_columns should respect terminal width constraints.""" - output = stream.getvalue() + fit = subsequence.display.GridDisplay._fit_columns - # Should contain cursor-up ANSI code for multi-line clear. - assert "\033[" in output - # Should have cleared multiple lines. - assert output.count("\r\033[K") >= 2 + # Wide terminal: all 16 columns fit. + assert fit(16, 80) == 16 + # Narrow terminal: fewer columns. + # Overhead = 16 (label) + 2 (pipes) = 18. + # Available = 30 - 18 = 12. Max cols = (12 + 1) // 2 = 6. + assert fit(16, 30) == 6 -def test_grid_fit_columns () -> None: + # Very narrow terminal: no columns. + assert fit(16, 16) == 0 - """_fit_columns should respect terminal width constraints.""" - fit = subsequence.display.GridDisplay._fit_columns +def test_grid_update_only_on_bar_change(patch_midi: None) -> None: + """Grid should only rebuild when the bar counter changes.""" - # Wide terminal: all 16 columns fit. - assert fit(16, 80) == 16 + comp = _make_composition(patch_midi) - # Narrow terminal: fewer columns. - # Overhead = 16 (label) + 2 (pipes) = 18. - # Available = 30 - 18 = 12. Max cols = (12 + 1) // 2 = 6. - assert fit(16, 30) == 6 + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] + comp._running_patterns["bass"] = pat - # Very narrow terminal: no columns. - assert fit(16, 16) == 0 + display = subsequence.display.Display(comp, grid=True) + display._active = True + import sys -def test_grid_update_only_on_bar_change (patch_midi: None) -> None: + original_stderr = sys.stderr + sys.stderr = io.StringIO() - """Grid should only rebuild when the bar counter changes.""" + try: + # First update at bar 0. + display.update(0) + first_lines = list(display._grid._lines) # type: ignore[union-attr] + assert len(first_lines) > 0 - comp = _make_composition(patch_midi) + # Second update at same bar — grid should be the same object contents. + display.update(0) + assert display._grid._lines == first_lines # type: ignore[union-attr] - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] - comp._running_patterns["bass"] = pat - - display = subsequence.display.Display(comp, grid=True) - display._active = True - - import sys - original_stderr = sys.stderr - sys.stderr = io.StringIO() - - try: - # First update at bar 0. - display.update(0) - first_lines = list(display._grid._lines) # type: ignore[union-attr] - assert len(first_lines) > 0 - - # Second update at same bar — grid should be the same object contents. - display.update(0) - assert display._grid._lines == first_lines # type: ignore[union-attr] - - # Advance bar counter. - comp._sequencer.current_bar = 1 - display.update(0) - # Grid was rebuilt (contents may be same since pattern didn't change, - # but the rebuild path was exercised). - assert display._last_grid_bar == 1 - finally: - sys.stderr = original_stderr + # Advance bar counter. + comp._sequencer.current_bar = 1 + display.update(0) + # Grid was rebuilt (contents may be same since pattern didn't change, + # but the rebuild path was exercised). + assert display._last_grid_bar == 1 + finally: + sys.stderr = original_stderr # --------------------------------------------------------------------------- @@ -708,123 +709,143 @@ def test_grid_update_only_on_bar_change (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_grid_sustain_single_note (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: +def test_grid_sustain_single_note( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A note spanning multiple slots should produce sustain markers after the attack.""" - """A note spanning multiple slots should produce sustain markers after the attack.""" + import os - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - comp = _make_composition(patch_midi) + comp = _make_composition(patch_midi) - pat = subsequence.pattern.Pattern(channel=5, length=4) - # One note at pulse 0, duration 18 pulses (= 3 slots of 6 pulses each). - pat.add_note(position=0, pitch=60, velocity=100, duration=18) - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + pat = subsequence.pattern.Pattern(channel=5, length=4) + # One note at pulse 0, duration 18 pulses (= 3 slots of 6 pulses each). + pat.add_note(position=0, pitch=60, velocity=100, duration=18) + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - comp._running_patterns["synth"] = pat + comp._running_patterns["synth"] = pat - grid = subsequence.display.GridDisplay(comp) - grid.build() + grid = subsequence.display.GridDisplay(comp) + grid.build() - assert grid.line_count == 1 + assert grid.line_count == 1 - parts = grid._lines[0].split("|")[1] - chars = parts.split() - assert chars[0] == "█" # attack (velocity 100) - assert chars[1] == ">" # sustain - assert chars[2] == ">" # sustain - assert chars[3] == "·" # empty — note ended + parts = grid._lines[0].split("|")[1] + chars = parts.split() + assert chars[0] == "█" # attack (velocity 100) + assert chars[1] == ">" # sustain + assert chars[2] == ">" # sustain + assert chars[3] == "·" # empty — note ended -def test_grid_sustain_short_note (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: +def test_grid_sustain_short_note( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A note shorter than one grid slot should produce no sustain markers.""" - """A note shorter than one grid slot should produce no sustain markers.""" + import os - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - comp = _make_composition(patch_midi) + comp = _make_composition(patch_midi) - pat = subsequence.pattern.Pattern(channel=9, length=4) - # Short drum hit: 3 pulses (half a slot at 6 pps). - pat.add_note(position=0, pitch=36, velocity=120, duration=3) - pat._drum_note_map = {"kick_1": 36} # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + pat = subsequence.pattern.Pattern(channel=9, length=4) + # Short drum hit: 3 pulses (half a slot at 6 pps). + pat.add_note(position=0, pitch=36, velocity=120, duration=3) + pat._drum_note_map = {"kick_1": 36} # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - comp._running_patterns["drums"] = pat + comp._running_patterns["drums"] = pat - grid = subsequence.display.GridDisplay(comp) - grid.build() + grid = subsequence.display.GridDisplay(comp) + grid.build() - # Find the kick row. - kick_line = next(l for l in grid._lines if "kick_1" in l) - parts = kick_line.split("|")[1] - chars = parts.split() - assert chars[0] == "█" # attack - assert chars[1] == "·" # no sustain — note too short + # Find the kick row. + kick_line = next(l for l in grid._lines if "kick_1" in l) + parts = kick_line.split("|")[1] + chars = parts.split() + assert chars[0] == "█" # attack + assert chars[1] == "·" # no sustain — note too short -def test_grid_sustain_does_not_overwrite_attack (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: +def test_grid_sustain_does_not_overwrite_attack( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A sustain marker should not overwrite an attack at the same slot.""" - """A sustain marker should not overwrite an attack at the same slot.""" + import os - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - comp = _make_composition(patch_midi) + comp = _make_composition(patch_midi) - pat = subsequence.pattern.Pattern(channel=5, length=4) - # Note A at pulse 0, sustains through 4 slots (24 pulses). - pat.add_note(position=0, pitch=60, velocity=100, duration=24) - # Note B attacks at pulse 12 (slot 2) — should NOT be overwritten by A's sustain. - pat.add_note(position=12, pitch=60, velocity=80, duration=6) - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + pat = subsequence.pattern.Pattern(channel=5, length=4) + # Note A at pulse 0, sustains through 4 slots (24 pulses). + pat.add_note(position=0, pitch=60, velocity=100, duration=24) + # Note B attacks at pulse 12 (slot 2) — should NOT be overwritten by A's sustain. + pat.add_note(position=12, pitch=60, velocity=80, duration=6) + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - comp._running_patterns["lead"] = pat + comp._running_patterns["lead"] = pat - grid = subsequence.display.GridDisplay(comp) - grid.build() + grid = subsequence.display.GridDisplay(comp) + grid.build() - parts = grid._lines[0].split("|")[1] - chars = parts.split() - assert chars[0] == "█" # Note A attack (vel 100) - assert chars[1] == ">" # Note A sustain - assert chars[2] == "▓" # Note B attack (vel 80) — NOT sustain - assert chars[3] == ">" # Note A still sustaining + parts = grid._lines[0].split("|")[1] + chars = parts.split() + assert chars[0] == "█" # Note A attack (vel 100) + assert chars[1] == ">" # Note A sustain + assert chars[2] == "▓" # Note B attack (vel 80) — NOT sustain + assert chars[3] == ">" # Note A still sustaining -def test_grid_pitched_summary_sustain (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: +def test_grid_pitched_summary_sustain( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pitched summary row should show sustain markers from the collapsed pitches.""" - """Pitched summary row should show sustain markers from the collapsed pitches.""" + import os - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - comp = _make_composition(patch_midi) + comp = _make_composition(patch_midi) - pat = subsequence.pattern.Pattern(channel=5, length=4) - # Legato note spanning 3 slots. - pat.add_note(position=0, pitch=60, velocity=100, duration=18) - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + pat = subsequence.pattern.Pattern(channel=5, length=4) + # Legato note spanning 3 slots. + pat.add_note(position=0, pitch=60, velocity=100, duration=18) + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - comp._running_patterns["bass"] = pat + comp._running_patterns["bass"] = pat - grid = subsequence.display.GridDisplay(comp) - grid.build() + grid = subsequence.display.GridDisplay(comp) + grid.build() - parts = grid._lines[0].split("|")[1] - chars = parts.split() - assert chars[0] == "█" # attack - assert chars[1] == ">" # sustain visible in summary - assert chars[2] == ">" # sustain visible in summary + parts = grid._lines[0].split("|")[1] + chars = parts.split() + assert chars[0] == "█" # attack + assert chars[1] == ">" # sustain visible in summary + assert chars[2] == ">" # sustain visible in summary # --------------------------------------------------------------------------- @@ -832,276 +853,321 @@ def test_grid_pitched_summary_sustain (patch_midi: None, monkeypatch: pytest.Mon # --------------------------------------------------------------------------- -def test_grid_scale_default (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - - """scale=1.0 (default) should produce identical output to unscaled grid.""" +def test_grid_scale_default(patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: + """scale=1.0 (default) should produce identical output to unscaled grid.""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp = _make_composition(patch_midi) - comp._running_patterns["bass"] = pat + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - grid_default = subsequence.display.GridDisplay(comp) - grid_default.build() + comp._running_patterns["bass"] = pat - grid_scale1 = subsequence.display.GridDisplay(comp, scale=1.0) - grid_scale1.build() + grid_default = subsequence.display.GridDisplay(comp) + grid_default.build() - assert grid_default._lines == grid_scale1._lines + grid_scale1 = subsequence.display.GridDisplay(comp, scale=1.0) + grid_scale1.build() + assert grid_default._lines == grid_scale1._lines -def test_grid_scale_doubles_columns (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - """scale=2.0 should produce 32 visual columns for a 16-step pattern.""" +def test_grid_scale_doubles_columns( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """scale=2.0 should produce 32 visual columns for a 16-step pattern.""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((200, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((200, 24)), + ) - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp = _make_composition(patch_midi) - comp._running_patterns["bass"] = pat + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - grid = subsequence.display.GridDisplay(comp, scale=2.0) - grid.build() + comp._running_patterns["bass"] = pat - assert grid.line_count == 1 + grid = subsequence.display.GridDisplay(comp, scale=2.0) + grid.build() - parts = grid._lines[0].split("|")[1] - # Can't use .split() — space cells merge with separators. - # Every other char is a cell (cells separated by single spaces). - individual_cells = list(parts[::2]) + assert grid.line_count == 1 - # 32 visual columns (16 grid steps * cols_per_step=2). - assert len(individual_cells) == 32 + parts = grid._lines[0].split("|")[1] + # Can't use .split() — space cells merge with separators. + # Every other char is a cell (cells separated by single spaces). + individual_cells = list(parts[::2]) + # 32 visual columns (16 grid steps * cols_per_step=2). + assert len(individual_cells) == 32 -def test_grid_scale_on_grid_dot_between_grid_space (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - """Empty on-grid positions should show '.', empty between-grid positions should show ' '.""" +def test_grid_scale_on_grid_dot_between_grid_space( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty on-grid positions should show '.', empty between-grid positions should show ' '.""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((200, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((200, 24)), + ) - # Pattern with a single note at pulse 0 — most positions are empty. - pat = subsequence.pattern.Pattern(channel=5, length=4) - pat.add_note(position=0, pitch=60, velocity=100, duration=3) # short note, no sustain - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp = _make_composition(patch_midi) - comp._running_patterns["lead"] = pat + # Pattern with a single note at pulse 0 — most positions are empty. + pat = subsequence.pattern.Pattern(channel=5, length=4) + pat.add_note( + position=0, pitch=60, velocity=100, duration=3 + ) # short note, no sustain + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - grid = subsequence.display.GridDisplay(comp, scale=2.0) - grid.build() + comp._running_patterns["lead"] = pat - parts = grid._lines[0].split("|")[1] - chars = parts.split() + grid = subsequence.display.GridDisplay(comp, scale=2.0) + grid.build() - # col 0 = on-grid, has attack. - assert chars[0] == "█" - # col 1 = between-grid, empty → space (represented as empty string after split). - # col 2 = on-grid, empty → "·". - assert chars[1] == "·" + parts = grid._lines[0].split("|")[1] + chars = parts.split() - # Verify the raw string has spaces at between-grid empty positions. - # Between col 0 ("O") and col 2 ("."), the raw cells are "O" + " " + " " + " " + ".". - # That's: "O ." — three spaces between them (separator + space-cell + separator). - raw = grid._lines[0].split("|")[1] - # Find a between-grid empty cell: col 1 should be a space character, col 2 should be a dot. - # The raw format is "X Y Z ..." where each cell is separated by a space. - # Cell at position 0 is O, cell at position 1 is space, cell at position 2 is dot. - individual_cells = list(raw[::2]) # every other char (skip separators) - assert individual_cells[0] == "█" - assert individual_cells[1] == " " # between-grid empty - assert individual_cells[2] == "·" # on-grid empty + # col 0 = on-grid, has attack. + assert chars[0] == "█" + # col 1 = between-grid, empty → space (represented as empty string after split). + # col 2 = on-grid, empty → "·". + assert chars[1] == "·" + # Verify the raw string has spaces at between-grid empty positions. + # Between col 0 ("O") and col 2 ("."), the raw cells are "O" + " " + " " + " " + ".". + # That's: "O ." — three spaces between them (separator + space-cell + separator). + raw = grid._lines[0].split("|")[1] + # Find a between-grid empty cell: col 1 should be a space character, col 2 should be a dot. + # The raw format is "X Y Z ..." where each cell is separated by a space. + # Cell at position 0 is O, cell at position 1 is space, cell at position 2 is dot. + individual_cells = list(raw[::2]) # every other char (skip separators) + assert individual_cells[0] == "█" + assert individual_cells[1] == " " # between-grid empty + assert individual_cells[2] == "·" # on-grid empty -def test_grid_scale_sustain_fills_between_grid (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - """Sustain markers should appear at both on-grid and between-grid positions.""" +def test_grid_scale_sustain_fills_between_grid( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Sustain markers should appear at both on-grid and between-grid positions.""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((200, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((200, 24)), + ) - pat = subsequence.pattern.Pattern(channel=5, length=4) - # Note spanning 4 grid slots = 8 visual columns at scale=2. - # At 96 pulses / 16 steps = 6 pulses per step, 4 steps = 24 pulses. - pat.add_note(position=0, pitch=60, velocity=100, duration=24) - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp = _make_composition(patch_midi) - comp._running_patterns["synth"] = pat + pat = subsequence.pattern.Pattern(channel=5, length=4) + # Note spanning 4 grid slots = 8 visual columns at scale=2. + # At 96 pulses / 16 steps = 6 pulses per step, 4 steps = 24 pulses. + pat.add_note(position=0, pitch=60, velocity=100, duration=24) + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - grid = subsequence.display.GridDisplay(comp, scale=2.0) - grid.build() + comp._running_patterns["synth"] = pat - parts = grid._lines[0].split("|")[1] - individual_cells = list(parts[::2]) # every other char (skip separators) + grid = subsequence.display.GridDisplay(comp, scale=2.0) + grid.build() - # Col 0: attack. - assert individual_cells[0] == "█" - # Cols 1-7: sustain (both on-grid and between-grid). - for i in range(1, 8): - assert individual_cells[i] == ">", f"col {i} should be sustain '>', got '{individual_cells[i]}'" - # Col 8: note ended (on-grid empty). - assert individual_cells[8] == "·" + parts = grid._lines[0].split("|")[1] + individual_cells = list(parts[::2]) # every other char (skip separators) + # Col 0: attack. + assert individual_cells[0] == "█" + # Cols 1-7: sustain (both on-grid and between-grid). + for i in range(1, 8): + assert individual_cells[i] == ">", ( + f"col {i} should be sustain '>', got '{individual_cells[i]}'" + ) + # Col 8: note ended (on-grid empty). + assert individual_cells[8] == "·" -def test_grid_scale_muted (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - """Muted pattern at scale=2 should show '-' at on-grid positions, space at between-grid.""" +def test_grid_scale_muted(patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: + """Muted pattern at scale=2 should show '-' at on-grid positions, space at between-grid.""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((200, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((200, 24)), + ) - pat = _make_drum_pattern() - pat._drum_note_map = {"kick_1": 36, "snare_1": 38} # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = True # type: ignore[attr-defined] + comp = _make_composition(patch_midi) - comp._running_patterns["drums"] = pat + pat = _make_drum_pattern() + pat._drum_note_map = {"kick_1": 36, "snare_1": 38} # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = True # type: ignore[attr-defined] - grid = subsequence.display.GridDisplay(comp, scale=2.0) - grid.build() + comp._running_patterns["drums"] = pat - assert grid.line_count == 1 + grid = subsequence.display.GridDisplay(comp, scale=2.0) + grid.build() - parts = grid._lines[0].split("|")[1] - individual_cells = list(parts[::2]) + assert grid.line_count == 1 - # 32 visual columns. On-grid at even indices: '-', between-grid at odd: ' '. - for i in range(32): - if i % 2 == 0: - assert individual_cells[i] == "-", f"on-grid col {i} should be '-'" - else: - assert individual_cells[i] == " ", f"between-grid col {i} should be ' '" + parts = grid._lines[0].split("|")[1] + individual_cells = list(parts[::2]) + # 32 visual columns. On-grid at even indices: '-', between-grid at odd: ' '. + for i in range(32): + if i % 2 == 0: + assert individual_cells[i] == "-", f"on-grid col {i} should be '-'" + else: + assert individual_cells[i] == " ", f"between-grid col {i} should be ' '" -def test_grid_scale_float_snaps (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - """Non-integer scale values should snap to the nearest integer cols_per_step.""" +def test_grid_scale_float_snaps( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Non-integer scale values should snap to the nearest integer cols_per_step.""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((200, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((200, 24)), + ) - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp = _make_composition(patch_midi) - comp._running_patterns["bass"] = pat + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - # scale=1.5 → round(1.5) = 2 → cols_per_step=2 → 32 visual columns. - grid_15 = subsequence.display.GridDisplay(comp, scale=1.5) - grid_15.build() - parts_15 = grid_15._lines[0].split("|")[1] - assert len(list(parts_15[::2])) == 32 + comp._running_patterns["bass"] = pat - # scale=2.6 → round(2.6) = 3 → cols_per_step=3 → 48 visual columns. - grid_26 = subsequence.display.GridDisplay(comp, scale=2.6) - grid_26.build() - parts_26 = grid_26._lines[0].split("|")[1] - assert len(list(parts_26[::2])) == 48 + # scale=1.5 → round(1.5) = 2 → cols_per_step=2 → 32 visual columns. + grid_15 = subsequence.display.GridDisplay(comp, scale=1.5) + grid_15.build() + parts_15 = grid_15._lines[0].split("|")[1] + assert len(list(parts_15[::2])) == 32 + # scale=2.6 → round(2.6) = 3 → cols_per_step=3 → 48 visual columns. + grid_26 = subsequence.display.GridDisplay(comp, scale=2.6) + grid_26.build() + parts_26 = grid_26._lines[0].split("|")[1] + assert len(list(parts_26[::2])) == 48 -def test_grid_scale_below_one (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - """Scale values below 1.0 should clamp to cols_per_step=1 (no change).""" +def test_grid_scale_below_one( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scale values below 1.0 should clamp to cols_per_step=1 (no change).""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((120, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((120, 24)), + ) - pat = _make_pitched_pattern() - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + comp = _make_composition(patch_midi) - comp._running_patterns["bass"] = pat + pat = _make_pitched_pattern() + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - grid_default = subsequence.display.GridDisplay(comp, scale=1.0) - grid_default.build() + comp._running_patterns["bass"] = pat - grid_low = subsequence.display.GridDisplay(comp, scale=0.5) - grid_low.build() + grid_default = subsequence.display.GridDisplay(comp, scale=1.0) + grid_default.build() - assert grid_default._lines == grid_low._lines + grid_low = subsequence.display.GridDisplay(comp, scale=0.5) + grid_low.build() + assert grid_default._lines == grid_low._lines -def test_grid_scale_uniform_spacing (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - """The rendered row marks on-grid cells exactly cols_per_step apart, and notes land on them.""" +def test_grid_scale_uniform_spacing( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """The rendered row marks on-grid cells exactly cols_per_step apart, and notes land on them.""" - import os - monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(80, 24): os.terminal_size((200, 24))) + import os - comp = _make_composition(patch_midi) + monkeypatch.setattr( + "shutil.get_terminal_size", + lambda fallback=(80, 24): os.terminal_size((200, 24)), + ) - # Notes at steps 0, 8, 12 with 1-pulse durations, so no ">" sustain - # markers can appear in the between-grid cells. - pat = subsequence.pattern.Pattern(channel=5, length=4) - pat.add_note(position=0, pitch=28, velocity=100, duration=1) - pat.add_note(position=48, pitch=28, velocity=80, duration=1) - pat.add_note(position=72, pitch=28, velocity=60, duration=1) + comp = _make_composition(patch_midi) - pat._drum_note_map = None # type: ignore[attr-defined] - pat._default_grid = 16 # type: ignore[attr-defined] - pat._muted = False # type: ignore[attr-defined] + # Notes at steps 0, 8, 12 with 1-pulse durations, so no ">" sustain + # markers can appear in the between-grid cells. + pat = subsequence.pattern.Pattern(channel=5, length=4) + pat.add_note(position=0, pitch=28, velocity=100, duration=1) + pat.add_note(position=48, pitch=28, velocity=80, duration=1) + pat.add_note(position=72, pitch=28, velocity=60, duration=1) - comp._running_patterns["bass"] = pat + pat._drum_note_map = None # type: ignore[attr-defined] + pat._default_grid = 16 # type: ignore[attr-defined] + pat._muted = False # type: ignore[attr-defined] - for scale, expected_step in [(1.0, 1), (2.0, 2), (3.0, 3), (1.5, 2), (2.6, 3)]: - grid = subsequence.display.GridDisplay(comp, scale=scale) - grid.build() + comp._running_patterns["bass"] = pat - parts = grid._lines[0].split("|")[1] - individual_cells = list(parts[::2]) + for scale, expected_step in [(1.0, 1), (2.0, 2), (3.0, 3), (1.5, 2), (2.6, 3)]: + grid = subsequence.display.GridDisplay(comp, scale=scale) + grid.build() - # Non-blank cells in the RENDERED output ("·" or a velocity glyph) - # must sit exactly at multiples of cols_per_step; between-grid - # cells must render as spaces. - rendered_markers = [i for i, cell in enumerate(individual_cells) if cell != " "] - expected_markers = [i for i in range(len(individual_cells)) if i % expected_step == 0] + parts = grid._lines[0].split("|")[1] + individual_cells = list(parts[::2]) - assert rendered_markers == expected_markers, ( - f"scale={scale}: expected markers every {expected_step} cells, got {rendered_markers}" - ) + # Non-blank cells in the RENDERED output ("·" or a velocity glyph) + # must sit exactly at multiples of cols_per_step; between-grid + # cells must render as spaces. + rendered_markers = [i for i, cell in enumerate(individual_cells) if cell != " "] + expected_markers = [ + i for i in range(len(individual_cells)) if i % expected_step == 0 + ] - # The three notes scale onto steps 0, 8, and 12 of the zoomed grid. - assert individual_cells[0] == "█", f"scale={scale}" # velocity 100 - assert individual_cells[8 * expected_step] == "▓", f"scale={scale}" # velocity 80 - assert individual_cells[12 * expected_step] == "▒", f"scale={scale}" # velocity 60 + assert rendered_markers == expected_markers, ( + f"scale={scale}: expected markers every {expected_step} cells, got {rendered_markers}" + ) + # The three notes scale onto steps 0, 8, and 12 of the zoomed grid. + assert individual_cells[0] == "█", f"scale={scale}" # velocity 100 + assert individual_cells[8 * expected_step] == "▓", ( + f"scale={scale}" + ) # velocity 80 + assert individual_cells[12 * expected_step] == "▒", ( + f"scale={scale}" + ) # velocity 60 -def test_composition_display_grid_scale (patch_midi: None) -> None: - """composition.display(grid=True, grid_scale=2.0) should pass scale through to GridDisplay.""" +def test_composition_display_grid_scale(patch_midi: None) -> None: + """composition.display(grid=True, grid_scale=2.0) should pass scale through to GridDisplay.""" - comp = _make_composition(patch_midi) - comp.display(grid=True, grid_scale=2.0) + comp = _make_composition(patch_midi) + comp.display(grid=True, grid_scale=2.0) - assert comp._display is not None - assert comp._display._grid is not None - assert comp._display._grid._scale == 2.0 + assert comp._display is not None + assert comp._display._grid is not None + assert comp._display._grid._scale == 2.0 diff --git a/tests/test_duration.py b/tests/test_duration.py index db23743..192477f 100644 --- a/tests/test_duration.py +++ b/tests/test_duration.py @@ -1,94 +1,87 @@ - import pytest import subsequence.pattern import subsequence.pattern_builder -def test_duration_fixed_value () -> None: - - """Test duration() sets a fixed note length relative to a quarter note.""" - - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - - # Place notes with different durations - builder.note(pitch=60, beat=0.0, duration=1.0) # Long note - builder.note(pitch=62, beat=1.0, duration=0.1) # Short note - - # Apply duration(0.5) -> duration should be 0.5 * 24 = 12 pulses - builder.duration(0.5) - - assert pat.steps[0].notes[0].duration == 12 - assert pat.steps[24].notes[0].duration == 12 - +def test_duration_fixed_value() -> None: + """Test duration() sets a fixed note length relative to a quarter note.""" -def test_duration_short_value () -> None: + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - """Test a very short fixed duration.""" + # Place notes with different durations + builder.note(pitch=60, beat=0.0, duration=1.0) # Long note + builder.note(pitch=62, beat=1.0, duration=0.1) # Short note - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # Apply duration(0.5) -> duration should be 0.5 * 24 = 12 pulses + builder.duration(0.5) - builder.note(60, 0.0) + assert pat.steps[0].notes[0].duration == 12 + assert pat.steps[24].notes[0].duration == 12 - # 0.1 beats -> 2.4 pulses -> truncated to 2 pulses - builder.duration(0.1) - assert pat.steps[0].notes[0].duration == int(24 * 0.1) +def test_duration_short_value() -> None: + """Test a very short fixed duration.""" + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) -def test_duration_long_value () -> None: + builder.note(60, 0.0) - """Test a value > 1.0 (a ringing, tenuto-like length but valid parameter).""" + # 0.1 beats -> 2.4 pulses -> truncated to 2 pulses + builder.duration(0.1) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + assert pat.steps[0].notes[0].duration == int(24 * 0.1) - builder.note(60, 0.0) - # 2.0 beats -> 48 pulses - builder.duration(2.0) +def test_duration_long_value() -> None: + """Test a value > 1.0 (a ringing, tenuto-like length but valid parameter).""" - assert pat.steps[0].notes[0].duration == 48 + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + builder.note(60, 0.0) -def test_duration_minimum_one_pulse () -> None: + # 2.0 beats -> 48 pulses + builder.duration(2.0) - """Ensure duration is at least 1 pulse.""" + assert pat.steps[0].notes[0].duration == 48 - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - builder.note(60, 0.0) +def test_duration_minimum_one_pulse() -> None: + """Ensure duration is at least 1 pulse.""" - # Tiny value - builder.duration(0.0001) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - assert pat.steps[0].notes[0].duration == 1 + builder.note(60, 0.0) + # Tiny value + builder.duration(0.0001) -def test_duration_invalid_value () -> None: + assert pat.steps[0].notes[0].duration == 1 - """Test that negative/zero duration raises ValueError.""" - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) +def test_duration_invalid_value() -> None: + """Test that negative/zero duration raises ValueError.""" - builder.note(60, 0.0) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - with pytest.raises(ValueError): - builder.duration(0) + builder.note(60, 0.0) - with pytest.raises(ValueError): - builder.duration(-0.5) + with pytest.raises(ValueError): + builder.duration(0) + with pytest.raises(ValueError): + builder.duration(-0.5) -def test_staccato_name_removed () -> None: - """The old staccato() name is gone — a hard break, not an alias.""" +def test_staccato_name_removed() -> None: + """The old staccato() name is gone — a hard break, not an alias.""" - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - assert not hasattr(builder, "staccato") + assert not hasattr(builder, "staccato") diff --git a/tests/test_eased_value.py b/tests/test_eased_value.py index 1a8d215..8913083 100644 --- a/tests/test_eased_value.py +++ b/tests/test_eased_value.py @@ -19,174 +19,176 @@ # Construction and initial state # --------------------------------------------------------------------------- -class TestEasedValueInit: - - def test_default_initial (self) -> None: - ev = subsequence.easing.EasedValue() - assert ev.current == 0.0 - assert ev.previous == 0.0 - assert ev.delta == 0.0 - - def test_custom_initial (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.7) - assert ev.current == pytest.approx(0.7) - assert ev.previous == pytest.approx(0.7) - assert ev.delta == pytest.approx(0.0) - def test_get_before_update_returns_initial (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.6) - # prev == current == 0.6, so interpolation always yields 0.6 - assert ev.get(0.0) == pytest.approx(0.6) - assert ev.get(0.5) == pytest.approx(0.6) - assert ev.get(1.0) == pytest.approx(0.6) +class TestEasedValueInit: + def test_default_initial(self) -> None: + ev = subsequence.easing.EasedValue() + assert ev.current == 0.0 + assert ev.previous == 0.0 + assert ev.delta == 0.0 + + def test_custom_initial(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.7) + assert ev.current == pytest.approx(0.7) + assert ev.previous == pytest.approx(0.7) + assert ev.delta == pytest.approx(0.0) + + def test_get_before_update_returns_initial(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.6) + # prev == current == 0.6, so interpolation always yields 0.6 + assert ev.get(0.0) == pytest.approx(0.6) + assert ev.get(0.5) == pytest.approx(0.6) + assert ev.get(1.0) == pytest.approx(0.6) # --------------------------------------------------------------------------- # update() # --------------------------------------------------------------------------- -class TestEasedValueUpdate: - def test_update_without_initial_sets_both (self) -> None: - """If no initial value was provided, the first update sets both prev and current.""" - ev = subsequence.easing.EasedValue() - ev.update(0.8) - assert ev.current == pytest.approx(0.8) - assert ev.previous == pytest.approx(0.8) - - def test_update_with_initial_shifts_previous (self) -> None: - """If an initial value was provided, the first update eases from it.""" - ev = subsequence.easing.EasedValue(initial=0.2) - ev.update(0.8) - assert ev.current == pytest.approx(0.8) - assert ev.previous == pytest.approx(0.2) - - def test_sequential_updates (self) -> None: - ev = subsequence.easing.EasedValue() - ev.update(0.2) # First update sets both to 0.2 - ev.update(1.0) # Second update sets prev=0.2, current=1.0 - assert ev.previous == pytest.approx(0.2) - assert ev.current == pytest.approx(1.0) - - def test_update_to_same_value (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.4) - ev.update(0.4) - assert ev.delta == pytest.approx(0.0) +class TestEasedValueUpdate: + def test_update_without_initial_sets_both(self) -> None: + """If no initial value was provided, the first update sets both prev and current.""" + ev = subsequence.easing.EasedValue() + ev.update(0.8) + assert ev.current == pytest.approx(0.8) + assert ev.previous == pytest.approx(0.8) + + def test_update_with_initial_shifts_previous(self) -> None: + """If an initial value was provided, the first update eases from it.""" + ev = subsequence.easing.EasedValue(initial=0.2) + ev.update(0.8) + assert ev.current == pytest.approx(0.8) + assert ev.previous == pytest.approx(0.2) + + def test_sequential_updates(self) -> None: + ev = subsequence.easing.EasedValue() + ev.update(0.2) # First update sets both to 0.2 + ev.update(1.0) # Second update sets prev=0.2, current=1.0 + assert ev.previous == pytest.approx(0.2) + assert ev.current == pytest.approx(1.0) + + def test_update_to_same_value(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.4) + ev.update(0.4) + assert ev.delta == pytest.approx(0.0) # --------------------------------------------------------------------------- # get() — interpolation correctness # --------------------------------------------------------------------------- -class TestEasedValueGet: - - def setup_method (self) -> None: - self.ev = subsequence.easing.EasedValue(initial=0.0) - self.ev.update(1.0) # prev=0.0, current=1.0 - - def test_get_at_zero_returns_previous (self) -> None: - assert self.ev.get(0.0) == pytest.approx(0.0) - def test_get_at_one_returns_current (self) -> None: - assert self.ev.get(1.0) == pytest.approx(1.0) - - def test_get_at_midpoint_uses_easing (self) -> None: - # Default shape is ease_in_out (Hermite smoothstep). - # subsequence.easing.ease_in_out(0.5) = 0.5^2 * (3 - 2*0.5) = 0.25 * 2.0 = 0.5 - result = self.ev.get(0.5) - expected = subsequence.easing.ease_in_out(0.5) # = 0.5 for smoothstep at midpoint - assert result == pytest.approx(expected) - - def test_get_interpolates_range (self) -> None: - prev_result = self.ev.get(0.0) - mid_result = self.ev.get(0.5) - current_result = self.ev.get(1.0) - # Values must be monotonically non-decreasing for a rising transition. - assert prev_result <= mid_result <= current_result - - def test_get_downward_transition (self) -> None: - ev = subsequence.easing.EasedValue(initial=1.0) - ev.update(0.0) # prev=1.0, current=0.0 - assert ev.get(0.0) == pytest.approx(1.0) - assert ev.get(1.0) == pytest.approx(0.0) - assert ev.get(0.5) < ev.get(0.0) # monotone decrease +class TestEasedValueGet: + def setup_method(self) -> None: + self.ev = subsequence.easing.EasedValue(initial=0.0) + self.ev.update(1.0) # prev=0.0, current=1.0 + + def test_get_at_zero_returns_previous(self) -> None: + assert self.ev.get(0.0) == pytest.approx(0.0) + + def test_get_at_one_returns_current(self) -> None: + assert self.ev.get(1.0) == pytest.approx(1.0) + + def test_get_at_midpoint_uses_easing(self) -> None: + # Default shape is ease_in_out (Hermite smoothstep). + # subsequence.easing.ease_in_out(0.5) = 0.5^2 * (3 - 2*0.5) = 0.25 * 2.0 = 0.5 + result = self.ev.get(0.5) + expected = subsequence.easing.ease_in_out( + 0.5 + ) # = 0.5 for smoothstep at midpoint + assert result == pytest.approx(expected) + + def test_get_interpolates_range(self) -> None: + prev_result = self.ev.get(0.0) + mid_result = self.ev.get(0.5) + current_result = self.ev.get(1.0) + # Values must be monotonically non-decreasing for a rising transition. + assert prev_result <= mid_result <= current_result + + def test_get_downward_transition(self) -> None: + ev = subsequence.easing.EasedValue(initial=1.0) + ev.update(0.0) # prev=1.0, current=0.0 + assert ev.get(0.0) == pytest.approx(1.0) + assert ev.get(1.0) == pytest.approx(0.0) + assert ev.get(0.5) < ev.get(0.0) # monotone decrease # --------------------------------------------------------------------------- # get() — custom shapes # --------------------------------------------------------------------------- -class TestEasedValueGetShape: - def setup_method (self) -> None: - self.ev = subsequence.easing.EasedValue(initial=0.0) - self.ev.update(1.0) +class TestEasedValueGetShape: + def setup_method(self) -> None: + self.ev = subsequence.easing.EasedValue(initial=0.0) + self.ev.update(1.0) - def test_linear_shape (self) -> None: - assert self.ev.get(0.25, shape="linear") == pytest.approx(0.25) - assert self.ev.get(0.75, shape="linear") == pytest.approx(0.75) + def test_linear_shape(self) -> None: + assert self.ev.get(0.25, shape="linear") == pytest.approx(0.25) + assert self.ev.get(0.75, shape="linear") == pytest.approx(0.75) - def test_callable_shape (self) -> None: - # Custom shape: square root (faster early) - result = self.ev.get(0.25, shape=lambda t: t ** 0.5) - assert result == pytest.approx(0.5) # sqrt(0.25) = 0.5 + def test_callable_shape(self) -> None: + # Custom shape: square root (faster early) + result = self.ev.get(0.25, shape=lambda t: t**0.5) + assert result == pytest.approx(0.5) # sqrt(0.25) = 0.5 - def test_unknown_shape_raises (self) -> None: - with pytest.raises(ValueError, match="Unknown easing shape"): - self.ev.get(0.5, shape="not_a_shape") + def test_unknown_shape_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown easing shape"): + self.ev.get(0.5, shape="not_a_shape") - def test_all_named_shapes_work (self) -> None: - for name in subsequence.easing.EASING_FUNCTIONS: - result = self.ev.get(0.5, shape=name) - assert 0.0 <= result <= 1.0, f"Shape {name!r} out of [0,1] range at t=0.5" + def test_all_named_shapes_work(self) -> None: + for name in subsequence.easing.EASING_FUNCTIONS: + result = self.ev.get(0.5, shape=name) + assert 0.0 <= result <= 1.0, f"Shape {name!r} out of [0,1] range at t=0.5" # --------------------------------------------------------------------------- # delta # --------------------------------------------------------------------------- -class TestEasedValueDelta: - def test_delta_zero_initially (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.3) - assert ev.delta == pytest.approx(0.0) - - def test_delta_positive_when_rising (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.2) - ev.update(0.7) - assert ev.delta == pytest.approx(0.5) - - def test_delta_negative_when_falling (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.9) - ev.update(0.4) - assert ev.delta == pytest.approx(-0.5) - - def test_delta_equals_current_minus_previous (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.1) - ev.update(0.6) - assert ev.delta == pytest.approx(ev.current - ev.previous) - - def test_delta_stable_across_gets (self) -> None: - """delta must not change between calls to get() within one cycle.""" - ev = subsequence.easing.EasedValue(initial=0.0) - ev.update(0.8) - d1 = ev.delta - ev.get(0.25) - ev.get(0.5) - ev.get(0.75) - assert ev.delta == pytest.approx(d1) - - def test_delta_updates_after_second_update (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.0) - ev.update(0.6) # delta = 0.6 - ev.update(0.4) # prev=0.6, current=0.4, delta=-0.2 - assert ev.delta == pytest.approx(-0.2) - - def test_delta_direction_sign (self) -> None: - ev = subsequence.easing.EasedValue(initial=0.5) - ev.update(0.9) - assert ev.delta > 0 - - ev2 = subsequence.easing.EasedValue(initial=0.5) - ev2.update(0.1) - assert ev2.delta < 0 +class TestEasedValueDelta: + def test_delta_zero_initially(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.3) + assert ev.delta == pytest.approx(0.0) + + def test_delta_positive_when_rising(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.2) + ev.update(0.7) + assert ev.delta == pytest.approx(0.5) + + def test_delta_negative_when_falling(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.9) + ev.update(0.4) + assert ev.delta == pytest.approx(-0.5) + + def test_delta_equals_current_minus_previous(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.1) + ev.update(0.6) + assert ev.delta == pytest.approx(ev.current - ev.previous) + + def test_delta_stable_across_gets(self) -> None: + """delta must not change between calls to get() within one cycle.""" + ev = subsequence.easing.EasedValue(initial=0.0) + ev.update(0.8) + d1 = ev.delta + ev.get(0.25) + ev.get(0.5) + ev.get(0.75) + assert ev.delta == pytest.approx(d1) + + def test_delta_updates_after_second_update(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.0) + ev.update(0.6) # delta = 0.6 + ev.update(0.4) # prev=0.6, current=0.4, delta=-0.2 + assert ev.delta == pytest.approx(-0.2) + + def test_delta_direction_sign(self) -> None: + ev = subsequence.easing.EasedValue(initial=0.5) + ev.update(0.9) + assert ev.delta > 0 + + ev2 = subsequence.easing.EasedValue(initial=0.5) + ev2.update(0.1) + assert ev2.delta < 0 diff --git a/tests/test_easing.py b/tests/test_easing.py index 916ee66..7951186 100644 --- a/tests/test_easing.py +++ b/tests/test_easing.py @@ -1,4 +1,3 @@ - import pytest import subsequence.easing import subsequence.sequencer @@ -7,296 +6,266 @@ # ─── Core properties of all easing functions ───────────────────────────────── -def test_all_easings_zero_at_zero () -> None: - - """Every easing function returns 0.0 at t=0.""" - - for name, fn in subsequence.easing.EASING_FUNCTIONS.items(): - assert fn(0.0) == pytest.approx(0.0), f"{name}(0) should be 0.0" - +def test_all_easings_zero_at_zero() -> None: + """Every easing function returns 0.0 at t=0.""" -def test_all_easings_one_at_one () -> None: + for name, fn in subsequence.easing.EASING_FUNCTIONS.items(): + assert fn(0.0) == pytest.approx(0.0), f"{name}(0) should be 0.0" - """Every easing function returns 1.0 at t=1.""" - for name, fn in subsequence.easing.EASING_FUNCTIONS.items(): - assert fn(1.0) == pytest.approx(1.0), f"{name}(1) should be 1.0" +def test_all_easings_one_at_one() -> None: + """Every easing function returns 1.0 at t=1.""" + for name, fn in subsequence.easing.EASING_FUNCTIONS.items(): + assert fn(1.0) == pytest.approx(1.0), f"{name}(1) should be 1.0" -def test_all_easings_monotonic () -> None: - """Every easing function is non-decreasing over [0, 1].""" +def test_all_easings_monotonic() -> None: + """Every easing function is non-decreasing over [0, 1].""" - steps = 100 - for name, fn in subsequence.easing.EASING_FUNCTIONS.items(): - values = [fn(i / steps) for i in range(steps + 1)] - for i in range(len(values) - 1): - assert values[i] <= values[i + 1] + 1e-9, ( - f"{name} is not monotonic at t={i/steps:.2f}" - ) + steps = 100 + for name, fn in subsequence.easing.EASING_FUNCTIONS.items(): + values = [fn(i / steps) for i in range(steps + 1)] + for i in range(len(values) - 1): + assert values[i] <= values[i + 1] + 1e-9, ( + f"{name} is not monotonic at t={i / steps:.2f}" + ) # ─── Shape-specific characteristics ────────────────────────────────────────── -def test_linear_is_identity () -> None: - - """linear(t) == t for a range of values.""" - - for t in [0.0, 0.25, 0.5, 0.75, 1.0]: - assert subsequence.easing.linear(t) == pytest.approx(t) - - -def test_ease_in_slow_start () -> None: +def test_linear_is_identity() -> None: + """linear(t) == t for a range of values.""" - """ease_in should be below the diagonal — slow start.""" + for t in [0.0, 0.25, 0.5, 0.75, 1.0]: + assert subsequence.easing.linear(t) == pytest.approx(t) - assert subsequence.easing.ease_in(0.5) < 0.5 +def test_ease_in_slow_start() -> None: + """ease_in should be below the diagonal — slow start.""" -def test_ease_out_fast_start () -> None: + assert subsequence.easing.ease_in(0.5) < 0.5 - """ease_out should be above the diagonal — fast start.""" - assert subsequence.easing.ease_out(0.5) > 0.5 +def test_ease_out_fast_start() -> None: + """ease_out should be above the diagonal — fast start.""" + assert subsequence.easing.ease_out(0.5) > 0.5 -def test_ease_in_out_symmetric () -> None: - """ease_in_out midpoint should equal 0.5.""" +def test_ease_in_out_symmetric() -> None: + """ease_in_out midpoint should equal 0.5.""" - assert subsequence.easing.ease_in_out(0.5) == pytest.approx(0.5) + assert subsequence.easing.ease_in_out(0.5) == pytest.approx(0.5) -def test_s_curve_symmetric () -> None: +def test_s_curve_symmetric() -> None: + """s_curve midpoint should equal 0.5.""" - """s_curve midpoint should equal 0.5.""" + assert subsequence.easing.s_curve(0.5) == pytest.approx(0.5) - assert subsequence.easing.s_curve(0.5) == pytest.approx(0.5) +def test_exponential_slower_than_ease_in() -> None: + """exponential (cubic) should be slower early than ease_in (quadratic).""" -def test_exponential_slower_than_ease_in () -> None: + assert subsequence.easing.exponential(0.5) < subsequence.easing.ease_in(0.5) - """exponential (cubic) should be slower early than ease_in (quadratic).""" - assert subsequence.easing.exponential(0.5) < subsequence.easing.ease_in(0.5) +def test_logarithmic_faster_than_ease_out() -> None: + """logarithmic (cubic) should be faster early than ease_out (quadratic).""" + assert subsequence.easing.logarithmic(0.5) > subsequence.easing.ease_out(0.5) -def test_logarithmic_faster_than_ease_out () -> None: - """logarithmic (cubic) should be faster early than ease_out (quadratic).""" +def test_s_curve_smoother_than_ease_in_out() -> None: + """s_curve should have a flatter start than ease_in_out (derivative closer to 0 near t=0).""" - assert subsequence.easing.logarithmic(0.5) > subsequence.easing.ease_out(0.5) - - -def test_s_curve_smoother_than_ease_in_out () -> None: - - """s_curve should have a flatter start than ease_in_out (derivative closer to 0 near t=0).""" - - # Near t=0, s_curve should be below ease_in_out (starts more slowly) - assert subsequence.easing.s_curve(0.1) < subsequence.easing.ease_in_out(0.1) + # Near t=0, s_curve should be below ease_in_out (starts more slowly) + assert subsequence.easing.s_curve(0.1) < subsequence.easing.ease_in_out(0.1) # ─── get_easing ─────────────────────────────────────────────────────────────── -def test_get_easing_by_string () -> None: - - """get_easing returns the correct function for a valid name.""" - - fn = subsequence.easing.get_easing("linear") - assert fn is subsequence.easing.linear - - -def test_get_easing_all_names () -> None: +def test_get_easing_by_string() -> None: + """get_easing returns the correct function for a valid name.""" - """get_easing works for every registered name.""" + fn = subsequence.easing.get_easing("linear") + assert fn is subsequence.easing.linear - for name, expected in subsequence.easing.EASING_FUNCTIONS.items(): - assert subsequence.easing.get_easing(name) is expected +def test_get_easing_all_names() -> None: + """get_easing works for every registered name.""" -def test_get_easing_callable_passthrough () -> None: + for name, expected in subsequence.easing.EASING_FUNCTIONS.items(): + assert subsequence.easing.get_easing(name) is expected - """get_easing returns the callable unchanged when passed a function.""" - custom = lambda t: t ** 0.5 - assert subsequence.easing.get_easing(custom) is custom +def test_get_easing_callable_passthrough() -> None: + """get_easing returns the callable unchanged when passed a function.""" + custom = lambda t: t**0.5 + assert subsequence.easing.get_easing(custom) is custom -def test_get_easing_unknown_raises () -> None: - """get_easing raises ValueError for an unknown string name.""" +def test_get_easing_unknown_raises() -> None: + """get_easing raises ValueError for an unknown string name.""" - with pytest.raises(ValueError, match="Unknown easing shape"): - subsequence.easing.get_easing("bogus_shape") + with pytest.raises(ValueError, match="Unknown easing shape"): + subsequence.easing.get_easing("bogus_shape") # ─── BpmTransition easing integration ──────────────────────────────────────── -def test_bpm_transition_ease_in_out_midpoint_is_linear () -> None: +def test_bpm_transition_ease_in_out_midpoint_is_linear() -> None: + """BpmTransition with ease_in_out: midpoint BPM equals the arithmetic midpoint.""" - """BpmTransition with ease_in_out: midpoint BPM equals the arithmetic midpoint.""" + transition = subsequence.sequencer.BpmTransition( + start_bpm=100.0, + target_bpm=140.0, + total_pulses=100, + easing_fn=subsequence.easing.ease_in_out, + ) - transition = subsequence.sequencer.BpmTransition( - start_bpm = 100.0, - target_bpm = 140.0, - total_pulses = 100, - easing_fn = subsequence.easing.ease_in_out, - ) + # At exactly the midpoint, ease_in_out(0.5) == 0.5, so BPM should be 120 + transition.elapsed_pulses = 50 + progress = transition.elapsed_pulses / transition.total_pulses + eased = transition.easing_fn(progress) + bpm = transition.start_bpm + (transition.target_bpm - transition.start_bpm) * eased - # At exactly the midpoint, ease_in_out(0.5) == 0.5, so BPM should be 120 - transition.elapsed_pulses = 50 - progress = transition.elapsed_pulses / transition.total_pulses - eased = transition.easing_fn(progress) - bpm = transition.start_bpm + (transition.target_bpm - transition.start_bpm) * eased + assert bpm == pytest.approx(120.0) - assert bpm == pytest.approx(120.0) +def test_bpm_transition_ease_in_starts_slowly() -> None: + """BpmTransition with ease_in: BPM at 25% of duration should be well below 25% of range.""" -def test_bpm_transition_ease_in_starts_slowly () -> None: + transition = subsequence.sequencer.BpmTransition( + start_bpm=100.0, + target_bpm=140.0, + total_pulses=100, + easing_fn=subsequence.easing.ease_in, + ) - """BpmTransition with ease_in: BPM at 25% of duration should be well below 25% of range.""" + transition.elapsed_pulses = 25 + progress = transition.elapsed_pulses / transition.total_pulses # 0.25 + eased = transition.easing_fn(progress) # ease_in(0.25) = 0.0625 + bpm = transition.start_bpm + (transition.target_bpm - transition.start_bpm) * eased - transition = subsequence.sequencer.BpmTransition( - start_bpm = 100.0, - target_bpm = 140.0, - total_pulses = 100, - easing_fn = subsequence.easing.ease_in, - ) + # Linear at 25% would be 110; ease_in should be much lower + assert bpm < 110.0 - transition.elapsed_pulses = 25 - progress = transition.elapsed_pulses / transition.total_pulses # 0.25 - eased = transition.easing_fn(progress) # ease_in(0.25) = 0.0625 - bpm = transition.start_bpm + (transition.target_bpm - transition.start_bpm) * eased - # Linear at 25% would be 110; ease_in should be much lower - assert bpm < 110.0 +def test_bpm_transition_default_easing_is_linear() -> None: + """BpmTransition default easing is linear (regression test).""" + transition = subsequence.sequencer.BpmTransition( + start_bpm=100.0, + target_bpm=140.0, + total_pulses=100, + ) -def test_bpm_transition_default_easing_is_linear () -> None: + transition.elapsed_pulses = 50 + progress = transition.elapsed_pulses / transition.total_pulses + eased = transition.easing_fn(progress) + bpm = transition.start_bpm + (transition.target_bpm - transition.start_bpm) * eased - """BpmTransition default easing is linear (regression test).""" - - transition = subsequence.sequencer.BpmTransition( - start_bpm = 100.0, - target_bpm = 140.0, - total_pulses = 100, - ) - - transition.elapsed_pulses = 50 - progress = transition.elapsed_pulses / transition.total_pulses - eased = transition.easing_fn(progress) - bpm = transition.start_bpm + (transition.target_bpm - transition.start_bpm) * eased - - assert bpm == pytest.approx(120.0) # linear midpoint + assert bpm == pytest.approx(120.0) # linear midpoint # ─── map_value ──────────────────────────────────────────────────────────────── -def test_map_value_linear () -> None: - - """map_value should interpolate linearly by default.""" - - # Map 0.5 from [0, 1] to [0, 100] -> 50.0 - val = subsequence.easing.map_value(0.5, 0.0, 1.0, 0.0, 100.0) - assert val == pytest.approx(50.0) - - # Map 5 from [0, 10] to [100, 200] -> 150.0 - val = subsequence.easing.map_value(5, 0, 10, 100, 200) - assert val == pytest.approx(150.0) - - -def test_map_value_clamp () -> None: +def test_map_value_linear() -> None: + """map_value should interpolate linearly by default.""" - """map_value should clamp to output bounds when clamp=True.""" + # Map 0.5 from [0, 1] to [0, 100] -> 50.0 + val = subsequence.easing.map_value(0.5, 0.0, 1.0, 0.0, 100.0) + assert val == pytest.approx(50.0) - # Value exceeds in_max (1.5 > 1.0) - val = subsequence.easing.map_value(1.5, 0.0, 1.0, 0.0, 100.0) - assert val == pytest.approx(100.0) + # Map 5 from [0, 10] to [100, 200] -> 150.0 + val = subsequence.easing.map_value(5, 0, 10, 100, 200) + assert val == pytest.approx(150.0) - # Value below in_min (-0.5 < 0.0) - val = subsequence.easing.map_value(-0.5, 0.0, 1.0, 0.0, 100.0) - assert val == pytest.approx(0.0) +def test_map_value_clamp() -> None: + """map_value should clamp to output bounds when clamp=True.""" -def test_map_value_no_clamp () -> None: + # Value exceeds in_max (1.5 > 1.0) + val = subsequence.easing.map_value(1.5, 0.0, 1.0, 0.0, 100.0) + assert val == pytest.approx(100.0) - """map_value should extrapolate when clamp=False.""" + # Value below in_min (-0.5 < 0.0) + val = subsequence.easing.map_value(-0.5, 0.0, 1.0, 0.0, 100.0) + assert val == pytest.approx(0.0) - val = subsequence.easing.map_value(1.5, 0.0, 1.0, 0.0, 100.0, clamp=False) - assert val == pytest.approx(150.0) +def test_map_value_no_clamp() -> None: + """map_value should extrapolate when clamp=False.""" -def test_map_value_easing_shape () -> None: + val = subsequence.easing.map_value(1.5, 0.0, 1.0, 0.0, 100.0, clamp=False) + assert val == pytest.approx(150.0) - """map_value should apply the requested easing curve.""" - # At 0.5 progress, ease_in is < 0.5. - line_val = subsequence.easing.map_value(0.5, 0.0, 1.0, 0.0, 100.0, shape="linear") - ease_val = subsequence.easing.map_value(0.5, 0.0, 1.0, 0.0, 100.0, shape="ease_in") +def test_map_value_easing_shape() -> None: + """map_value should apply the requested easing curve.""" - assert line_val == pytest.approx(50.0) - assert ease_val < 50.0 + # At 0.5 progress, ease_in is < 0.5. + line_val = subsequence.easing.map_value(0.5, 0.0, 1.0, 0.0, 100.0, shape="linear") + ease_val = subsequence.easing.map_value(0.5, 0.0, 1.0, 0.0, 100.0, shape="ease_in") + assert line_val == pytest.approx(50.0) + assert ease_val < 50.0 -def test_map_value_reversed_range () -> None: - """map_value should handle descending output ranges.""" +def test_map_value_reversed_range() -> None: + """map_value should handle descending output ranges.""" - # Map 0.25 from [0, 1] to [100, 0] -> 75.0 - val = subsequence.easing.map_value(0.25, 0.0, 1.0, 100.0, 0.0) - assert val == pytest.approx(75.0) + # Map 0.25 from [0, 1] to [100, 0] -> 75.0 + val = subsequence.easing.map_value(0.25, 0.0, 1.0, 100.0, 0.0) + assert val == pytest.approx(75.0) -def test_map_value_zero_range_safeguard () -> None: +def test_map_value_zero_range_safeguard() -> None: + """map_value should handle in_min == in_max safely.""" - """map_value should handle in_min == in_max safely.""" - - val = subsequence.easing.map_value(0.5, 0.0, 0.0, 0.0, 100.0) - assert val == pytest.approx(0.0) + val = subsequence.easing.map_value(0.5, 0.0, 0.0, 0.0, 100.0) + assert val == pytest.approx(0.0) # ─── ramp ───────────────────────────────────────────────────────────────────── -def test_ramp_linear_three_steps () -> None: - - """ramp with linear shape produces evenly spaced values.""" - - result = subsequence.easing.ramp(3, 0.0, 1.0, "linear") - assert result == pytest.approx([0.0, 0.5, 1.0]) - - -def test_ramp_bounds () -> None: - - """ramp first and last values match low and high exactly.""" - - result = subsequence.easing.ramp(8, 50.0, 100.0, "ease_in_out") - assert result[0] == pytest.approx(50.0) - assert result[-1] == pytest.approx(100.0) +def test_ramp_linear_three_steps() -> None: + """ramp with linear shape produces evenly spaced values.""" + result = subsequence.easing.ramp(3, 0.0, 1.0, "linear") + assert result == pytest.approx([0.0, 0.5, 1.0]) -def test_ramp_single_step () -> None: - """ramp(1, ...) returns [low] regardless of shape.""" +def test_ramp_bounds() -> None: + """ramp first and last values match low and high exactly.""" - assert subsequence.easing.ramp(1, 42.0, 99.0) == pytest.approx([42.0]) + result = subsequence.easing.ramp(8, 50.0, 100.0, "ease_in_out") + assert result[0] == pytest.approx(50.0) + assert result[-1] == pytest.approx(100.0) -def test_ramp_monotonic_ease_in () -> None: +def test_ramp_single_step() -> None: + """ramp(1, ...) returns [low] regardless of shape.""" - """ramp with ease_in shape produces monotonically non-decreasing values.""" + assert subsequence.easing.ramp(1, 42.0, 99.0) == pytest.approx([42.0]) - result = subsequence.easing.ramp(8, 0.0, 1.0, "ease_in") - assert all(result[i] <= result[i + 1] for i in range(len(result) - 1)) +def test_ramp_monotonic_ease_in() -> None: + """ramp with ease_in shape produces monotonically non-decreasing values.""" -def test_ramp_length () -> None: + result = subsequence.easing.ramp(8, 0.0, 1.0, "ease_in") + assert all(result[i] <= result[i + 1] for i in range(len(result) - 1)) - """ramp returns a list of exactly n values.""" - assert len(subsequence.easing.ramp(16, 20.0, 80.0)) == 16 +def test_ramp_length() -> None: + """ramp returns a list of exactly n values.""" + assert len(subsequence.easing.ramp(16, 20.0, 80.0)) == 16 diff --git a/tests/test_event_emitter.py b/tests/test_event_emitter.py index 2c02840..1c39dab 100644 --- a/tests/test_event_emitter.py +++ b/tests/test_event_emitter.py @@ -1,151 +1,142 @@ - import pytest import subsequence.event_emitter -def test_on_and_emit_sync () -> None: - - """Registered sync callbacks are called on emit_sync.""" - - emitter = subsequence.event_emitter.EventEmitter() - received: list[int] = [] - - emitter.on("tick", lambda v: received.append(v)) - emitter.emit_sync("tick", 42) - - assert received == [42] +def test_on_and_emit_sync() -> None: + """Registered sync callbacks are called on emit_sync.""" + emitter = subsequence.event_emitter.EventEmitter() + received: list[int] = [] -def test_off_removes_callback () -> None: + emitter.on("tick", lambda v: received.append(v)) + emitter.emit_sync("tick", 42) - """off() prevents a previously registered callback from being called.""" + assert received == [42] - emitter = subsequence.event_emitter.EventEmitter() - received: list[int] = [] - def cb (v: int) -> None: - received.append(v) +def test_off_removes_callback() -> None: + """off() prevents a previously registered callback from being called.""" - emitter.on("tick", cb) - emitter.off("tick", cb) - emitter.emit_sync("tick", 1) + emitter = subsequence.event_emitter.EventEmitter() + received: list[int] = [] - assert received == [] + def cb(v: int) -> None: + received.append(v) + emitter.on("tick", cb) + emitter.off("tick", cb) + emitter.emit_sync("tick", 1) -def test_off_only_removes_target_callback () -> None: + assert received == [] - """off() leaves other callbacks for the same event intact.""" - emitter = subsequence.event_emitter.EventEmitter() - a: list[int] = [] - b: list[int] = [] +def test_off_only_removes_target_callback() -> None: + """off() leaves other callbacks for the same event intact.""" - def cb_a (v: int) -> None: - a.append(v) + emitter = subsequence.event_emitter.EventEmitter() + a: list[int] = [] + b: list[int] = [] - def cb_b (v: int) -> None: - b.append(v) + def cb_a(v: int) -> None: + a.append(v) - emitter.on("tick", cb_a) - emitter.on("tick", cb_b) - emitter.off("tick", cb_a) - emitter.emit_sync("tick", 7) + def cb_b(v: int) -> None: + b.append(v) - assert a == [] - assert b == [7] + emitter.on("tick", cb_a) + emitter.on("tick", cb_b) + emitter.off("tick", cb_a) + emitter.emit_sync("tick", 7) + assert a == [] + assert b == [7] -def test_off_raises_for_unregistered_callback () -> None: - """off() raises ValueError when the callback was never registered.""" +def test_off_raises_for_unregistered_callback() -> None: + """off() raises ValueError when the callback was never registered.""" - emitter = subsequence.event_emitter.EventEmitter() + emitter = subsequence.event_emitter.EventEmitter() - with pytest.raises(ValueError, match="tick"): - emitter.off("tick", lambda: None) + with pytest.raises(ValueError, match="tick"): + emitter.off("tick", lambda: None) -def test_off_raises_after_already_removed () -> None: +def test_off_raises_after_already_removed() -> None: + """off() raises ValueError when called twice for the same callback.""" - """off() raises ValueError when called twice for the same callback.""" + emitter = subsequence.event_emitter.EventEmitter() + received: list[int] = [] - emitter = subsequence.event_emitter.EventEmitter() - received: list[int] = [] + def cb(v: int) -> None: + received.append(v) - def cb (v: int) -> None: - received.append(v) + emitter.on("tick", cb) + emitter.off("tick", cb) - emitter.on("tick", cb) - emitter.off("tick", cb) + with pytest.raises(ValueError): + emitter.off("tick", cb) - with pytest.raises(ValueError): - emitter.off("tick", cb) @pytest.mark.asyncio -async def test_emit_async_isolates_raising_sync_listener () -> None: +async def test_emit_async_isolates_raising_sync_listener() -> None: + """A raising sync listener must not silence later listeners or async tasks.""" - """A raising sync listener must not silence later listeners or async tasks.""" + emitter = subsequence.event_emitter.EventEmitter() + calls = [] - emitter = subsequence.event_emitter.EventEmitter() - calls = [] + def bad() -> None: + raise RuntimeError("boom") - def bad () -> None: - raise RuntimeError("boom") + def good() -> None: + calls.append("sync") - def good () -> None: - calls.append("sync") + async def good_async() -> None: + calls.append("async") - async def good_async () -> None: - calls.append("async") + emitter.on("x", bad) + emitter.on("x", good) + emitter.on("x", good_async) - emitter.on("x", bad) - emitter.on("x", good) - emitter.on("x", good_async) + await emitter.emit_async("x") - await emitter.emit_async("x") - - assert calls == ["sync", "async"] + assert calls == ["sync", "async"] @pytest.mark.asyncio -async def test_emit_async_awaits_async_callable_objects () -> None: - - """An object with async __call__ must be awaited, not silently dropped.""" +async def test_emit_async_awaits_async_callable_objects() -> None: + """An object with async __call__ must be awaited, not silently dropped.""" - emitter = subsequence.event_emitter.EventEmitter() - calls = [] + emitter = subsequence.event_emitter.EventEmitter() + calls = [] - class Handler: + class Handler: + async def __call__(self) -> None: + calls.append("handled") - async def __call__ (self) -> None: - calls.append("handled") + emitter.on("x", Handler()) - emitter.on("x", Handler()) + await emitter.emit_async("x") - await emitter.emit_async("x") - - assert calls == ["handled"] + assert calls == ["handled"] @pytest.mark.asyncio -async def test_emit_async_logs_raising_async_listener () -> None: - - """A raising async listener is logged, never propagated to the emit site.""" +async def test_emit_async_logs_raising_async_listener() -> None: + """A raising async listener is logged, never propagated to the emit site.""" - emitter = subsequence.event_emitter.EventEmitter() - calls = [] + emitter = subsequence.event_emitter.EventEmitter() + calls = [] - async def bad () -> None: - raise RuntimeError("boom") + async def bad() -> None: + raise RuntimeError("boom") - async def good () -> None: - calls.append("ok") + async def good() -> None: + calls.append("ok") - emitter.on("x", bad) - emitter.on("x", good) + emitter.on("x", bad) + emitter.on("x", good) - # Must not raise. - await emitter.emit_async("x") + # Must not raise. + await emitter.emit_async("x") - assert calls == ["ok"] + assert calls == ["ok"] diff --git a/tests/test_form.py b/tests/test_form.py index 1773596..19cfdb7 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -13,768 +13,792 @@ # --- FormState --- -def test_form_state_initial_section () -> None: +def test_form_state_initial_section() -> None: + """FormState should expose the first section immediately after creation.""" - """FormState should expose the first section immediately after creation.""" + form = subsequence.form_state.FormState([("intro", 4), ("verse", 8)]) + section = form.get_section_info() - form = subsequence.form_state.FormState([("intro", 4), ("verse", 8)]) - section = form.get_section_info() + assert section is not None + assert section.name == "intro" + assert section.bar == 0 + assert section.bars == 4 + assert section.index == 0 - assert section is not None - assert section.name == "intro" - assert section.bar == 0 - assert section.bars == 4 - assert section.index == 0 +def test_form_state_advance_within_section() -> None: + """Advancing within a section should increment bar but keep the same name.""" -def test_form_state_advance_within_section () -> None: + form = subsequence.form_state.FormState([("intro", 4), ("verse", 8)]) - """Advancing within a section should increment bar but keep the same name.""" + form.advance() + section = form.get_section_info() - form = subsequence.form_state.FormState([("intro", 4), ("verse", 8)]) + assert section is not None + assert section.name == "intro" + assert section.bar == 1 + assert section.bars == 4 - form.advance() - section = form.get_section_info() + form.advance() + section = form.get_section_info() - assert section is not None - assert section.name == "intro" - assert section.bar == 1 - assert section.bars == 4 + assert section.name == "intro" + assert section.bar == 2 - form.advance() - section = form.get_section_info() - assert section.name == "intro" - assert section.bar == 2 +def test_form_state_advance_to_next_section() -> None: + """Advancing past a section boundary should transition to the next section.""" + form = subsequence.form_state.FormState([("intro", 2), ("verse", 4)]) -def test_form_state_advance_to_next_section () -> None: + # Bar 0 → 1 + form.advance() + assert form.get_section_info().name == "intro" - """Advancing past a section boundary should transition to the next section.""" + # Bar 1 → section complete → transition to verse, bar 0 + form.advance() + section = form.get_section_info() - form = subsequence.form_state.FormState([("intro", 2), ("verse", 4)]) + assert section.name == "verse" + assert section.bar == 0 + assert section.bars == 4 + assert section.index == 1 - # Bar 0 → 1 - form.advance() - assert form.get_section_info().name == "intro" - # Bar 1 → section complete → transition to verse, bar 0 - form.advance() - section = form.get_section_info() +def test_form_state_loop() -> None: + """A looping form should cycle back to the first section.""" - assert section.name == "verse" - assert section.bar == 0 - assert section.bars == 4 - assert section.index == 1 + form = subsequence.form_state.FormState([("A", 2), ("B", 2)], loop=True) + # Advance through A (2 bars) + B (2 bars) = 4 advances + for _ in range(4): + form.advance() -def test_form_state_loop () -> None: + # Should be back to A, bar 0 + section = form.get_section_info() - """A looping form should cycle back to the first section.""" + assert section is not None + assert section.name == "A" + assert section.bar == 0 + assert section.index == 2 - form = subsequence.form_state.FormState([("A", 2), ("B", 2)], loop=True) - # Advance through A (2 bars) + B (2 bars) = 4 advances - for _ in range(4): - form.advance() +def test_form_state_loop_conflicting_at_end_raises() -> None: + """loop=True with a contradictory at_end fails loudly instead of silently winning.""" - # Should be back to A, bar 0 - section = form.get_section_info() + with pytest.raises(ValueError, match="loop=True conflicts"): + subsequence.form_state.FormState([("A", 2)], loop=True, at_end="hold") - assert section is not None - assert section.name == "A" - assert section.bar == 0 - assert section.index == 2 + # The sugar (loop=True with the default at_end) is still accepted. + form = subsequence.form_state.FormState([("A", 2)], loop=True) + assert form._at_end == "loop" -def test_form_state_loop_conflicting_at_end_raises () -> None: +def test_form_state_finite_exhausts() -> None: + """A non-looping form should return None after all sections are complete.""" - """loop=True with a contradictory at_end fails loudly instead of silently winning.""" + form = subsequence.form_state.FormState([("only", 2)]) - with pytest.raises(ValueError, match="loop=True conflicts"): - subsequence.form_state.FormState([("A", 2)], loop=True, at_end="hold") + form.advance() + form.advance() - # The sugar (loop=True with the default at_end) is still accepted. - form = subsequence.form_state.FormState([("A", 2)], loop=True) - assert form._at_end == "loop" + assert form.get_section_info() is None -def test_form_state_finite_exhausts () -> None: +def test_form_state_generator() -> None: + """FormState should work with a generator of (name, bars) tuples.""" - """A non-looping form should return None after all sections are complete.""" + def my_form() -> typing.Iterator[typing.Tuple[str, int]]: + yield ("intro", 2) + yield ("verse", 4) - form = subsequence.form_state.FormState([("only", 2)]) + form = subsequence.form_state.FormState(my_form()) - form.advance() - form.advance() + section = form.get_section_info() + assert section.name == "intro" - assert form.get_section_info() is None + # Advance past intro + form.advance() + form.advance() + section = form.get_section_info() + assert section.name == "verse" + assert section.bar == 0 -def test_form_state_generator () -> None: + # Advance past verse + for _ in range(4): + form.advance() - """FormState should work with a generator of (name, bars) tuples.""" + assert form.get_section_info() is None - def my_form () -> typing.Iterator[typing.Tuple[str, int]]: - yield ("intro", 2) - yield ("verse", 4) - form = subsequence.form_state.FormState(my_form()) +def test_form_state_total_bars() -> None: + """The total_bars counter should track the global bar count across sections.""" - section = form.get_section_info() - assert section.name == "intro" + form = subsequence.form_state.FormState([("A", 2), ("B", 3)]) - # Advance past intro - form.advance() - form.advance() + assert form.total_bars == 0 - section = form.get_section_info() - assert section.name == "verse" - assert section.bar == 0 + form.advance() + assert form.total_bars == 1 - # Advance past verse - for _ in range(4): - form.advance() + form.advance() + assert form.total_bars == 2 - assert form.get_section_info() is None - - -def test_form_state_total_bars () -> None: - - """The total_bars counter should track the global bar count across sections.""" - - form = subsequence.form_state.FormState([("A", 2), ("B", 3)]) - - assert form.total_bars == 0 - - form.advance() - assert form.total_bars == 1 - - form.advance() - assert form.total_bars == 2 - - form.advance() - assert form.total_bars == 3 + form.advance() + assert form.total_bars == 3 # --- SectionInfo --- -def test_section_info_progress () -> None: - - """Progress should reflect position within the section as 0.0 to ~1.0.""" +def test_section_info_progress() -> None: + """Progress should reflect position within the section as 0.0 to ~1.0.""" - info = subsequence.form_state.SectionInfo(name="verse", bar=0, bars=8, index=0) - assert info.progress == 0.0 + info = subsequence.form_state.SectionInfo(name="verse", bar=0, bars=8, index=0) + assert info.progress == 0.0 - info = subsequence.form_state.SectionInfo(name="verse", bar=4, bars=8, index=0) - assert info.progress == 0.5 + info = subsequence.form_state.SectionInfo(name="verse", bar=4, bars=8, index=0) + assert info.progress == 0.5 - info = subsequence.form_state.SectionInfo(name="verse", bar=7, bars=8, index=0) - assert info.progress == 7 / 8 + info = subsequence.form_state.SectionInfo(name="verse", bar=7, bars=8, index=0) + assert info.progress == 7 / 8 -def test_section_info_first_last_bar () -> None: +def test_section_info_first_last_bar() -> None: + """first_bar and last_bar should be correct at section boundaries.""" - """first_bar and last_bar should be correct at section boundaries.""" + first = subsequence.form_state.SectionInfo(name="A", bar=0, bars=4, index=0) + assert first.first_bar is True + assert first.last_bar is False - first = subsequence.form_state.SectionInfo(name="A", bar=0, bars=4, index=0) - assert first.first_bar is True - assert first.last_bar is False + last = subsequence.form_state.SectionInfo(name="A", bar=3, bars=4, index=0) + assert last.first_bar is False + assert last.last_bar is True - last = subsequence.form_state.SectionInfo(name="A", bar=3, bars=4, index=0) - assert last.first_bar is False - assert last.last_bar is True - - mid = subsequence.form_state.SectionInfo(name="A", bar=2, bars=4, index=0) - assert mid.first_bar is False - assert mid.last_bar is False + mid = subsequence.form_state.SectionInfo(name="A", bar=2, bars=4, index=0) + assert mid.first_bar is False + assert mid.last_bar is False # --- Composition integration --- -def test_composition_form_registers_state (patch_midi: None) -> None: - - """Calling form() should store a FormState on the composition.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") +def test_composition_form_registers_state(patch_midi: None) -> None: + """Calling form() should store a FormState on the composition.""" - composition.form([("intro", 4), ("verse", 8)]) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - assert composition._form_state is not None - assert composition._form_state.get_section_info().name == "intro" + composition.form([("intro", 4), ("verse", 8)]) + assert composition._form_state is not None + assert composition._form_state.get_section_info().name == "intro" -def test_section_injected_into_builder (patch_midi: None) -> None: - """Builder functions should receive section info via p.section when form is configured.""" +def test_section_injected_into_builder(patch_midi: None) -> None: + """Builder functions should receive section info via p.section when form is configured.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition.form([("intro", 4), ("verse", 8)]) + composition.form([("intro", 4), ("verse", 8)]) - received_sections = [] + received_sections = [] - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - received_sections.append(p.section) + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + received_sections.append(p.section) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - composition._build_pattern_from_pending(pending) + composition._build_pattern_from_pending(pending) - assert len(received_sections) == 1 - assert received_sections[0] is not None - assert received_sections[0].name == "intro" - assert received_sections[0].bar == 0 + assert len(received_sections) == 1 + assert received_sections[0] is not None + assert received_sections[0].name == "intro" + assert received_sections[0].bar == 0 -def test_no_form_section_is_none (patch_midi: None) -> None: +def test_no_form_section_is_none(patch_midi: None) -> None: + """Without form(), p.section should be None.""" - """Without form(), p.section should be None.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + received_sections = [] - received_sections = [] + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + received_sections.append(p.section) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - received_sections.append(p.section) + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + composition._build_pattern_from_pending(pending) - composition._build_pattern_from_pending(pending) + assert received_sections == [None] - assert received_sections == [None] +def test_builder_bar_available(patch_midi: None) -> None: + """Builder functions should have access to p.bar (global bar count).""" -def test_builder_bar_available (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - """Builder functions should have access to p.bar (global bar count).""" + received_bars = [] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + received_bars.append(p.bar) - received_bars = [] + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - received_bars.append(p.bar) + composition._build_pattern_from_pending(pending) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + assert received_bars == [0] - composition._build_pattern_from_pending(pending) - assert received_bars == [0] +def test_form_state_empty_list() -> None: + """An empty section list should immediately exhaust.""" + form = subsequence.form_state.FormState([]) -def test_form_state_empty_list () -> None: + assert form.get_section_info() is None - """An empty section list should immediately exhaust.""" - form = subsequence.form_state.FormState([]) +def test_form_state_advance_returns_section_changed() -> None: + """advance() should return True when transitioning to a new section.""" - assert form.get_section_info() is None + form = subsequence.form_state.FormState([("A", 2), ("B", 2)]) + # Bar 0 → 1 within section A. + changed = form.advance() + assert changed is False + assert form.get_section_info().name == "A" -def test_form_state_advance_returns_section_changed () -> None: + # Bar 1 → 2, transition to section B. + changed = form.advance() + assert changed is True + assert form.get_section_info().name == "B" - """advance() should return True when transitioning to a new section.""" + # Bar 0 → 1 within section B. + changed = form.advance() + assert changed is False + assert form.get_section_info().name == "B" - form = subsequence.form_state.FormState([("A", 2), ("B", 2)]) - - # Bar 0 → 1 within section A. - changed = form.advance() - assert changed is False - assert form.get_section_info().name == "A" - - # Bar 1 → 2, transition to section B. - changed = form.advance() - assert changed is True - assert form.get_section_info().name == "B" - - # Bar 0 → 1 within section B. - changed = form.advance() - assert changed is False - assert form.get_section_info().name == "B" - - # Bar 1 → exhausted. - changed = form.advance() - assert changed is True - assert form.get_section_info() is None + # Bar 1 → exhausted. + changed = form.advance() + assert changed is True + assert form.get_section_info() is None # --- Graph-based FormState --- -def test_form_state_graph_initial_section () -> None: - - """Graph-based FormState should expose the start section immediately after creation.""" - - form = subsequence.form_state.FormState({ - "intro": (4, [("verse", 1)]), - "verse": (8, [("chorus", 1)]), - "chorus": (8, []), - }, start="intro") - - section = form.get_section_info() - - assert section is not None - assert section.name == "intro" - assert section.bar == 0 - assert section.bars == 4 - assert section.index == 0 - - -def test_form_state_graph_advance_within_section () -> None: - - """Advancing within a graph section should increment bar but keep the same name.""" +def test_form_state_graph_initial_section() -> None: + """Graph-based FormState should expose the start section immediately after creation.""" - form = subsequence.form_state.FormState({ - "intro": (4, [("verse", 1)]), - "verse": (8, []), - }, start="intro") + form = subsequence.form_state.FormState( + { + "intro": (4, [("verse", 1)]), + "verse": (8, [("chorus", 1)]), + "chorus": (8, []), + }, + start="intro", + ) - form.advance() - section = form.get_section_info() + section = form.get_section_info() - assert section.name == "intro" - assert section.bar == 1 + assert section is not None + assert section.name == "intro" + assert section.bar == 0 + assert section.bars == 4 + assert section.index == 0 -def test_form_state_graph_transition () -> None: +def test_form_state_graph_advance_within_section() -> None: + """Advancing within a graph section should increment bar but keep the same name.""" - """Graph-based form should transition to the next section when bars are exhausted.""" + form = subsequence.form_state.FormState( + { + "intro": (4, [("verse", 1)]), + "verse": (8, []), + }, + start="intro", + ) - form = subsequence.form_state.FormState({ - "intro": (2, [("verse", 1)]), - "verse": (4, []), - }, start="intro") + form.advance() + section = form.get_section_info() - # Advance through intro (2 bars). - form.advance() - form.advance() + assert section.name == "intro" + assert section.bar == 1 - section = form.get_section_info() - assert section.name == "verse" - assert section.bar == 0 - assert section.bars == 4 - assert section.index == 1 +def test_form_state_graph_transition() -> None: + """Graph-based form should transition to the next section when bars are exhausted.""" + form = subsequence.form_state.FormState( + { + "intro": (2, [("verse", 1)]), + "verse": (4, []), + }, + start="intro", + ) -def test_form_state_graph_weighted_transition () -> None: + # Advance through intro (2 bars). + form.advance() + form.advance() - """Graph transitions should follow weighted probabilities.""" + section = form.get_section_info() - counts = {"chorus": 0, "bridge": 0} + assert section.name == "verse" + assert section.bar == 0 + assert section.bars == 4 + assert section.index == 1 - for seed in range(100): - rng = random.Random(seed) +def test_form_state_graph_weighted_transition() -> None: + """Graph transitions should follow weighted probabilities.""" - form = subsequence.form_state.FormState({ - "verse": (1, [("chorus", 3), ("bridge", 1)]), - "chorus": (1, [("verse", 1)]), - "bridge": (1, [("verse", 1)]), - }, start="verse", rng=rng) + counts = {"chorus": 0, "bridge": 0} - form.advance() - section = form.get_section_info() - counts[section.name] += 1 + for seed in range(100): + rng = random.Random(seed) - # With 3:1 weights, chorus should get ~75%. Allow a generous margin. - assert counts["chorus"] > 50 - assert counts["bridge"] > 5 + form = subsequence.form_state.FormState( + { + "verse": (1, [("chorus", 3), ("bridge", 1)]), + "chorus": (1, [("verse", 1)]), + "bridge": (1, [("verse", 1)]), + }, + start="verse", + rng=rng, + ) + form.advance() + section = form.get_section_info() + counts[section.name] += 1 -def test_form_state_graph_dead_end_self_loops () -> None: + # With 3:1 weights, chorus should get ~75%. Allow a generous margin. + assert counts["chorus"] > 50 + assert counts["bridge"] > 5 - """A section with no outgoing edges should loop itself.""" - form = subsequence.form_state.FormState({ - "intro": (2, [("end", 1)]), - "end": (2, []), - }, start="intro") +def test_form_state_graph_dead_end_self_loops() -> None: + """A section with no outgoing edges should loop itself.""" - # Advance through intro. - form.advance() - form.advance() + form = subsequence.form_state.FormState( + { + "intro": (2, [("end", 1)]), + "end": (2, []), + }, + start="intro", + ) - assert form.get_section_info().name == "end" + # Advance through intro. + form.advance() + form.advance() - # Advance through "end" - should self-loop. - form.advance() - form.advance() + assert form.get_section_info().name == "end" - section = form.get_section_info() + # Advance through "end" - should self-loop. + form.advance() + form.advance() - assert section.name == "end" - assert section.bar == 0 - assert section.index == 2 + section = form.get_section_info() + assert section.name == "end" + assert section.bar == 0 + assert section.index == 2 -def test_form_state_graph_never_finishes () -> None: - """Graph-based form should never exhaust - it always has a next section.""" +def test_form_state_graph_never_finishes() -> None: + """Graph-based form should never exhaust - it always has a next section.""" - form = subsequence.form_state.FormState({ - "A": (1, [("B", 1)]), - "B": (1, [("A", 1)]), - }, start="A") + form = subsequence.form_state.FormState( + { + "A": (1, [("B", 1)]), + "B": (1, [("A", 1)]), + }, + start="A", + ) - for _ in range(100): - form.advance() - assert form.get_section_info() is not None + for _ in range(100): + form.advance() + assert form.get_section_info() is not None -def test_form_state_graph_default_start () -> None: +def test_form_state_graph_default_start() -> None: + """Omitting start= should default to the first key in the dict.""" - """Omitting start= should default to the first key in the dict.""" + form = subsequence.form_state.FormState( + { + "alpha": (2, [("beta", 1)]), + "beta": (2, [("alpha", 1)]), + } + ) - form = subsequence.form_state.FormState({ - "alpha": (2, [("beta", 1)]), - "beta": (2, [("alpha", 1)]), - }) + assert form.get_section_info().name == "alpha" - assert form.get_section_info().name == "alpha" +def test_form_state_graph_invalid_start_raises() -> None: + """Passing a start section not in the dict should raise ValueError.""" -def test_form_state_graph_invalid_start_raises () -> None: + with pytest.raises(ValueError): + subsequence.form_state.FormState( + { + "A": (2, [("B", 1)]), + "B": (2, []), + }, + start="Z", + ) - """Passing a start section not in the dict should raise ValueError.""" - with pytest.raises(ValueError): +def test_form_state_graph_zero_bars_raises() -> None: + """A graph section lasting fewer than 1 bar should raise ValueError at construction.""" - subsequence.form_state.FormState({ - "A": (2, [("B", 1)]), - "B": (2, []), - }, start="Z") + with pytest.raises(ValueError, match="at least 1 bar"): + subsequence.form_state.FormState( + { + "A": (0, [("B", 1)]), + "B": (2, []), + }, + start="A", + ) -def test_form_state_graph_zero_bars_raises () -> None: +def test_form_state_list_zero_bars_raises() -> None: + """A list-form section lasting fewer than 1 bar should raise ValueError at construction.""" - """A graph section lasting fewer than 1 bar should raise ValueError at construction.""" + with pytest.raises(ValueError, match="at least 1 bar"): + subsequence.form_state.FormState([("intro", 0), ("verse", 4)]) - with pytest.raises(ValueError, match="at least 1 bar"): - subsequence.form_state.FormState({ - "A": (0, [("B", 1)]), - "B": (2, []), - }, start="A") +def test_form_state_graph_total_bars() -> None: + """The total_bars counter should track across graph transitions.""" + form = subsequence.form_state.FormState( + { + "A": (2, [("B", 1)]), + "B": (2, [("A", 1)]), + }, + start="A", + ) -def test_form_state_list_zero_bars_raises () -> None: + assert form.total_bars == 0 - """A list-form section lasting fewer than 1 bar should raise ValueError at construction.""" + form.advance() + assert form.total_bars == 1 - with pytest.raises(ValueError, match="at least 1 bar"): - subsequence.form_state.FormState([("intro", 0), ("verse", 4)]) + form.advance() + assert form.total_bars == 2 + form.advance() + assert form.total_bars == 3 -def test_form_state_graph_total_bars () -> None: - """The total_bars counter should track across graph transitions.""" +def test_form_state_graph_section_progress() -> None: + """Section progress should reflect position within graph-mode sections.""" - form = subsequence.form_state.FormState({ - "A": (2, [("B", 1)]), - "B": (2, [("A", 1)]), - }, start="A") + form = subsequence.form_state.FormState( + { + "verse": (4, [("verse", 1)]), + }, + start="verse", + ) - assert form.total_bars == 0 + assert form.get_section_info().progress == 0.0 - form.advance() - assert form.total_bars == 1 + form.advance() + assert form.get_section_info().progress == 0.25 - form.advance() - assert form.total_bars == 2 + form.advance() + assert form.get_section_info().progress == 0.5 - form.advance() - assert form.total_bars == 3 +def test_composition_form_graph_registers_state(patch_midi: None) -> None: + """Calling form() with a dict should create a graph-mode FormState.""" -def test_form_state_graph_section_progress () -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - """Section progress should reflect position within graph-mode sections.""" + composition.form( + { + "intro": (4, [("verse", 1)]), + "verse": (8, []), + }, + start="intro", + ) - form = subsequence.form_state.FormState({ - "verse": (4, [("verse", 1)]), - }, start="verse") - - assert form.get_section_info().progress == 0.0 - - form.advance() - assert form.get_section_info().progress == 0.25 - - form.advance() - assert form.get_section_info().progress == 0.5 - - -def test_composition_form_graph_registers_state (patch_midi: None) -> None: - - """Calling form() with a dict should create a graph-mode FormState.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="C") - - composition.form({ - "intro": (4, [("verse", 1)]), - "verse": (8, []), - }, start="intro") - - assert composition._form_state is not None - assert composition._form_state.get_section_info().name == "intro" + assert composition._form_state is not None + assert composition._form_state.get_section_info().name == "intro" # --- Terminal graph sections --- -def test_form_state_graph_terminal_section () -> None: - - """A section with None transitions should end the form after completing.""" - - form = subsequence.form_state.FormState({ - "intro": (2, [("outro", 1)]), - "outro": (2, None), - }, start="intro") +def test_form_state_graph_terminal_section() -> None: + """A section with None transitions should end the form after completing.""" - # Advance through intro (2 bars). - form.advance() - form.advance() + form = subsequence.form_state.FormState( + { + "intro": (2, [("outro", 1)]), + "outro": (2, None), + }, + start="intro", + ) - assert form.get_section_info().name == "outro" + # Advance through intro (2 bars). + form.advance() + form.advance() - # Advance through outro (2 bars) - form should end. - form.advance() - form.advance() + assert form.get_section_info().name == "outro" - assert form.get_section_info() is None + # Advance through outro (2 bars) - form should end. + form.advance() + form.advance() + assert form.get_section_info() is None -def test_form_state_graph_terminal_vs_dead_end () -> None: - """None transitions end the form; empty list transitions self-loop.""" +def test_form_state_graph_terminal_vs_dead_end() -> None: + """None transitions end the form; empty list transitions self-loop.""" - # Terminal: None → form ends. - terminal = subsequence.form_state.FormState({ - "end": (1, None), - }, start="end") + # Terminal: None → form ends. + terminal = subsequence.form_state.FormState( + { + "end": (1, None), + }, + start="end", + ) - terminal.advance() - assert terminal.get_section_info() is None + terminal.advance() + assert terminal.get_section_info() is None - # Dead end: [] → self-loops. - dead_end = subsequence.form_state.FormState({ - "end": (1, []), - }, start="end") + # Dead end: [] → self-loops. + dead_end = subsequence.form_state.FormState( + { + "end": (1, []), + }, + start="end", + ) - dead_end.advance() - assert dead_end.get_section_info() is not None - assert dead_end.get_section_info().name == "end" + dead_end.advance() + assert dead_end.get_section_info() is not None + assert dead_end.get_section_info().name == "end" -def test_form_state_graph_terminal_advance_returns_changed () -> None: +def test_form_state_graph_terminal_advance_returns_changed() -> None: + """advance() should return True when the form ends at a terminal section.""" - """advance() should return True when the form ends at a terminal section.""" + form = subsequence.form_state.FormState( + { + "only": (2, None), + }, + start="only", + ) - form = subsequence.form_state.FormState({ - "only": (2, None), - }, start="only") + changed = form.advance() + assert changed is False - changed = form.advance() - assert changed is False - - changed = form.advance() - assert changed is True - assert form.get_section_info() is None + changed = form.advance() + assert changed is True + assert form.get_section_info() is None # --- next_section lookahead --- -def test_next_section_graph_mode () -> None: - - """Graph mode should pre-decide the next section from bar 0.""" - - form = subsequence.form_state.FormState({ - "intro": (2, [("verse", 1)]), - "verse": (4, [("chorus", 1)]), - "chorus": (4, [("verse", 1)]), - }, start="intro") - - section = form.get_section_info() - assert section.name == "intro" - assert section.next_section == "verse" - - -def test_next_section_list_mode () -> None: - - """List mode should peek the next section from the iterator.""" - - form = subsequence.form_state.FormState([("intro", 2), ("verse", 4), ("chorus", 4)]) - - section = form.get_section_info() - assert section.name == "intro" - assert section.next_section == "verse" - - # Advance through intro. - form.advance() - form.advance() - - section = form.get_section_info() - assert section.name == "verse" - assert section.next_section == "chorus" +def test_next_section_graph_mode() -> None: + """Graph mode should pre-decide the next section from bar 0.""" + form = subsequence.form_state.FormState( + { + "intro": (2, [("verse", 1)]), + "verse": (4, [("chorus", 1)]), + "chorus": (4, [("verse", 1)]), + }, + start="intro", + ) -def test_next_section_list_mode_last () -> None: + section = form.get_section_info() + assert section.name == "intro" + assert section.next_section == "verse" - """Last section in a non-looping list should have next_section=None.""" - form = subsequence.form_state.FormState([("intro", 1), ("verse", 1)]) +def test_next_section_list_mode() -> None: + """List mode should peek the next section from the iterator.""" - # Advance into verse (the last section). - form.advance() + form = subsequence.form_state.FormState([("intro", 2), ("verse", 4), ("chorus", 4)]) - section = form.get_section_info() - assert section.name == "verse" - assert section.next_section is None + section = form.get_section_info() + assert section.name == "intro" + assert section.next_section == "verse" + # Advance through intro. + form.advance() + form.advance() -def test_next_section_list_mode_looping () -> None: + section = form.get_section_info() + assert section.name == "verse" + assert section.next_section == "chorus" - """Looping list should always have a next_section.""" - form = subsequence.form_state.FormState([("a", 1), ("b", 1)], loop=True) +def test_next_section_list_mode_last() -> None: + """Last section in a non-looping list should have next_section=None.""" - section = form.get_section_info() - assert section.name == "a" - assert section.next_section == "b" + form = subsequence.form_state.FormState([("intro", 1), ("verse", 1)]) - form.advance() + # Advance into verse (the last section). + form.advance() - section = form.get_section_info() - assert section.name == "b" - assert section.next_section == "a" + section = form.get_section_info() + assert section.name == "verse" + assert section.next_section is None -def test_next_section_terminal () -> None: +def test_next_section_list_mode_looping() -> None: + """Looping list should always have a next_section.""" - """Terminal graph section should have next_section=None.""" + form = subsequence.form_state.FormState([("a", 1), ("b", 1)], loop=True) - form = subsequence.form_state.FormState({ - "intro": (1, [("outro", 1)]), - "outro": (2, None), - }, start="intro") + section = form.get_section_info() + assert section.name == "a" + assert section.next_section == "b" - # Advance into outro (terminal). - form.advance() + form.advance() - section = form.get_section_info() - assert section.name == "outro" - assert section.next_section is None + section = form.get_section_info() + assert section.name == "b" + assert section.next_section == "a" -def test_next_section_advance_consumes () -> None: +def test_next_section_terminal() -> None: + """Terminal graph section should have next_section=None.""" - """advance() should consume the pre-decided next and pick a new one.""" + form = subsequence.form_state.FormState( + { + "intro": (1, [("outro", 1)]), + "outro": (2, None), + }, + start="intro", + ) - form = subsequence.form_state.FormState({ - "a": (1, [("b", 1)]), - "b": (1, [("c", 1)]), - "c": (1, [("a", 1)]), - }, start="a") + # Advance into outro (terminal). + form.advance() - assert form.get_section_info().next_section == "b" + section = form.get_section_info() + assert section.name == "outro" + assert section.next_section is None - form.advance() - section = form.get_section_info() - assert section.name == "b" - assert section.next_section == "c" +def test_next_section_advance_consumes() -> None: + """advance() should consume the pre-decided next and pick a new one.""" + form = subsequence.form_state.FormState( + { + "a": (1, [("b", 1)]), + "b": (1, [("c", 1)]), + "c": (1, [("a", 1)]), + }, + start="a", + ) -def test_queue_next_overrides () -> None: + assert form.get_section_info().next_section == "b" - """queue_next() should override the auto-decided next section.""" + form.advance() - form = subsequence.form_state.FormState({ - "a": (2, [("b", 1)]), - "b": (2, [("a", 1)]), - "c": (2, [("a", 1)]), - }, start="a") + section = form.get_section_info() + assert section.name == "b" + assert section.next_section == "c" - assert form.get_section_info().next_section == "b" - form.queue_next("c") - assert form.get_section_info().next_section == "c" +def test_queue_next_overrides() -> None: + """queue_next() should override the auto-decided next section.""" - # Advance through "a" — should transition to "c" (queued), not "b" (auto). - form.advance() - form.advance() + form = subsequence.form_state.FormState( + { + "a": (2, [("b", 1)]), + "b": (2, [("a", 1)]), + "c": (2, [("a", 1)]), + }, + start="a", + ) - section = form.get_section_info() - assert section.name == "c" + assert form.get_section_info().next_section == "b" + form.queue_next("c") + assert form.get_section_info().next_section == "c" -def test_queue_next_last_call_wins () -> None: + # Advance through "a" — should transition to "c" (queued), not "b" (auto). + form.advance() + form.advance() - """Multiple queue_next() calls: the last one takes effect.""" + section = form.get_section_info() + assert section.name == "c" - form = subsequence.form_state.FormState({ - "a": (2, [("b", 1)]), - "b": (2, [("a", 1)]), - "c": (2, [("a", 1)]), - }, start="a") - form.queue_next("b") - form.queue_next("c") - assert form.get_section_info().next_section == "c" +def test_queue_next_last_call_wins() -> None: + """Multiple queue_next() calls: the last one takes effect.""" + form = subsequence.form_state.FormState( + { + "a": (2, [("b", 1)]), + "b": (2, [("a", 1)]), + "c": (2, [("a", 1)]), + }, + start="a", + ) -def test_queue_next_invalid_section () -> None: + form.queue_next("b") + form.queue_next("c") + assert form.get_section_info().next_section == "c" - """queue_next() should raise ValueError for unknown section names.""" - form = subsequence.form_state.FormState({ - "a": (2, [("b", 1)]), - "b": (2, [("a", 1)]), - }, start="a") +def test_queue_next_invalid_section() -> None: + """queue_next() should raise ValueError for unknown section names.""" - with pytest.raises(ValueError, match="not found"): - form.queue_next("nonexistent") + form = subsequence.form_state.FormState( + { + "a": (2, [("b", 1)]), + "b": (2, [("a", 1)]), + }, + start="a", + ) + with pytest.raises(ValueError, match="not found"): + form.queue_next("nonexistent") -def test_jump_to_re_picks_next () -> None: - """jump_to() should re-pick next_section after the jump.""" +def test_jump_to_re_picks_next() -> None: + """jump_to() should re-pick next_section after the jump.""" - form = subsequence.form_state.FormState({ - "a": (4, [("b", 1)]), - "b": (4, [("c", 1)]), - "c": (4, [("a", 1)]), - }, start="a") + form = subsequence.form_state.FormState( + { + "a": (4, [("b", 1)]), + "b": (4, [("c", 1)]), + "c": (4, [("a", 1)]), + }, + start="a", + ) - assert form.get_section_info().next_section == "b" + assert form.get_section_info().next_section == "b" - form.jump_to("c") + form.jump_to("c") - section = form.get_section_info() - assert section.name == "c" - assert section.next_section == "a" + section = form.get_section_info() + assert section.name == "c" + assert section.next_section == "a" diff --git a/tests/test_forms.py b/tests/test_forms.py index 175f837..2cb1383 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -26,81 +26,84 @@ # --------------------------------------------------------------------------- -def test_section_validates_loudly () -> None: +def test_section_validates_loudly() -> None: + """Bad payloads raise at construction.""" - """Bad payloads raise at construction.""" + with pytest.raises(ValueError, match="at least 1 bar"): + subsequence.Section("verse", 0) - with pytest.raises(ValueError, match="at least 1 bar"): - subsequence.Section("verse", 0) + with pytest.raises(ValueError, match="energy"): + subsequence.Section("verse", 4, energy=1.5) - with pytest.raises(ValueError, match="energy"): - subsequence.Section("verse", 4, energy=1.5) + with pytest.raises(ValueError, match="name"): + subsequence.Section("", 4) - with pytest.raises(ValueError, match="name"): - subsequence.Section("", 4) +def test_form_coerces_tuples_and_sections() -> None: + """Form([...]) accepts Sections and (name, bars) tuples interchangeably.""" -def test_form_coerces_tuples_and_sections () -> None: + form = subsequence.Form( + [("verse", 8), subsequence.Section("chorus", 8, energy=0.9)] + ) - """Form([...]) accepts Sections and (name, bars) tuples interchangeably.""" + assert len(form) == 2 + assert form.bars == 16 + assert form.sections[0] == subsequence.Section("verse", 8) + assert form.sections[1].energy == 0.9 - form = subsequence.Form([("verse", 8), subsequence.Section("chorus", 8, energy=0.9)]) + with pytest.raises(ValueError, match="at least one section"): + subsequence.Form([]) - assert len(form) == 2 - assert form.bars == 16 - assert form.sections[0] == subsequence.Section("verse", 8) - assert form.sections[1].energy == 0.9 + with pytest.raises(TypeError, match="Sections or"): + subsequence.Form(["verse"]) - with pytest.raises(ValueError, match="at least one section"): - subsequence.Form([]) - with pytest.raises(TypeError, match="Sections or"): - subsequence.Form(["verse"]) +def test_form_algebra_and_edits() -> None: + """+ concatenates; replace/insert/with_energy edit by 1-based slot.""" + S = subsequence.Section + form = subsequence.Form([S("verse", 8), S("chorus", 8)]) -def test_form_algebra_and_edits () -> None: + doubled = form + form + assert len(doubled) == 4 and doubled.bars == 32 - """+ concatenates; replace/insert/with_energy edit by 1-based slot.""" + stretched = form.replace(2, bars=16) + assert stretched.sections[1].bars == 16 + assert stretched.sections[1].name == "chorus" + assert form.sections[1].bars == 8 # the original is frozen - S = subsequence.Section - form = subsequence.Form([S("verse", 8), S("chorus", 8)]) + swapped = form.replace(1, S("intro", 4)) + assert swapped.sections[0].name == "intro" - doubled = form + form - assert len(doubled) == 4 and doubled.bars == 32 + with pytest.raises(ValueError, match="not both"): + form.replace(1, S("intro", 4), bars=2) - stretched = form.replace(2, bars=16) - assert stretched.sections[1].bars == 16 - assert stretched.sections[1].name == "chorus" - assert form.sections[1].bars == 8 # the original is frozen + inserted = form.insert(2, ("bridge", 4)) + assert [section.name for section in inserted] == ["verse", "bridge", "chorus"] - swapped = form.replace(1, S("intro", 4)) - assert swapped.sections[0].name == "intro" + appended = form.insert(3, ("outro", 4)) + assert appended.sections[-1].name == "outro" - with pytest.raises(ValueError, match="not both"): - form.replace(1, S("intro", 4), bars=2) + energised = form.with_energy({"chorus": 0.95}) + assert energised.sections[1].energy == 0.95 + assert energised.sections[0].energy == 0.5 - inserted = form.insert(2, ("bridge", 4)) - assert [section.name for section in inserted] == ["verse", "bridge", "chorus"] + with pytest.raises(ValueError, match="no section named"): + form.with_energy({"drop": 1.0}) - appended = form.insert(3, ("outro", 4)) - assert appended.sections[-1].name == "outro" - energised = form.with_energy({"chorus": 0.95}) - assert energised.sections[1].energy == 0.95 - assert energised.sections[0].energy == 0.5 +def test_form_describe_prints_slots_and_payload() -> None: + """describe() is the on-paper view: slots, bar ranges, payload.""" - with pytest.raises(ValueError, match="no section named"): - form.with_energy({"drop": 1.0}) + form = subsequence.Form([subsequence.Section("verse", 8, energy=0.6, key="D")]) + text = form.describe() - -def test_form_describe_prints_slots_and_payload () -> None: - - """describe() is the on-paper view: slots, bar ranges, payload.""" - - form = subsequence.Form([subsequence.Section("verse", 8, energy=0.6, key="D")]) - text = form.describe() - - assert "verse" in text and "bars 1–8" in text and "energy=0.6" in text and "key=D" in text + assert ( + "verse" in text + and "bars 1–8" in text + and "energy=0.6" in text + and "key=D" in text + ) # --------------------------------------------------------------------------- @@ -108,141 +111,145 @@ def test_form_describe_prints_slots_and_payload () -> None: # --------------------------------------------------------------------------- -def test_section_info_carries_payload () -> None: - - """A bound Form's energy/key payloads ride SectionInfo.""" - - form = subsequence.Form([subsequence.Section("verse", 2, energy=0.7, key="D")]) - state = subsequence.form_state.FormState(form) - - info = state.get_section_info() - assert info is not None - assert info.energy == 0.7 and info.key == "D" - - -def test_at_end_hold_repeats_the_final_section () -> None: - - """hold: the last section repeats as a re-entry (index bumps).""" +def test_section_info_carries_payload() -> None: + """A bound Form's energy/key payloads ride SectionInfo.""" - state = subsequence.form_state.FormState([("verse", 1), ("outro", 1)], at_end="hold") + form = subsequence.Form([subsequence.Section("verse", 2, energy=0.7, key="D")]) + state = subsequence.form_state.FormState(form) - assert state.advance() is True # verse -> outro - info = state.get_section_info() - assert info is not None and info.name == "outro" and info.index == 1 - assert info.next_section == "outro" # holding: the next is itself + info = state.get_section_info() + assert info is not None + assert info.energy == 0.7 and info.key == "D" - assert state.advance() is True # outro -> outro (the hold) - info = state.get_section_info() - assert info is not None and info.name == "outro" and info.index == 2 and info.bar == 0 +def test_at_end_hold_repeats_the_final_section() -> None: + """hold: the last section repeats as a re-entry (index bumps).""" -def test_at_end_loop_matches_loop_sugar () -> None: + state = subsequence.form_state.FormState( + [("verse", 1), ("outro", 1)], at_end="hold" + ) - """loop=True is sugar for at_end="loop".""" + assert state.advance() is True # verse -> outro + info = state.get_section_info() + assert info is not None and info.name == "outro" and info.index == 1 + assert info.next_section == "outro" # holding: the next is itself - for kwargs in ({"loop": True}, {"at_end": "loop"}): - state = subsequence.form_state.FormState([("a", 1), ("b", 1)], **kwargs) - assert state.advance() is True # a -> b - assert state.advance() is True # b -> a (the loop) - info = state.get_section_info() - assert info is not None and info.name == "a" + assert state.advance() is True # outro -> outro (the hold) + info = state.get_section_info() + assert ( + info is not None and info.name == "outro" and info.index == 2 and info.bar == 0 + ) -def test_at_end_stop_finishes () -> None: +def test_at_end_loop_matches_loop_sugar() -> None: + """loop=True is sugar for at_end="loop".""" - """stop (the default): the form ends; info goes None.""" + for kwargs in ({"loop": True}, {"at_end": "loop"}): + state = subsequence.form_state.FormState([("a", 1), ("b", 1)], **kwargs) + assert state.advance() is True # a -> b + assert state.advance() is True # b -> a (the loop) + info = state.get_section_info() + assert info is not None and info.name == "a" - state = subsequence.form_state.FormState([("a", 1)]) - assert state.advance() is True - assert state.get_section_info() is None +def test_at_end_stop_finishes() -> None: + """stop (the default): the form ends; info goes None.""" -def test_at_end_validation () -> None: + state = subsequence.form_state.FormState([("a", 1)]) + assert state.advance() is True + assert state.get_section_info() is None - """Graphs only take "stop"; generators cannot "loop"; unknown values raise.""" - with pytest.raises(ValueError, match="terminal"): - subsequence.form_state.FormState({"a": (4, None)}, at_end="hold") +def test_at_end_validation() -> None: + """Graphs only take "stop"; generators cannot "loop"; unknown values raise.""" - with pytest.raises(ValueError, match="generator"): - subsequence.form_state.FormState(iter([("a", 4)]), at_end="loop") + with pytest.raises(ValueError, match="terminal"): + subsequence.form_state.FormState({"a": (4, None)}, at_end="hold") - with pytest.raises(ValueError, match="at_end"): - subsequence.form_state.FormState([("a", 4)], at_end="freeze") + with pytest.raises(ValueError, match="generator"): + subsequence.form_state.FormState(iter([("a", 4)]), at_end="loop") + with pytest.raises(ValueError, match="at_end"): + subsequence.form_state.FormState([("a", 4)], at_end="freeze") -def test_section_ending_property () -> None: - """ending: True on the last bar before a DIFFERENT section only.""" +def test_section_ending_property() -> None: + """ending: True on the last bar before a DIFFERENT section only.""" - state = subsequence.form_state.FormState([("verse", 2), ("verse", 2), ("chorus", 2)]) + state = subsequence.form_state.FormState( + [("verse", 2), ("verse", 2), ("chorus", 2)] + ) - info = state.get_section_info() - assert info is not None and info.ending is False # bar 0 of 2 + info = state.get_section_info() + assert info is not None and info.ending is False # bar 0 of 2 - state.advance() - info = state.get_section_info() - assert info is not None and info.last_bar is True - assert info.ending is False # verse -> verse: a repeat, not an ending + state.advance() + info = state.get_section_info() + assert info is not None and info.last_bar is True + assert info.ending is False # verse -> verse: a repeat, not an ending - state.advance(); state.advance() - info = state.get_section_info() - assert info is not None and info.name == "verse" and info.last_bar - assert info.ending is True # verse -> chorus + state.advance() + state.advance() + info = state.get_section_info() + assert info is not None and info.name == "verse" and info.last_bar + assert info.ending is True # verse -> chorus - state.advance(); state.advance() - info = state.get_section_info() - assert info is not None and info.name == "chorus" and info.last_bar - assert info.ending is False # the form ends after this — no incoming section + state.advance() + state.advance() + info = state.get_section_info() + assert info is not None and info.name == "chorus" and info.last_bar + assert info.ending is False # the form ends after this — no incoming section -def test_sequence_jump_to_lands_on_next_occurrence () -> None: +def test_sequence_jump_to_lands_on_next_occurrence() -> None: + """jump_to in sequence mode searches forward and wraps.""" - """jump_to in sequence mode searches forward and wraps.""" + state = subsequence.form_state.FormState( + [("verse", 4), ("chorus", 4), ("verse", 4)] + ) - state = subsequence.form_state.FormState([("verse", 4), ("chorus", 4), ("verse", 4)]) + state.jump_to("chorus") + info = state.get_section_info() + assert ( + info is not None and info.name == "chorus" and info.bar == 0 and info.index == 1 + ) + assert info.next_section == "verse" # the form continues from slot 2 - state.jump_to("chorus") - info = state.get_section_info() - assert info is not None and info.name == "chorus" and info.bar == 0 and info.index == 1 - assert info.next_section == "verse" # the form continues from slot 2 + state.jump_to("verse") # forward from chorus -> slot 3 + info = state.get_section_info() + assert info is not None and info.name == "verse" + assert info.next_section is None # slot 3 is the last (at_end="stop") - state.jump_to("verse") # forward from chorus -> slot 3 - info = state.get_section_info() - assert info is not None and info.name == "verse" - assert info.next_section is None # slot 3 is the last (at_end="stop") + with pytest.raises(ValueError, match="not found"): + state.jump_to("drop") - with pytest.raises(ValueError, match="not found"): - state.jump_to("drop") +def test_sequence_queue_next_takes_effect_at_the_boundary() -> None: + """queue_next overrides the natural successor; the form continues from there.""" -def test_sequence_queue_next_takes_effect_at_the_boundary () -> None: + state = subsequence.form_state.FormState([("a", 1), ("b", 1), ("c", 1)]) - """queue_next overrides the natural successor; the form continues from there.""" + state.queue_next("c") + info = state.get_section_info() + assert info is not None and info.next_section == "c" - state = subsequence.form_state.FormState([("a", 1), ("b", 1), ("c", 1)]) + state.advance() + info = state.get_section_info() + assert info is not None and info.name == "c" # skipped b + assert info.next_section is None # c is the last slot - state.queue_next("c") - info = state.get_section_info() - assert info is not None and info.next_section == "c" - state.advance() - info = state.get_section_info() - assert info is not None and info.name == "c" # skipped b - assert info.next_section is None # c is the last slot +def test_jump_to_revives_a_finished_sequence() -> None: + """Navigation works even after at_end="stop" finished the form.""" + state = subsequence.form_state.FormState([("a", 1), ("b", 1)]) + state.advance() + state.advance() + assert state.get_section_info() is None -def test_jump_to_revives_a_finished_sequence () -> None: - - """Navigation works even after at_end="stop" finished the form.""" - - state = subsequence.form_state.FormState([("a", 1), ("b", 1)]) - state.advance(); state.advance() - assert state.get_section_info() is None - - state.jump_to("a") - info = state.get_section_info() - assert info is not None and info.name == "a" + state.jump_to("a") + info = state.get_section_info() + assert info is not None and info.name == "a" # --------------------------------------------------------------------------- @@ -250,75 +257,87 @@ def test_jump_to_revives_a_finished_sequence () -> None: # --------------------------------------------------------------------------- -def test_form_freeze_walks_to_the_terminal (patch_midi: None) -> None: - - """The frozen path starts at the current section and ends at a terminal.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1337) - composition.form({ - "intro": (8, [("verse", 1)]), - "verse": (16, [("drop", 1)]), - "drop": (16, [("break", 1)]), - "break": (8, [("drop", 1), ("outro", 1)]), - "outro": (8, None), - }, start="intro") - - path = composition.form_freeze() - - assert isinstance(path, subsequence.Form) - assert path.sections[0].name == "intro" and path.sections[0].bars == 8 - assert path.sections[-1].name == "outro" - assert [section.name for section in path][:3] == ["intro", "verse", "drop"] - - -def test_form_freeze_reproduces_the_live_walk (patch_midi: None) -> None: - - """The frozen path is the path the live graph would have played.""" - - def build () -> subsequence.composition.Composition: - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=7) - composition.form({ - "a": (1, [("b", 1), ("c", 1)]), - "b": (1, [("a", 1), ("c", 1)]), - "c": (1, None), - }, start="a") - return composition - - frozen = build().form_freeze() - - # Drive an identically-seeded live form to its end and record the path. - live = build() - assert live._form_state is not None - played = [] - info = live._form_state.get_section_info() - while info is not None: - played.append(info.name) - live._form_state.advance() - info = live._form_state.get_section_info() - - assert [section.name for section in frozen] == played - - -def test_form_freeze_bounds_and_validation (patch_midi: None) -> None: - - """sections= bounds the walk; terminal-less graphs require it; lists refuse.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=3) - composition.form({ - "a": (4, [("b", 1)]), - "b": (4, [("a", 1)]), - }, start="a") - - with pytest.raises(ValueError, match="no terminal"): - composition.form_freeze() - - path = composition.form_freeze(sections=5) - assert len(path) == 5 - assert [section.name for section in path] == ["a", "b", "a", "b", "a"] - - composition.form([("a", 4)]) - with pytest.raises(ValueError, match="already a frozen sequence"): - composition.form_freeze() +def test_form_freeze_walks_to_the_terminal(patch_midi: None) -> None: + """The frozen path starts at the current section and ends at a terminal.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1337 + ) + composition.form( + { + "intro": (8, [("verse", 1)]), + "verse": (16, [("drop", 1)]), + "drop": (16, [("break", 1)]), + "break": (8, [("drop", 1), ("outro", 1)]), + "outro": (8, None), + }, + start="intro", + ) + + path = composition.form_freeze() + + assert isinstance(path, subsequence.Form) + assert path.sections[0].name == "intro" and path.sections[0].bars == 8 + assert path.sections[-1].name == "outro" + assert [section.name for section in path][:3] == ["intro", "verse", "drop"] + + +def test_form_freeze_reproduces_the_live_walk(patch_midi: None) -> None: + """The frozen path is the path the live graph would have played.""" + + def build() -> subsequence.composition.Composition: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=7 + ) + composition.form( + { + "a": (1, [("b", 1), ("c", 1)]), + "b": (1, [("a", 1), ("c", 1)]), + "c": (1, None), + }, + start="a", + ) + return composition + + frozen = build().form_freeze() + + # Drive an identically-seeded live form to its end and record the path. + live = build() + assert live._form_state is not None + played = [] + info = live._form_state.get_section_info() + while info is not None: + played.append(info.name) + live._form_state.advance() + info = live._form_state.get_section_info() + + assert [section.name for section in frozen] == played + + +def test_form_freeze_bounds_and_validation(patch_midi: None) -> None: + """sections= bounds the walk; terminal-less graphs require it; lists refuse.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=3 + ) + composition.form( + { + "a": (4, [("b", 1)]), + "b": (4, [("a", 1)]), + }, + start="a", + ) + + with pytest.raises(ValueError, match="no terminal"): + composition.form_freeze() + + path = composition.form_freeze(sections=5) + assert len(path) == 5 + assert [section.name for section in path] == ["a", "b", "a", "b", "a"] + + composition.form([("a", 4)]) + with pytest.raises(ValueError, match="already a frozen sequence"): + composition.form_freeze() # --------------------------------------------------------------------------- @@ -326,123 +345,135 @@ def test_form_freeze_bounds_and_validation (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_energy_validation (patch_midi: None) -> None: - - """Levels are 0–1; ramps are 2-tuples.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - - with pytest.raises(ValueError, match="0.0–1.0"): - composition.energy({"verse": 1.5}) - - with pytest.raises(ValueError, match=r"\(start, end\)"): - composition.energy({"build": (0.2, 0.5, 1.0)}) # type: ignore[dict-item] +def test_energy_validation(patch_midi: None) -> None: + """Levels are 0–1; ramps are 2-tuples.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") -def test_energy_priority_dict_over_payload_over_default (patch_midi: None) -> None: + with pytest.raises(ValueError, match="0.0–1.0"): + composition.energy({"verse": 1.5}) - """The energy() dict overrides Section payloads; 0.5 with nothing configured.""" + with pytest.raises(ValueError, match=r"\(start, end\)"): + composition.energy({"build": (0.2, 0.5, 1.0)}) # type: ignore[dict-item] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - assert composition._current_energy(None) == 0.5 +def test_energy_priority_dict_over_payload_over_default(patch_midi: None) -> None: + """The energy() dict overrides Section payloads; 0.5 with nothing configured.""" - composition.form(subsequence.Form([subsequence.Section("verse", 4, energy=0.7)])) - info = composition._form_state.get_section_info() if composition._form_state else None - assert composition._current_energy(info) == 0.7 # the payload + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.energy({"verse": 0.9}) - assert composition._current_energy(info) == 0.9 # the dict wins + assert composition._current_energy(None) == 0.5 + composition.form(subsequence.Form([subsequence.Section("verse", 4, energy=0.7)])) + info = ( + composition._form_state.get_section_info() if composition._form_state else None + ) + assert composition._current_energy(info) == 0.7 # the payload -def test_energy_ramp_interpolates_across_the_section (patch_midi: None) -> None: + composition.energy({"verse": 0.9}) + assert composition._current_energy(info) == 0.9 # the dict wins - """(start, end) tuples are the build gesture — linear, reaching end on the last bar.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.form([("build", 4)]) - composition.energy({"build": (0.2, 1.0)}) +def test_energy_ramp_interpolates_across_the_section(patch_midi: None) -> None: + """(start, end) tuples are the build gesture — linear, reaching end on the last bar.""" - state = composition._form_state - assert state is not None + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.form([("build", 4)]) + composition.energy({"build": (0.2, 1.0)}) - assert composition._current_energy(state.get_section_info()) == pytest.approx(0.2) # bar 0 = start + state = composition._form_state + assert state is not None - state.advance() - assert composition._current_energy(state.get_section_info()) == pytest.approx(0.2 + 0.8 / 3) + assert composition._current_energy(state.get_section_info()) == pytest.approx( + 0.2 + ) # bar 0 = start - state.advance(); state.advance() - assert composition._current_energy(state.get_section_info()) == pytest.approx(1.0) # last bar = end + state.advance() + assert composition._current_energy(state.get_section_info()) == pytest.approx( + 0.2 + 0.8 / 3 + ) + state.advance() + state.advance() + assert composition._current_energy(state.get_section_info()) == pytest.approx( + 1.0 + ) # last bar = end -def test_energy_ramp_single_bar_section_sits_at_end (patch_midi: None) -> None: - """A one-bar build has no span to ramp over, so it plays at its destination level.""" +def test_energy_ramp_single_bar_section_sits_at_end(patch_midi: None) -> None: + """A one-bar build has no span to ramp over, so it plays at its destination level.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.form([("hit", 1)]) - composition.energy({"hit": (0.2, 1.0)}) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.form([("hit", 1)]) + composition.energy({"hit": (0.2, 1.0)}) - state = composition._form_state - assert state is not None + state = composition._form_state + assert state is not None - assert composition._current_energy(state.get_section_info()) == pytest.approx(1.0) + assert composition._current_energy(state.get_section_info()) == pytest.approx(1.0) -def test_min_energy_gates_the_pattern (patch_midi: None) -> None: +def test_min_energy_gates_the_pattern(patch_midi: None) -> None: + """Below the threshold the pattern is silent; raising the energy reopens it.""" - """Below the threshold the pattern is silent; raising the energy reopens it.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form([("verse", 4)]) + composition.energy({"verse": 0.3}) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form([("verse", 4)]) - composition.energy({"verse": 0.3}) + @composition.pattern( + channel=10, beats=4, drum_note_map={"conga": 63}, min_energy=0.8 + ) + def perc(p: typing.Any) -> None: + p.hit("conga", beats=[0]) - @composition.pattern(channel=10, beats=4, drum_note_map={"conga": 63}, min_energy=0.8) - def perc (p: typing.Any) -> None: - p.hit("conga", beats=[0]) + pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) + assert not pattern.steps # gated - pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) - assert not pattern.steps # gated + composition.energy({"verse": 0.9}) + pattern.on_reschedule() + assert pattern.steps # the gate opened - composition.energy({"verse": 0.9}) - pattern.on_reschedule() - assert pattern.steps # the gate opened +def test_p_energy_reaches_the_builder(patch_midi: None) -> None: + """p.energy reads the resolved level inside the builder.""" -def test_p_energy_reaches_the_builder (patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form([("chorus", 4)]) + composition.energy({"chorus": 0.9}) - """p.energy reads the resolved level inside the builder.""" + seen: typing.List[float] = [] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form([("chorus", 4)]) - composition.energy({"chorus": 0.9}) + @composition.pattern(channel=1, beats=4) + def lead(p: typing.Any) -> None: + seen.append(p.energy) - seen: typing.List[float] = [] + composition._build_pattern_from_pending(composition._pending_patterns[0]) + assert seen == [0.9] - @composition.pattern(channel=1, beats=4) - def lead (p: typing.Any) -> None: - seen.append(p.energy) - composition._build_pattern_from_pending(composition._pending_patterns[0]) - assert seen == [0.9] +def test_section_key_reanchors_degrees(patch_midi: None) -> None: + """Section.key moves the tonic for relative content bound in that section.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form(subsequence.Form([subsequence.Section("verse", 4, key="D")])) -def test_section_key_reanchors_degrees (patch_midi: None) -> None: + placed: typing.List[int] = [] - """Section.key moves the tonic for relative content bound in that section.""" + @composition.pattern(channel=1, beats=4) + def lead(p: typing.Any) -> None: + p.motif(subsequence.motif([1]), root=60) + placed.extend( + note.pitch for step in p._pattern.steps.values() for note in step.notes + ) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4, key="D")])) - - placed: typing.List[int] = [] - - @composition.pattern(channel=1, beats=4) - def lead (p: typing.Any) -> None: - p.motif(subsequence.motif([1]), root=60) - placed.extend(note.pitch for step in p._pattern.steps.values() for note in step.notes) - - composition._build_pattern_from_pending(composition._pending_patterns[0]) - assert placed == [62] # degree 1 of D, anchored near middle C + composition._build_pattern_from_pending(composition._pending_patterns[0]) + assert placed == [62] # degree 1 of D, anchored near middle C # --------------------------------------------------------------------------- @@ -450,95 +481,105 @@ def lead (p: typing.Any) -> None: # --------------------------------------------------------------------------- -async def _capture_form_clock ( - form_state: subsequence.form_state.FormState, - on_bar: typing.Optional[typing.Callable[[int, bool], None]] = None, +async def _capture_form_clock( + form_state: subsequence.form_state.FormState, + on_bar: typing.Optional[typing.Callable[[int, bool], None]] = None, ) -> typing.Tuple[typing.Callable[[int], None], subsequence.event_emitter.EventEmitter]: + """Schedule the form clock against a mock sequencer; return (callback, emitter).""" - """Schedule the form clock against a mock sequencer; return (callback, emitter).""" - - captured: typing.Dict[str, typing.Any] = {} + captured: typing.Dict[str, typing.Any] = {} - mock_seq = unittest.mock.MagicMock() - mock_seq.pulses_per_beat = 24 - mock_seq.time_signature = (4, 4) - mock_seq.events = subsequence.event_emitter.EventEmitter() + mock_seq = unittest.mock.MagicMock() + mock_seq.pulses_per_beat = 24 + mock_seq.time_signature = (4, 4) + mock_seq.events = subsequence.event_emitter.EventEmitter() - async def capture (callback: typing.Callable, interval_beats: float, start_pulse: int = 0, reschedule_lookahead: float = 1) -> None: - captured["callback"] = callback + async def capture( + callback: typing.Callable, + interval_beats: float, + start_pulse: int = 0, + reschedule_lookahead: float = 1, + ) -> None: + captured["callback"] = callback - mock_seq.schedule_callback_repeating = capture + mock_seq.schedule_callback_repeating = capture - await subsequence.composition.schedule_form( - sequencer = mock_seq, - form_state = form_state, - reschedule_lookahead = 1, - on_bar = on_bar, - ) + await subsequence.composition.schedule_form( + sequencer=mock_seq, + form_state=form_state, + reschedule_lookahead=1, + on_bar=on_bar, + ) - return captured["callback"], mock_seq.events + return captured["callback"], mock_seq.events @pytest.mark.asyncio -async def test_section_event_fires_on_changes () -> None: +async def test_section_event_fires_on_changes() -> None: + """The "section" event announces the opening section and every change.""" - """The "section" event announces the opening section and every change.""" + state = subsequence.form_state.FormState([("verse", 1), ("chorus", 1)]) + heard: typing.List[typing.Optional[str]] = [] - state = subsequence.form_state.FormState([("verse", 1), ("chorus", 1)]) - heard: typing.List[typing.Optional[str]] = [] + # Register before scheduling so the initial announcement is caught. + mock_seq = unittest.mock.MagicMock() + mock_seq.pulses_per_beat = 24 + mock_seq.time_signature = (4, 4) + mock_seq.events = subsequence.event_emitter.EventEmitter() + mock_seq.events.on( + "section", lambda info: heard.append(info.name if info else None) + ) - # Register before scheduling so the initial announcement is caught. - mock_seq = unittest.mock.MagicMock() - mock_seq.pulses_per_beat = 24 - mock_seq.time_signature = (4, 4) - mock_seq.events = subsequence.event_emitter.EventEmitter() - mock_seq.events.on("section", lambda info: heard.append(info.name if info else None)) + captured: typing.Dict[str, typing.Any] = {} - captured: typing.Dict[str, typing.Any] = {} + async def capture( + callback: typing.Callable, + interval_beats: float, + start_pulse: int = 0, + reschedule_lookahead: float = 1, + ) -> None: + captured["callback"] = callback - async def capture (callback: typing.Callable, interval_beats: float, start_pulse: int = 0, reschedule_lookahead: float = 1) -> None: - captured["callback"] = callback + mock_seq.schedule_callback_repeating = capture - mock_seq.schedule_callback_repeating = capture + await subsequence.composition.schedule_form(mock_seq, state, reschedule_lookahead=1) - await subsequence.composition.schedule_form(mock_seq, state, reschedule_lookahead=1) + assert heard == ["verse"] # the opening announcement - assert heard == ["verse"] # the opening announcement + cb = captured["callback"] + cb(72) # the verse/chorus boundary (bar line 96, lookahead 24) + cb(168) # the chorus/end boundary - cb = captured["callback"] - cb(72) # the verse/chorus boundary (bar line 96, lookahead 24) - cb(168) # the chorus/end boundary - - assert heard == ["verse", "chorus", None] + assert heard == ["verse", "chorus", None] @pytest.mark.asyncio -async def test_on_bar_hook_receives_boundary_pulses () -> None: - - """The boundary hook gets the bar-line pulse (fire pulse + lookahead) per bar.""" - - state = subsequence.form_state.FormState([("verse", 2)]) - calls: typing.List[typing.Tuple[int, bool]] = [] +async def test_on_bar_hook_receives_boundary_pulses() -> None: + """The boundary hook gets the bar-line pulse (fire pulse + lookahead) per bar.""" - cb, _events = await _capture_form_clock(state, on_bar=lambda pulse, changed: calls.append((pulse, changed))) + state = subsequence.form_state.FormState([("verse", 2)]) + calls: typing.List[typing.Tuple[int, bool]] = [] - assert calls == [(0, True)] # the schedule-time check for bar 0 + cb, _events = await _capture_form_clock( + state, on_bar=lambda pulse, changed: calls.append((pulse, changed)) + ) - cb(72) # fires lookahead-early for the bar line at pulse 96 - assert calls[-1] == (96, False) + assert calls == [(0, True)] # the schedule-time check for bar 0 + cb(72) # fires lookahead-early for the bar line at pulse 96 + assert calls[-1] == (96, False) -def test_on_section_registers_on_the_composition (patch_midi: None) -> None: - """comp.on_section is sugar over the sequencer's "section" event.""" +def test_on_section_registers_on_the_composition(patch_midi: None) -> None: + """comp.on_section is sugar over the sequencer's "section" event.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - heard: typing.List[typing.Any] = [] + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + heard: typing.List[typing.Any] = [] - composition.on_section(heard.append) - composition._sequencer.events.emit_sync("section", "probe") + composition.on_section(heard.append) + composition._sequencer.events.emit_sync("section", "probe") - assert heard == ["probe"] + assert heard == ["probe"] # --------------------------------------------------------------------------- @@ -546,121 +587,136 @@ def test_on_section_registers_on_the_composition (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_transition_validation (patch_midi: None) -> None: - - """Actionless rules and homeless fills raise.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") +def test_transition_validation(patch_midi: None) -> None: + """Actionless rules and homeless fills raise.""" - with pytest.raises(ValueError, match="fill= and/or mute="): - composition.transition(before="*") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - with pytest.raises(ValueError, match="channel="): - composition.transition(before="*", fill=subsequence.Motif.hits("snare", beats=[0], length=1)) + with pytest.raises(ValueError, match="fill= and/or mute="): + composition.transition(before="*") + with pytest.raises(ValueError, match="channel="): + composition.transition( + before="*", fill=subsequence.Motif.hits("snare", beats=[0], length=1) + ) -def test_transition_device_name_defers_resolution (patch_midi: None) -> None: - """A transition fill routed to a named device keeps the name raw until play(). +def test_transition_device_name_defers_resolution(patch_midi: None) -> None: + """A transition fill routed to a named device keeps the name raw until play(). - Regression: the device was resolved at registration, but device names are - not populated until ``play()`` opens the ports — so a string name collapsed - to device 0 (with a spurious warning) and the fill played on the wrong port. - """ + Regression: the device was resolved at registration, but device names are + not populated until ``play()`` opens the ports — so a string name collapsed + to device 0 (with a spurious warning) and the fill played on the wrong port. + """ - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.transition(before="*", fill=subsequence.motif([1]), channel=10, device="Aux Synth") + composition.transition( + before="*", fill=subsequence.motif([1]), channel=10, device="Aux Synth" + ) - rule = composition._transitions[0] - assert rule.device == "Aux Synth" # stored raw, not eagerly collapsed to 0 + rule = composition._transitions[0] + assert rule.device == "Aux Synth" # stored raw, not eagerly collapsed to 0 -def test_transition_fill_fires_in_the_final_bar (patch_midi: None) -> None: +def test_transition_fill_fires_in_the_final_bar(patch_midi: None) -> None: + """The fill one-shot lands at the final bar's start + beat, with a borrowed drum map.""" - """The fill one-shot lands at the final bar's start + beat, with a borrowed drum map.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form([("verse", 2), ("chorus", 2)]) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form([("verse", 2), ("chorus", 2)]) + fill = subsequence.Motif.hits("snare", beats=[0, 0.5], length=2) + composition.transition(before="*", fill=fill, channel=10, beat=2.0) - fill = subsequence.Motif.hits("snare", beats=[0, 0.5], length=2) - composition.transition(before="*", fill=fill, channel=10, beat=2.0) + # A registered pattern on channel 10 donates its drum map to the fill. + @composition.pattern(channel=10, beats=4, drum_note_map={"kick": 36, "snare": 38}) + def drums(p: typing.Any) -> None: + p.hit("kick", beat=0) - # A registered pattern on channel 10 donates its drum map to the fill. - @composition.pattern(channel=10, beats=4, drum_note_map={"kick": 36, "snare": 38}) - def drums (p: typing.Any) -> None: - p.hit("kick", beat=0) + fired: typing.List[typing.Tuple[typing.Any, int]] = [] + composition._schedule_one_shot = lambda pattern, start_pulse: fired.append( + (pattern, start_pulse) + ) # type: ignore[method-assign] - fired: typing.List[typing.Tuple[typing.Any, int]] = [] - composition._schedule_one_shot = lambda pattern, start_pulse: fired.append((pattern, start_pulse)) # type: ignore[method-assign] + state = composition._form_state + assert state is not None - state = composition._form_state - assert state is not None + # Bar 0 of verse: not the last bar — nothing fires. + composition._check_transitions(0, True) + assert fired == [] - # Bar 0 of verse: not the last bar — nothing fires. - composition._check_transitions(0, True) - assert fired == [] + # Bar 1 of verse (the final bar before chorus): the fill fires at +2 beats. + state.advance() + composition._check_transitions(96, False) - # Bar 1 of verse (the final bar before chorus): the fill fires at +2 beats. - state.advance() - composition._check_transitions(96, False) + assert len(fired) == 1 + pattern, start_pulse = fired[0] + assert start_pulse == 96 + 2 * 24 + pitches = [note.pitch for step in pattern.steps.values() for note in step.notes] + assert pitches == [38, 38] # the borrowed map resolved "snare" - assert len(fired) == 1 - pattern, start_pulse = fired[0] - assert start_pulse == 96 + 2 * 24 - pitches = [note.pitch for step in pattern.steps.values() for note in step.notes] - assert pitches == [38, 38] # the borrowed map resolved "snare" +def test_transition_star_skips_repeats(patch_midi: None) -> None: + """before="*" means a DIFFERENT section — verse→verse does not fire.""" -def test_transition_star_skips_repeats (patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form([("verse", 1), ("verse", 1), ("chorus", 1)]) + composition.transition( + before="*", + fill=subsequence.Motif.hits("snare", beats=[0], length=1), + channel=10, + drum_note_map={"snare": 38}, + ) - """before="*" means a DIFFERENT section — verse→verse does not fire.""" + fired: typing.List[int] = [] + composition._schedule_one_shot = lambda pattern, start_pulse: fired.append( + start_pulse + ) # type: ignore[method-assign] - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form([("verse", 1), ("verse", 1), ("chorus", 1)]) - composition.transition(before="*", fill=subsequence.Motif.hits("snare", beats=[0], length=1), channel=10, - drum_note_map={"snare": 38}) + composition._check_transitions(0, True) # verse bar 0 (last bar) -> verse: a repeat + assert fired == [] - fired: typing.List[int] = [] - composition._schedule_one_shot = lambda pattern, start_pulse: fired.append(start_pulse) # type: ignore[method-assign] + state = composition._form_state + assert state is not None + state.advance() + composition._check_transitions(96, True) # verse -> chorus next: fires + assert fired == [96] - composition._check_transitions(0, True) # verse bar 0 (last bar) -> verse: a repeat - assert fired == [] - state = composition._form_state - assert state is not None - state.advance() - composition._check_transitions(96, True) # verse -> chorus next: fires - assert fired == [96] +def test_transition_mute_closes_and_reopens(patch_midi: None) -> None: + """mute= closes named patterns over the approach and restores them at the boundary.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form([("verse", 2), ("chorus", 2)]) + composition.transition(before="chorus", mute=["kick_pattern"], beats=4) -def test_transition_mute_closes_and_reopens (patch_midi: None) -> None: + kick = types.SimpleNamespace(_muted=False, channel=9) + performer_muted = types.SimpleNamespace(_muted=True, channel=9) + composition._running_patterns["kick_pattern"] = kick # type: ignore[assignment] + composition._running_patterns["pads"] = performer_muted # type: ignore[assignment] - """mute= closes named patterns over the approach and restores them at the boundary.""" + state = composition._form_state + assert state is not None - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form([("verse", 2), ("chorus", 2)]) - composition.transition(before="chorus", mute=["kick_pattern"], beats=4) + composition._check_transitions(0, True) + assert kick._muted is False # bar 0: outside the 1-bar window - kick = types.SimpleNamespace(_muted=False, channel=9) - performer_muted = types.SimpleNamespace(_muted=True, channel=9) - composition._running_patterns["kick_pattern"] = kick # type: ignore[assignment] - composition._running_patterns["pads"] = performer_muted # type: ignore[assignment] + state.advance() + composition._check_transitions(96, False) + assert kick._muted is True # the approach: muted - state = composition._form_state - assert state is not None - - composition._check_transitions(0, True) - assert kick._muted is False # bar 0: outside the 1-bar window - - state.advance() - composition._check_transitions(96, False) - assert kick._muted is True # the approach: muted - - state.advance() # the boundary: chorus begins - composition._check_transitions(192, True) - assert kick._muted is False # restored - assert performer_muted._muted is True # the performer's mute was never ours to touch + state.advance() # the boundary: chorus begins + composition._check_transitions(192, True) + assert kick._muted is False # restored + assert ( + performer_muted._muted is True + ) # the performer's mute was never ours to touch # --------------------------------------------------------------------------- @@ -668,173 +724,223 @@ def test_transition_mute_closes_and_reopens (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_sketch_d_fills_and_energy_layers (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """Sketch (d): automatic fill before every section change; energy-driven layers.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C", seed=42) - composition.form([("verse", 2), ("chorus", 2)], loop=True) - - DRUM_MAP = {"kick": 36, "snare": 38, "hh": 42, "conga": 63} - - KICK = subsequence.Motif.hits("kick", beats=[0, 1.5, 3], length=4) - FILL = subsequence.Motif.hits("snare", - beats=[0, 0.5, 1.0, 1.25] + [1.5 + i * 0.125 for i in range(4)], - length=2) - - composition.energy({"verse": 0.5, "chorus": 0.9}) - composition.transition(before="*", fill=FILL, channel=10, beat=2.0) - - @composition.pattern(channel=10, beats=4, drum_note_map=DRUM_MAP) - def drums (p: typing.Any) -> None: - p.motif(KICK) - if p.energy >= 0.6: - p.euclidean("hh", pulses=int(5 + 6 * p.energy), velocity=(60, 90)) - - @composition.pattern(channel=10, beats=4, drum_note_map=DRUM_MAP, min_energy=0.8) - def perc (p: typing.Any) -> None: - p.bresenham("conga", 7) - - composition.render(bars=4, filename=str(tmp_path / "sketch_d.mid")) - - mid = mido.MidiFile(str(tmp_path / "sketch_d.mid")) - tpb = mid.ticks_per_beat - - notes: typing.List[typing.Tuple[float, int]] = [] - for track in mid.tracks: - now = 0 - for msg in track: - now += msg.time - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0: - notes.append((now / tpb, msg.note)) - - verse_window = [n for beat, n in notes if beat < 8] - chorus_window = [n for beat, n in notes if 8 <= beat < 16] - - # Energy layers: hats and congas only in the chorus (0.9 >= thresholds). - assert 42 not in verse_window and 63 not in verse_window - assert 42 in chorus_window and 63 in chorus_window - - # The fill: snares in the back half of bar 2 (the last verse bar) — the - # transition fired automatically before the section change. - fill_notes = [beat for beat, n in notes if n == 38 and 6 <= beat < 8] - assert len(fill_notes) == 8 - - -def test_sketch_e_full_song_runs_as_written (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """Sketch (e): explicit form with payload, generated + manual material, - frozen, inspected, two hand-edits, rebound.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=42) - - S = subsequence.Section - form = subsequence.Form([ - S("verse", bars=8, energy=0.55), S("verse", bars=8, energy=0.6), - S("chorus", bars=8, energy=0.9), S("verse", bars=8, energy=0.65), - S("outro", bars=4, energy=0.3), - ]) - - composition.harmony(style="functional_major", cycle_beats=4) - verse_prog = composition.freeze(8) - chorus_prog = subsequence.progression(style="functional_major", bars=8, end=1, seed=9) - - theme = subsequence.motif([1, 3, 5, 6, 5, 3, 2], durations=[1, 1, 1, 1, 1, 1, 2]) - verse_line = subsequence.Phrase.develop(theme, bars=8, plan=["a", "a", "a", "b"], seed=4) - - print(verse_prog); print(verse_line) - verse_prog = verse_prog.replace(6, "Dm9") - verse_line = verse_line.replace(4, subsequence.motif([3, 2, 1], durations=[1, 1, 2])) - - composition.form(form, at_end="stop") - composition.section_chords("verse", verse_prog) - composition.section_chords("chorus", chorus_prog) - composition.section_motifs("verse", verse_line, part="lead") - composition.phrase_part(channel=4, root=72, part="lead", fit=0.7) - - # The edits took: chord 6 is decorated, segment 4 was swapped. - assert verse_prog.spans[5].extensions == (9,) - assert verse_line.segments[3].length == 4.0 - - composition.render(bars=10, filename=str(tmp_path / "sketch_e.mid")) - - mid = mido.MidiFile(str(tmp_path / "sketch_e.mid")) - lead_notes = 0 - for track in mid.tracks: - for msg in track: - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0 and msg.channel == 3: - lead_notes += 1 - - assert lead_notes > 0 # the verse's bound lead actually sounded - - -def test_sketch_f_fully_generated_from_one_seed (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """Sketch (f): fully generated from one seed — keep what works, reroll what - doesn't. Every line is a declared primitive; no compose() magic.""" - - DRUM_MAP = {"kick": 36, "snare": 38, "hh": 42, "conga": 63} - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="F#", scale="minor", seed=1337) - composition.harmony(style="phrygian_minor", cycle_beats=4) - composition.form({ - "intro": (8, [("verse", 1)]), - "verse": (16, [("drop", 1)]), - "drop": (16, [("break", 1)]), - "break": (8, [("drop", 1), ("outro", 1)]), - "outro": (8, None), - }, start="intro") - - path = composition.form_freeze() # graph walk -> editable, seeded Form - composition.form(path, at_end="stop") - composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95, "break": 0.35, "outro": 0.2}) - - for name, bars in (("verse", 16), ("drop", 16), ("break", 8)): - composition.section_chords(name, composition.freeze(bars)) - - hook = subsequence.Motif.generate( - rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", - contour="arch", end_on=1, seed=composition.seed_for("hook"), - ) - verse_line = subsequence.Phrase.develop(hook, bars=8, plan="call_response", - seed=composition.seed_for("verse_line")) - drop_line = subsequence.Phrase.develop(hook.transpose(steps=2), bars=8, - plan=["a", "a", "a", "b"], seed=composition.seed_for("drop_line")) - - KICK = subsequence.Motif.euclidean(5, 16, "kick", length=4) - HATS = subsequence.Motif.euclidean(11, 16, "hh", length=4) - - @composition.pattern(channel=10, beats=4, drum_note_map=DRUM_MAP) - def drums (p: typing.Any) -> None: - p.motif(KICK & HATS) - - drop_line = drop_line.reroll(bars=[5, 6], seed=2) - composition.section_motifs("verse", verse_line, part="lead") - composition.section_motifs("drop", drop_line, part="lead") - composition.phrase_part(channel=4, root=78, part="lead") - - # The frozen path is the seeded walk: intro first, outro terminal. - assert path.sections[0].name == "intro" - assert path.sections[-1].name == "outro" - - # Render across the intro/verse boundary: the verse's bound lead enters. - composition.render(bars=12, filename=str(tmp_path / "sketch_f.mid")) - - mid = mido.MidiFile(str(tmp_path / "sketch_f.mid")) - tpb = mid.ticks_per_beat - - drum_notes = 0 - lead_beats: typing.List[float] = [] - for track in mid.tracks: - now = 0 - for msg in track: - now += msg.time - if isinstance(msg, mido.MetaMessage) or msg.type != "note_on" or msg.velocity == 0: - continue - if msg.channel == 9: - drum_notes += 1 - if msg.channel == 3: - lead_beats.append(now / tpb) - - assert drum_notes > 0 - assert lead_beats and min(lead_beats) >= 32.0 # silent intro; the lead enters with the verse +def test_sketch_d_fills_and_energy_layers( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Sketch (d): automatic fill before every section change; energy-driven layers.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="C", seed=42 + ) + composition.form([("verse", 2), ("chorus", 2)], loop=True) + + DRUM_MAP = {"kick": 36, "snare": 38, "hh": 42, "conga": 63} + + KICK = subsequence.Motif.hits("kick", beats=[0, 1.5, 3], length=4) + FILL = subsequence.Motif.hits( + "snare", + beats=[0, 0.5, 1.0, 1.25] + [1.5 + i * 0.125 for i in range(4)], + length=2, + ) + + composition.energy({"verse": 0.5, "chorus": 0.9}) + composition.transition(before="*", fill=FILL, channel=10, beat=2.0) + + @composition.pattern(channel=10, beats=4, drum_note_map=DRUM_MAP) + def drums(p: typing.Any) -> None: + p.motif(KICK) + if p.energy >= 0.6: + p.euclidean("hh", pulses=int(5 + 6 * p.energy), velocity=(60, 90)) + + @composition.pattern(channel=10, beats=4, drum_note_map=DRUM_MAP, min_energy=0.8) + def perc(p: typing.Any) -> None: + p.bresenham("conga", 7) + + composition.render(bars=4, filename=str(tmp_path / "sketch_d.mid")) + + mid = mido.MidiFile(str(tmp_path / "sketch_d.mid")) + tpb = mid.ticks_per_beat + + notes: typing.List[typing.Tuple[float, int]] = [] + for track in mid.tracks: + now = 0 + for msg in track: + now += msg.time + if ( + not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + ): + notes.append((now / tpb, msg.note)) + + verse_window = [n for beat, n in notes if beat < 8] + chorus_window = [n for beat, n in notes if 8 <= beat < 16] + + # Energy layers: hats and congas only in the chorus (0.9 >= thresholds). + assert 42 not in verse_window and 63 not in verse_window + assert 42 in chorus_window and 63 in chorus_window + + # The fill: snares in the back half of bar 2 (the last verse bar) — the + # transition fired automatically before the section change. + fill_notes = [beat for beat, n in notes if n == 38 and 6 <= beat < 8] + assert len(fill_notes) == 8 + + +def test_sketch_e_full_song_runs_as_written( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Sketch (e): explicit form with payload, generated + manual material, + frozen, inspected, two hand-edits, rebound.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=42 + ) + + S = subsequence.Section + form = subsequence.Form( + [ + S("verse", bars=8, energy=0.55), + S("verse", bars=8, energy=0.6), + S("chorus", bars=8, energy=0.9), + S("verse", bars=8, energy=0.65), + S("outro", bars=4, energy=0.3), + ] + ) + + composition.harmony(style="functional_major", cycle_beats=4) + verse_prog = composition.freeze(8) + chorus_prog = subsequence.progression( + style="functional_major", bars=8, end=1, seed=9 + ) + + theme = subsequence.motif([1, 3, 5, 6, 5, 3, 2], durations=[1, 1, 1, 1, 1, 1, 2]) + verse_line = subsequence.Phrase.develop( + theme, bars=8, plan=["a", "a", "a", "b"], seed=4 + ) + + print(verse_prog) + print(verse_line) + verse_prog = verse_prog.replace(6, "Dm9") + verse_line = verse_line.replace( + 4, subsequence.motif([3, 2, 1], durations=[1, 1, 2]) + ) + + composition.form(form, at_end="stop") + composition.section_chords("verse", verse_prog) + composition.section_chords("chorus", chorus_prog) + composition.section_motifs("verse", verse_line, part="lead") + composition.phrase_part(channel=4, root=72, part="lead", fit=0.7) + + # The edits took: chord 6 is decorated, segment 4 was swapped. + assert verse_prog.spans[5].extensions == (9,) + assert verse_line.segments[3].length == 4.0 + + composition.render(bars=10, filename=str(tmp_path / "sketch_e.mid")) + + mid = mido.MidiFile(str(tmp_path / "sketch_e.mid")) + lead_notes = 0 + for track in mid.tracks: + for msg in track: + if ( + not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + and msg.channel == 3 + ): + lead_notes += 1 + + assert lead_notes > 0 # the verse's bound lead actually sounded + + +def test_sketch_f_fully_generated_from_one_seed( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Sketch (f): fully generated from one seed — keep what works, reroll what + doesn't. Every line is a declared primitive; no compose() magic.""" + + DRUM_MAP = {"kick": 36, "snare": 38, "hh": 42, "conga": 63} + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="F#", scale="minor", seed=1337 + ) + composition.harmony(style="phrygian_minor", cycle_beats=4) + composition.form( + { + "intro": (8, [("verse", 1)]), + "verse": (16, [("drop", 1)]), + "drop": (16, [("break", 1)]), + "break": (8, [("drop", 1), ("outro", 1)]), + "outro": (8, None), + }, + start="intro", + ) + + path = composition.form_freeze() # graph walk -> editable, seeded Form + composition.form(path, at_end="stop") + composition.energy( + {"intro": 0.2, "verse": 0.55, "drop": 0.95, "break": 0.35, "outro": 0.2} + ) + + for name, bars in (("verse", 16), ("drop", 16), ("break", 8)): + composition.section_chords(name, composition.freeze(bars)) + + hook = subsequence.Motif.generate( + rhythm=[0, 1, 1.5, 1.75, 2.5], + scale="minor_pentatonic", + contour="arch", + end_on=1, + seed=composition.seed_for("hook"), + ) + verse_line = subsequence.Phrase.develop( + hook, bars=8, plan="call_response", seed=composition.seed_for("verse_line") + ) + drop_line = subsequence.Phrase.develop( + hook.transpose(steps=2), + bars=8, + plan=["a", "a", "a", "b"], + seed=composition.seed_for("drop_line"), + ) + + KICK = subsequence.Motif.euclidean(5, 16, "kick", length=4) + HATS = subsequence.Motif.euclidean(11, 16, "hh", length=4) + + @composition.pattern(channel=10, beats=4, drum_note_map=DRUM_MAP) + def drums(p: typing.Any) -> None: + p.motif(KICK & HATS) + + drop_line = drop_line.reroll(bars=[5, 6], seed=2) + composition.section_motifs("verse", verse_line, part="lead") + composition.section_motifs("drop", drop_line, part="lead") + composition.phrase_part(channel=4, root=78, part="lead") + + # The frozen path is the seeded walk: intro first, outro terminal. + assert path.sections[0].name == "intro" + assert path.sections[-1].name == "outro" + + # Render across the intro/verse boundary: the verse's bound lead enters. + composition.render(bars=12, filename=str(tmp_path / "sketch_f.mid")) + + mid = mido.MidiFile(str(tmp_path / "sketch_f.mid")) + tpb = mid.ticks_per_beat + + drum_notes = 0 + lead_beats: typing.List[float] = [] + for track in mid.tracks: + now = 0 + for msg in track: + now += msg.time + if ( + isinstance(msg, mido.MetaMessage) + or msg.type != "note_on" + or msg.velocity == 0 + ): + continue + if msg.channel == 9: + drum_notes += 1 + if msg.channel == 3: + lead_beats.append(now / tpb) + + assert drum_notes > 0 + assert ( + lead_beats and min(lead_beats) >= 32.0 + ) # silent intro; the lead enters with the verse diff --git a/tests/test_groove.py b/tests/test_groove.py index 9904260..90c7126 100644 --- a/tests/test_groove.py +++ b/tests/test_groove.py @@ -6,503 +6,519 @@ import subsequence.pattern -def _make_steps (*pulses: int, velocity: int = 100) -> dict: - - """ - Build a steps dict with one note per pulse position. - """ - - steps = {} - for p in pulses: - steps[p] = subsequence.pattern.Step(notes=[ - subsequence.pattern.Note(pitch=60, velocity=velocity, duration=6, channel=0) - ]) - return steps +def _make_steps(*pulses: int, velocity: int = 100) -> dict: + """ + Build a steps dict with one note per pulse position. + """ + + steps = {} + for p in pulses: + steps[p] = subsequence.pattern.Step( + notes=[ + subsequence.pattern.Note( + pitch=60, velocity=velocity, duration=6, channel=0 + ) + ] + ) + return steps # ── Groove.swing() factory ─────────────────────────────────────────── -def test_swing_50_percent_is_straight () -> None: - - """50% swing produces zero offsets (straight time).""" - - g = subsequence.groove.Groove.swing(percent=50.0) - assert g.offsets == [0.0, 0.0] - assert g.grid == 0.25 - - -def test_swing_57_percent () -> None: - """57% swing produces the expected offset for 16th notes.""" +def test_swing_50_percent_is_straight() -> None: + """50% swing produces zero offsets (straight time).""" - g = subsequence.groove.Groove.swing(percent=57.0) - assert g.offsets[0] == 0.0 - assert abs(g.offsets[1] - 0.035) < 1e-9 + g = subsequence.groove.Groove.swing(percent=50.0) + assert g.offsets == [0.0, 0.0] + assert g.grid == 0.25 -def test_swing_67_percent_triplet () -> None: +def test_swing_57_percent() -> None: + """57% swing produces the expected offset for 16th notes.""" - """67% gives approximate triplet swing.""" + g = subsequence.groove.Groove.swing(percent=57.0) + assert g.offsets[0] == 0.0 + assert abs(g.offsets[1] - 0.035) < 1e-9 - g = subsequence.groove.Groove.swing(percent=67.0) - assert g.offsets[0] == 0.0 - # 67% of 0.5 = 0.335, offset = 0.335 - 0.25 = 0.085 - assert abs(g.offsets[1] - 0.085) < 1e-9 +def test_swing_67_percent_triplet() -> None: + """67% gives approximate triplet swing.""" -def test_swing_eighth_note_grid () -> None: + g = subsequence.groove.Groove.swing(percent=67.0) + assert g.offsets[0] == 0.0 + # 67% of 0.5 = 0.335, offset = 0.335 - 0.25 = 0.085 + assert abs(g.offsets[1] - 0.085) < 1e-9 - """Swing can be applied to 8th note grid.""" - g = subsequence.groove.Groove.swing(percent=57.0, grid=0.5) - assert g.grid == 0.5 - # pair_duration = 1.0, offset = (0.57 - 0.5) * 1.0 = 0.07 - assert abs(g.offsets[1] - 0.07) < 1e-9 +def test_swing_eighth_note_grid() -> None: + """Swing can be applied to 8th note grid.""" + g = subsequence.groove.Groove.swing(percent=57.0, grid=0.5) + assert g.grid == 0.5 + # pair_duration = 1.0, offset = (0.57 - 0.5) * 1.0 = 0.07 + assert abs(g.offsets[1] - 0.07) < 1e-9 -def test_swing_invalid_percent () -> None: - """Percent outside 50-99 range raises ValueError.""" +def test_swing_invalid_percent() -> None: + """Percent outside 50-99 range raises ValueError.""" - with pytest.raises(ValueError): - subsequence.groove.Groove.swing(percent=40.0) + with pytest.raises(ValueError): + subsequence.groove.Groove.swing(percent=40.0) - with pytest.raises(ValueError): - subsequence.groove.Groove.swing(percent=100.0) + with pytest.raises(ValueError): + subsequence.groove.Groove.swing(percent=100.0) # ── apply_groove() ─────────────────────────────────────────────────── -def test_apply_groove_shifts_offbeat_16ths () -> None: - - """Off-beat 16th notes are shifted by the groove offset.""" - - # 16th notes at pulses 0, 6, 12, 18 (one beat of 16ths at 24 PPQN) - steps = _make_steps(0, 6, 12, 18) - g = subsequence.groove.Groove.swing(percent=57.0) - - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - - # On-beat 16ths (pulse 0, 12) should stay put - assert 0 in result - assert 12 in result - - # Off-beat 16ths: ideal=6, offset=0.035*24=0.84 → round(6+0.84)=7 - assert 7 in result - # ideal=18, offset=0.035*24=0.84 → round(18+0.84)=19 - assert 19 in result - -def test_apply_groove_straight_no_change () -> None: +def test_apply_groove_shifts_offbeat_16ths() -> None: + """Off-beat 16th notes are shifted by the groove offset.""" - """50% swing (straight) produces no timing changes.""" + # 16th notes at pulses 0, 6, 12, 18 (one beat of 16ths at 24 PPQN) + steps = _make_steps(0, 6, 12, 18) + g = subsequence.groove.Groove.swing(percent=57.0) - steps = _make_steps(0, 6, 12, 18) - g = subsequence.groove.Groove.swing(percent=50.0) + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) + # On-beat 16ths (pulse 0, 12) should stay put + assert 0 in result + assert 12 in result - assert set(result.keys()) == {0, 6, 12, 18} + # Off-beat 16ths: ideal=6, offset=0.035*24=0.84 → round(6+0.84)=7 + assert 7 in result + # ideal=18, offset=0.035*24=0.84 → round(18+0.84)=19 + assert 19 in result -def test_apply_groove_empty_pattern () -> None: +def test_apply_groove_straight_no_change() -> None: + """50% swing (straight) produces no timing changes.""" - """Empty pattern returns empty result.""" + steps = _make_steps(0, 6, 12, 18) + g = subsequence.groove.Groove.swing(percent=50.0) - g = subsequence.groove.Groove.swing(percent=57.0) - result = subsequence.groove.apply_groove({}, g, pulses_per_quarter=24) - assert result == {} + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) + assert set(result.keys()) == {0, 6, 12, 18} -def test_apply_groove_notes_between_grid_untouched () -> None: - """Notes not close to a grid position are left alone.""" +def test_apply_groove_empty_pattern() -> None: + """Empty pattern returns empty result.""" - # Place a note at pulse 3 — midway between grid positions 0 and 6 - steps = _make_steps(3) - g = subsequence.groove.Groove(offsets=[0.0, 0.1], grid=0.25) + g = subsequence.groove.Groove.swing(percent=57.0) + result = subsequence.groove.apply_groove({}, g, pulses_per_quarter=24) + assert result == {} - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - # Pulse 3 is exactly half a grid cell from each neighbor — should be untouched - assert 3 in result +def test_apply_groove_notes_between_grid_untouched() -> None: + """Notes not close to a grid position are left alone.""" + # Place a note at pulse 3 — midway between grid positions 0 and 6 + steps = _make_steps(3) + g = subsequence.groove.Groove(offsets=[0.0, 0.1], grid=0.25) -def test_apply_groove_velocity_scaling () -> None: + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - """Velocity scaling adjusts note velocities per grid slot.""" + # Pulse 3 is exactly half a grid cell from each neighbor — should be untouched + assert 3 in result - steps = _make_steps(0, 6) - g = subsequence.groove.Groove( - offsets=[0.0, 0.0], - grid=0.25, - velocities=[1.0, 0.5] - ) - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) +def test_apply_groove_velocity_scaling() -> None: + """Velocity scaling adjusts note velocities per grid slot.""" - # Slot 0: velocity unchanged (100 * 1.0 = 100) - assert result[0].notes[0].velocity == 100 - # Slot 1: velocity halved (100 * 0.5 = 50) - assert result[6].notes[0].velocity == 50 + steps = _make_steps(0, 6) + g = subsequence.groove.Groove(offsets=[0.0, 0.0], grid=0.25, velocities=[1.0, 0.5]) + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) -def test_apply_groove_velocity_clamp () -> None: + # Slot 0: velocity unchanged (100 * 1.0 = 100) + assert result[0].notes[0].velocity == 100 + # Slot 1: velocity halved (100 * 0.5 = 50) + assert result[6].notes[0].velocity == 50 - """Velocity is clamped to 1-127.""" - steps = _make_steps(0, velocity=120) - g = subsequence.groove.Groove( - offsets=[0.0], - grid=0.25, - velocities=[2.0] # would produce 240 - ) +def test_apply_groove_velocity_clamp() -> None: + """Velocity is clamped to 1-127.""" - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - assert result[0].notes[0].velocity == 127 + steps = _make_steps(0, velocity=120) + g = subsequence.groove.Groove( + offsets=[0.0], + grid=0.25, + velocities=[2.0], # would produce 240 + ) + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) + assert result[0].notes[0].velocity == 127 -def test_apply_groove_cyclic_repetition () -> None: - """Short offset list repeats cyclically across more grid positions.""" +def test_apply_groove_cyclic_repetition() -> None: + """Short offset list repeats cyclically across more grid positions.""" - # 2-slot pattern applied to 4 beats of 16th notes (16 positions) - pulses = [i * 6 for i in range(16)] # 0, 6, 12, 18, 24, ... - steps = _make_steps(*pulses) - g = subsequence.groove.Groove(offsets=[0.0, 0.035], grid=0.25) + # 2-slot pattern applied to 4 beats of 16th notes (16 positions) + pulses = [i * 6 for i in range(16)] # 0, 6, 12, 18, 24, ... + steps = _make_steps(*pulses) + g = subsequence.groove.Groove(offsets=[0.0, 0.035], grid=0.25) - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - # Every even slot (0, 2, 4, ...) stays; every odd slot shifts - for i in range(0, 16, 2): - assert i * 6 in result, f"Even slot {i} at pulse {i * 6} should be unchanged" + # Every even slot (0, 2, 4, ...) stays; every odd slot shifts + for i in range(0, 16, 2): + assert i * 6 in result, f"Even slot {i} at pulse {i * 6} should be unchanged" -def test_apply_groove_no_negative_pulses () -> None: +def test_apply_groove_no_negative_pulses() -> None: + """Notes at pulse 0 with a negative offset are clamped to 0.""" - """Notes at pulse 0 with a negative offset are clamped to 0.""" + steps = _make_steps(0) + g = subsequence.groove.Groove(offsets=[-0.1], grid=0.25) - steps = _make_steps(0) - g = subsequence.groove.Groove(offsets=[-0.1], grid=0.25) + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) + assert 0 in result - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - assert 0 in result +def test_apply_groove_preserves_note_data() -> None: + """Notes keep their pitch, duration, and channel through the transform.""" -def test_apply_groove_preserves_note_data () -> None: + steps = { + 6: subsequence.pattern.Step( + notes=[ + subsequence.pattern.Note(pitch=48, velocity=90, duration=12, channel=5) + ] + ) + } + g = subsequence.groove.Groove.swing(percent=57.0) - """Notes keep their pitch, duration, and channel through the transform.""" + result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - steps = {6: subsequence.pattern.Step(notes=[ - subsequence.pattern.Note(pitch=48, velocity=90, duration=12, channel=5) - ])} - g = subsequence.groove.Groove.swing(percent=57.0) - - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24) - - # Find the moved note (should be at pulse 7) - assert 7 in result - note = result[7].notes[0] - assert note.pitch == 48 - assert note.duration == 12 - assert note.channel == 5 + # Find the moved note (should be at pulse 7) + assert 7 in result + note = result[7].notes[0] + assert note.pitch == 48 + assert note.duration == 12 + assert note.channel == 5 # ── Groove.from_agr() ─────────────────────────────────────────────── -def test_from_agr_parses_swing_16ths_57 () -> None: - - """The sample .agr file produces the expected groove. - - The asset is tracked at examples/assets/ — a missing file is a real - failure, so there is deliberately no existence guard here. - """ - - agr_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "examples", "assets", "Swing 16ths 57.agr") - - g = subsequence.groove.Groove.from_agr(agr_path) - - # 16 notes in 4 beats → 16th note grid - assert abs(g.grid - 0.25) < 1e-9 - - # 16 offsets - assert len(g.offsets) == 16 - - # Even slots should be ~0, odd slots should be ~0.035 - for i in range(0, 16, 2): - assert abs(g.offsets[i]) < 0.001, f"Even slot {i} offset should be ~0" - for i in range(1, 16, 2): - assert abs(g.offsets[i] - 0.035) < 0.001, f"Odd slot {i} offset should be ~0.035" - - # All velocities are 127 → no velocity variation → None - assert g.velocities is None - -def test_from_agr_matches_swing_factory () -> None: +def test_from_agr_parses_swing_16ths_57() -> None: + """The sample .agr file produces the expected groove. + + The asset is tracked at examples/assets/ — a missing file is a real + failure, so there is deliberately no existence guard here. + """ + + agr_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "examples", + "assets", + "Swing 16ths 57.agr", + ) + + g = subsequence.groove.Groove.from_agr(agr_path) + + # 16 notes in 4 beats → 16th note grid + assert abs(g.grid - 0.25) < 1e-9 + + # 16 offsets + assert len(g.offsets) == 16 + + # Even slots should be ~0, odd slots should be ~0.035 + for i in range(0, 16, 2): + assert abs(g.offsets[i]) < 0.001, f"Even slot {i} offset should be ~0" + for i in range(1, 16, 2): + assert abs(g.offsets[i] - 0.035) < 0.001, ( + f"Odd slot {i} offset should be ~0.035" + ) + + # All velocities are 127 → no velocity variation → None + assert g.velocities is None + + +def test_from_agr_matches_swing_factory() -> None: + """The .agr import and Groove.swing(57) produce equivalent offsets.""" + + agr_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "examples", + "assets", + "Swing 16ths 57.agr", + ) - """The .agr import and Groove.swing(57) produce equivalent offsets.""" + agr_groove = subsequence.groove.Groove.from_agr(agr_path) + factory_groove = subsequence.groove.Groove.swing(percent=57.0) - agr_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "examples", "assets", "Swing 16ths 57.agr") + # The factory produces a 2-slot repeating pattern + # The .agr produces a 16-slot pattern that repeats the same 2-slot pattern + for i in range(16): + slot = i % len(factory_groove.offsets) + assert abs(agr_groove.offsets[i] - factory_groove.offsets[slot]) < 0.001 - agr_groove = subsequence.groove.Groove.from_agr(agr_path) - factory_groove = subsequence.groove.Groove.swing(percent=57.0) - # The factory produces a 2-slot repeating pattern - # The .agr produces a 16-slot pattern that repeats the same 2-slot pattern - for i in range(16): - slot = i % len(factory_groove.offsets) - assert abs(agr_groove.offsets[i] - factory_groove.offsets[slot]) < 0.001 +def _write_agr( + path: str, + timing_amount: float = 100.0, + velocity_amount: float = 100.0, + note_times_and_velocities=None, +) -> None: + """Write a minimal synthetic .agr file for testing.""" + if note_times_and_velocities is None: + note_times_and_velocities = [ + (0.0, 127), + (0.285, 127), + (0.5, 127), + (0.785, 127), + ] -def _write_agr (path: str, timing_amount: float = 100.0, velocity_amount: float = 100.0, note_times_and_velocities = None) -> None: + notes_xml = "\n\t\t\t\t\t\t\t\t\t".join( + f'' + for i, (t, v) in enumerate(note_times_and_velocities) + ) - """Write a minimal synthetic .agr file for testing.""" + content = ( + "\n" + '\n' + "\t\n" + '\t\t\n' + '\t\t\t\n' + f'\t\t\t\n' + '\t\t\t\n' + f"\t\t\t\t\t\t\t\t\t{notes_xml}\n" + "\t\t\t\n" + "\n" + f'\t\t\n' + f'\t\t\n' + "\t\n" + "\n" + ) + with open(path, "w") as f: + f.write(content) - if note_times_and_velocities is None: - note_times_and_velocities = [ - (0.0, 127), (0.285, 127), (0.5, 127), (0.785, 127), - ] - notes_xml = "\n\t\t\t\t\t\t\t\t\t".join( - f'' - for i, (t, v) in enumerate(note_times_and_velocities) - ) +def test_from_agr_timing_amount_scales_offsets() -> None: + """TimingAmount=50 halves all timing offsets relative to TimingAmount=100.""" + + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: + tmp_path = f.name - content = ( - "\n" - "\n" - "\t\n" - "\t\t\n" - "\t\t\t\n" - f"\t\t\t\n" - "\t\t\t\n" - f"\t\t\t\t\t\t\t\t\t{notes_xml}\n" - "\t\t\t\n" - "\n" - f"\t\t\n" - f"\t\t\n" - "\t\n" - "\n" - ) - with open(path, "w") as f: - f.write(content) + try: + _write_agr(tmp_path, timing_amount=100.0) + g_full = subsequence.groove.Groove.from_agr(tmp_path) + _write_agr(tmp_path, timing_amount=50.0) + g_half = subsequence.groove.Groove.from_agr(tmp_path) -def test_from_agr_timing_amount_scales_offsets () -> None: + for i in range(len(g_full.offsets)): + assert abs(g_half.offsets[i] - g_full.offsets[i] * 0.5) < 1e-9, ( + f"Slot {i}: expected half offset, got {g_half.offsets[i]} vs full {g_full.offsets[i]}" + ) + finally: + os.unlink(tmp_path) - """TimingAmount=50 halves all timing offsets relative to TimingAmount=100.""" - import tempfile +def test_from_agr_velocity_amount_zero_gives_no_velocity() -> None: + """VelocityAmount=0 collapses all velocity deviation to None (no variation).""" + + import tempfile + + notes = [(0.0, 127), (0.25, 80), (0.5, 127), (0.75, 80)] - with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: - tmp_path = f.name + with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: + tmp_path = f.name - try: - _write_agr(tmp_path, timing_amount=100.0) - g_full = subsequence.groove.Groove.from_agr(tmp_path) + try: + _write_agr(tmp_path, velocity_amount=0.0, note_times_and_velocities=notes) + g = subsequence.groove.Groove.from_agr(tmp_path) - _write_agr(tmp_path, timing_amount=50.0) - g_half = subsequence.groove.Groove.from_agr(tmp_path) + # velocity_amount=0 → all scales collapse to 1.0 → stored as None + assert g.velocities is None + finally: + os.unlink(tmp_path) + + +def test_from_agr_velocity_amount_50_blends() -> None: + """VelocityAmount=50 produces velocity scales halfway between 1.0 and the raw scale.""" - for i in range(len(g_full.offsets)): - assert abs(g_half.offsets[i] - g_full.offsets[i] * 0.5) < 1e-9, \ - f"Slot {i}: expected half offset, got {g_half.offsets[i]} vs full {g_full.offsets[i]}" - finally: - os.unlink(tmp_path) + import tempfile + # max=127, other=63 → raw_scale ≈ 0.496 + notes = [(0.0, 127), (0.25, 63)] + + with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: + tmp_path = f.name -def test_from_agr_velocity_amount_zero_gives_no_velocity () -> None: + try: + _write_agr(tmp_path, velocity_amount=100.0, note_times_and_velocities=notes) + g_full = subsequence.groove.Groove.from_agr(tmp_path) + + _write_agr(tmp_path, velocity_amount=50.0, note_times_and_velocities=notes) + g_half = subsequence.groove.Groove.from_agr(tmp_path) - """VelocityAmount=0 collapses all velocity deviation to None (no variation).""" + assert g_full.velocities is not None + assert g_half.velocities is not None - import tempfile + for i in range(len(g_full.velocities)): + expected = 1.0 + (g_full.velocities[i] - 1.0) * 0.5 + assert abs(g_half.velocities[i] - expected) < 1e-9, ( + f"Slot {i}: expected {expected}, got {g_half.velocities[i]}" + ) + finally: + os.unlink(tmp_path) - notes = [(0.0, 127), (0.25, 80), (0.5, 127), (0.75, 80)] - with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: - tmp_path = f.name +def test_from_agr_out_of_order_events_keep_velocity_pairing() -> None: + """MidiNoteEvent elements listed out of time order still pair each note time with ITS OWN velocity.""" - try: - _write_agr(tmp_path, velocity_amount=0.0, note_times_and_velocities=notes) - g = subsequence.groove.Groove.from_agr(tmp_path) + import tempfile - # velocity_amount=0 → all scales collapse to 1.0 → stored as None - assert g.velocities is None - finally: - os.unlink(tmp_path) + # Deliberately out of time order, each time with a distinct velocity. + notes = [(0.5, 80), (0.0, 127), (0.785, 90), (0.285, 110)] + with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: + tmp_path = f.name -def test_from_agr_velocity_amount_50_blends () -> None: + try: + _write_agr(tmp_path, note_times_and_velocities=notes) + g = subsequence.groove.Groove.from_agr(tmp_path) - """VelocityAmount=50 produces velocity scales halfway between 1.0 and the raw scale.""" + # Offsets follow time-sorted order: 0.0, 0.285, 0.5, 0.785 on a 0.25 grid. + expected_offsets = [0.0, 0.035, 0.0, 0.035] + for i in range(4): + assert abs(g.offsets[i] - expected_offsets[i]) < 1e-9, ( + f"Slot {i}: expected offset {expected_offsets[i]}, got {g.offsets[i]}" + ) - import tempfile - - # max=127, other=63 → raw_scale ≈ 0.496 - notes = [(0.0, 127), (0.25, 63)] - - with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: - tmp_path = f.name - - try: - _write_agr(tmp_path, velocity_amount=100.0, note_times_and_velocities=notes) - g_full = subsequence.groove.Groove.from_agr(tmp_path) - - _write_agr(tmp_path, velocity_amount=50.0, note_times_and_velocities=notes) - g_half = subsequence.groove.Groove.from_agr(tmp_path) - - assert g_full.velocities is not None - assert g_half.velocities is not None - - for i in range(len(g_full.velocities)): - expected = 1.0 + (g_full.velocities[i] - 1.0) * 0.5 - assert abs(g_half.velocities[i] - expected) < 1e-9, \ - f"Slot {i}: expected {expected}, got {g_half.velocities[i]}" - finally: - os.unlink(tmp_path) - - -def test_from_agr_out_of_order_events_keep_velocity_pairing () -> None: - - """MidiNoteEvent elements listed out of time order still pair each note time with ITS OWN velocity.""" - - import tempfile - - # Deliberately out of time order, each time with a distinct velocity. - notes = [(0.5, 80), (0.0, 127), (0.785, 90), (0.285, 110)] - - with tempfile.NamedTemporaryFile(suffix=".agr", delete=False, mode="w") as f: - tmp_path = f.name - - try: - _write_agr(tmp_path, note_times_and_velocities=notes) - g = subsequence.groove.Groove.from_agr(tmp_path) - - # Offsets follow time-sorted order: 0.0, 0.285, 0.5, 0.785 on a 0.25 grid. - expected_offsets = [0.0, 0.035, 0.0, 0.035] - for i in range(4): - assert abs(g.offsets[i] - expected_offsets[i]) < 1e-9, \ - f"Slot {i}: expected offset {expected_offsets[i]}, got {g.offsets[i]}" - - # Velocities must travel with their times: 127, 110, 80, 90 (each / max 127). - assert g.velocities is not None - expected_velocities = [127 / 127, 110 / 127, 80 / 127, 90 / 127] - for i in range(4): - assert abs(g.velocities[i] - expected_velocities[i]) < 1e-9, \ - f"Slot {i}: expected velocity scale {expected_velocities[i]}, got {g.velocities[i]}" - finally: - os.unlink(tmp_path) + # Velocities must travel with their times: 127, 110, 80, 90 (each / max 127). + assert g.velocities is not None + expected_velocities = [127 / 127, 110 / 127, 80 / 127, 90 / 127] + for i in range(4): + assert abs(g.velocities[i] - expected_velocities[i]) < 1e-9, ( + f"Slot {i}: expected velocity scale {expected_velocities[i]}, got {g.velocities[i]}" + ) + finally: + os.unlink(tmp_path) # ── Validation ─────────────────────────────────────────────────────── -def test_groove_empty_offsets_raises () -> None: - - """Empty offsets list raises ValueError.""" - - with pytest.raises(ValueError): - subsequence.groove.Groove(offsets=[]) +def test_groove_empty_offsets_raises() -> None: + """Empty offsets list raises ValueError.""" + with pytest.raises(ValueError): + subsequence.groove.Groove(offsets=[]) -def test_groove_zero_grid_raises () -> None: - """Zero grid raises ValueError.""" +def test_groove_zero_grid_raises() -> None: + """Zero grid raises ValueError.""" - with pytest.raises(ValueError): - subsequence.groove.Groove(offsets=[0.0], grid=0.0) + with pytest.raises(ValueError): + subsequence.groove.Groove(offsets=[0.0], grid=0.0) -def test_groove_empty_velocities_raises () -> None: +def test_groove_empty_velocities_raises() -> None: + """Empty velocities list raises ValueError (use None instead).""" - """Empty velocities list raises ValueError (use None instead).""" - - with pytest.raises(ValueError): - subsequence.groove.Groove(offsets=[0.0], velocities=[]) + with pytest.raises(ValueError): + subsequence.groove.Groove(offsets=[0.0], velocities=[]) # ── strength parameter ──────────────────────────────────────────────── -def test_apply_groove_strength_zero_no_change () -> None: - - """strength=0.0 leaves timing and velocity completely unchanged.""" - - steps = _make_steps(0, 6, 12, 18) - g = subsequence.groove.Groove.swing(percent=57.0) - - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24, strength=0.0) - - # All original positions preserved - assert set(result.keys()) == {0, 6, 12, 18} +def test_apply_groove_strength_zero_no_change() -> None: + """strength=0.0 leaves timing and velocity completely unchanged.""" -def test_apply_groove_strength_zero_leaves_off_grid_note_untouched () -> None: + steps = _make_steps(0, 6, 12, 18) + g = subsequence.groove.Groove.swing(percent=57.0) - """strength=0.0 leaves a note that sits off the groove grid (pulse 1) exactly where it is.""" + result = subsequence.groove.apply_groove( + steps, g, pulses_per_quarter=24, strength=0.0 + ) - steps = _make_steps(1) # pulse 1 — just off the 16th-note grid - g = subsequence.groove.Groove(offsets=[0.05] * 16, grid=0.25) + # All original positions preserved + assert set(result.keys()) == {0, 6, 12, 18} - result = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24, strength=0.0) - assert set(result.keys()) == {1} - assert result[1].notes[0].velocity == 100 +def test_apply_groove_strength_zero_leaves_off_grid_note_untouched() -> None: + """strength=0.0 leaves a note that sits off the groove grid (pulse 1) exactly where it is.""" + steps = _make_steps(1) # pulse 1 — just off the 16th-note grid + g = subsequence.groove.Groove(offsets=[0.05] * 16, grid=0.25) -def test_apply_groove_strength_half_timing () -> None: + result = subsequence.groove.apply_groove( + steps, g, pulses_per_quarter=24, strength=0.0 + ) - """strength=0.5 produces half the timing offset of strength=1.0.""" + assert set(result.keys()) == {1} + assert result[1].notes[0].velocity == 100 - steps = _make_steps(6) # off-beat 16th - g = subsequence.groove.Groove.swing(percent=57.0) - result_full = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24, strength=1.0) - result_half = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24, strength=0.5) +def test_apply_groove_strength_half_timing() -> None: + """strength=0.5 produces half the timing offset of strength=1.0.""" - # Full: pulse 6 moves to 7 (offset ≈ +0.84 pulses → rounds to 1) - # Half: pulse 6 moves by half that offset → offset ≈ +0.42 → rounds to 0 → stays at 6 - # The key thing is that half is closer to 6 than full - full_pulse = list(result_full.keys())[0] - half_pulse = list(result_half.keys())[0] + steps = _make_steps(6) # off-beat 16th + g = subsequence.groove.Groove.swing(percent=57.0) - # Half-strength offset is less than or equal to full-strength offset - assert abs(half_pulse - 6) <= abs(full_pulse - 6) + result_full = subsequence.groove.apply_groove( + steps, g, pulses_per_quarter=24, strength=1.0 + ) + result_half = subsequence.groove.apply_groove( + steps, g, pulses_per_quarter=24, strength=0.5 + ) + # Full: pulse 6 moves to 7 (offset ≈ +0.84 pulses → rounds to 1) + # Half: pulse 6 moves by half that offset → offset ≈ +0.42 → rounds to 0 → stays at 6 + # The key thing is that half is closer to 6 than full + full_pulse = list(result_full.keys())[0] + half_pulse = list(result_half.keys())[0] -def test_apply_groove_strength_velocity_blend () -> None: + # Half-strength offset is less than or equal to full-strength offset + assert abs(half_pulse - 6) <= abs(full_pulse - 6) - """strength blends velocity deviation: 0.0 = no change, 1.0 = full scale.""" - steps = _make_steps(0, velocity=100) - g = subsequence.groove.Groove( - offsets=[0.0], - grid=0.25, - velocities=[0.5], # full strength would halve velocity to 50 - ) +def test_apply_groove_strength_velocity_blend() -> None: + """strength blends velocity deviation: 0.0 = no change, 1.0 = full scale.""" - result_full = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24, strength=1.0) - result_half = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24, strength=0.5) - result_zero = subsequence.groove.apply_groove(steps, g, pulses_per_quarter=24, strength=0.0) + steps = _make_steps(0, velocity=100) + g = subsequence.groove.Groove( + offsets=[0.0], + grid=0.25, + velocities=[0.5], # full strength would halve velocity to 50 + ) - vel_full = result_full[0].notes[0].velocity # 100 * 0.5 = 50 - vel_half = result_half[0].notes[0].velocity # 100 * 0.75 = 75 (blended halfway) - vel_zero = result_zero[0].notes[0].velocity # 100 (unchanged) + result_full = subsequence.groove.apply_groove( + steps, g, pulses_per_quarter=24, strength=1.0 + ) + result_half = subsequence.groove.apply_groove( + steps, g, pulses_per_quarter=24, strength=0.5 + ) + result_zero = subsequence.groove.apply_groove( + steps, g, pulses_per_quarter=24, strength=0.0 + ) - assert vel_full == 50 - assert vel_half == 75 - assert vel_zero == 100 + vel_full = result_full[0].notes[0].velocity # 100 * 0.5 = 50 + vel_half = result_half[0].notes[0].velocity # 100 * 0.75 = 75 (blended halfway) + vel_zero = result_zero[0].notes[0].velocity # 100 (unchanged) + assert vel_full == 50 + assert vel_half == 75 + assert vel_zero == 100 -def test_apply_groove_strength_out_of_range_raises () -> None: - """strength outside 0.0-1.0 raises ValueError.""" +def test_apply_groove_strength_out_of_range_raises() -> None: + """strength outside 0.0-1.0 raises ValueError.""" - g = subsequence.groove.Groove.swing(percent=57.0) + g = subsequence.groove.Groove.swing(percent=57.0) - with pytest.raises(ValueError): - subsequence.groove.apply_groove({}, g, strength=-0.1) + with pytest.raises(ValueError): + subsequence.groove.apply_groove({}, g, strength=-0.1) - with pytest.raises(ValueError): - subsequence.groove.apply_groove({}, g, strength=1.1) + with pytest.raises(ValueError): + subsequence.groove.apply_groove({}, g, strength=1.1) diff --git a/tests/test_harmonic_gravity.py b/tests/test_harmonic_gravity.py index 9f2cc46..105c11b 100644 --- a/tests/test_harmonic_gravity.py +++ b/tests/test_harmonic_gravity.py @@ -1,4 +1,3 @@ - import collections import random @@ -8,367 +7,360 @@ import subsequence.harmonic_state -def test_nir_history_tracking () -> None: - - hs = subsequence.harmonic_state.HarmonicState(key_name="C") - - # Simulate a sequence: C -> F -> G - c = subsequence.chords.Chord(root_pc=0, quality="major") - f = subsequence.chords.Chord(root_pc=5, quality="major") - g = subsequence.chords.Chord(root_pc=7, quality="major") - - # Manually inject history for testing - hs.current_chord = c - hs.step() # transition to something (mocked) - - # Step simulation - hs.current_chord = f - hs.step() +def test_nir_history_tracking() -> None: + hs = subsequence.harmonic_state.HarmonicState(key_name="C") - assert len(hs.history) > 0 - assert hs.history[-1] == f + # Simulate a sequence: C -> F -> G + c = subsequence.chords.Chord(root_pc=0, quality="major") + f = subsequence.chords.Chord(root_pc=5, quality="major") + g = subsequence.chords.Chord(root_pc=7, quality="major") + # Manually inject history for testing + hs.current_chord = c + hs.step() # transition to something (mocked) -def test_calculate_nir_score_reversal () -> None: + # Step simulation + hs.current_chord = f + hs.step() - """ - Test Rule A: Reversal (Gap Fill) - If previous move was a Large Leap (> 5 semitones), - BOOST targets that change direction. - """ + assert len(hs.history) > 0 + assert hs.history[-1] == f - hs = subsequence.harmonic_state.HarmonicState(key_name="C") - # History: C (0) -> G (7) - # Modulo 12 shortest path: 7 - 0 = +7. >6 so 7-12 = -5. - # Interval = 5 (Large Leap). Direction = DOWN. - c = subsequence.chords.Chord(root_pc=0, quality="major") - g = subsequence.chords.Chord(root_pc=7, quality="major") +def test_calculate_nir_score_reversal() -> None: + """ + Test Rule A: Reversal (Gap Fill) + If previous move was a Large Leap (> 5 semitones), + BOOST targets that change direction. + """ - # step() appends the source before scoring, so history is [prev, source]. - hs.history = [c, g] - source = g + hs = subsequence.harmonic_state.HarmonicState(key_name="C") - # Target 1: A (9). G->A is +2. - # Direction = UP (Opposite to prev DOWN). - # Interval = 2 (Small). - # Reversal Expects: Change Direction (Yes) AND Small Interval (Yes). - # Should get MAX boost. - target_reversal = subsequence.chords.Chord(root_pc=9, quality="minor") + # History: C (0) -> G (7) + # Modulo 12 shortest path: 7 - 0 = +7. >6 so 7-12 = -5. + # Interval = 5 (Large Leap). Direction = DOWN. + c = subsequence.chords.Chord(root_pc=0, quality="major") + g = subsequence.chords.Chord(root_pc=7, quality="major") - # Target 2: E (4). G->E is -3. - # Direction = DOWN (Same as prev). - # Interval = 3. - # Should get NO boost. - target_continuation = subsequence.chords.Chord(root_pc=4, quality="minor") + # step() appends the source before scoring, so history is [prev, source]. + hs.history = [c, g] + source = g - score_rev = hs._calculate_nir_score(source, target_reversal) - score_cont = hs._calculate_nir_score(source, target_continuation) + # Target 1: A (9). G->A is +2. + # Direction = UP (Opposite to prev DOWN). + # Interval = 2 (Small). + # Reversal Expects: Change Direction (Yes) AND Small Interval (Yes). + # Should get MAX boost. + target_reversal = subsequence.chords.Chord(root_pc=9, quality="minor") - assert score_rev > 1.0 - assert score_rev > score_cont + # Target 2: E (4). G->E is -3. + # Direction = DOWN (Same as prev). + # Interval = 3. + # Should get NO boost. + target_continuation = subsequence.chords.Chord(root_pc=4, quality="minor") + score_rev = hs._calculate_nir_score(source, target_reversal) + score_cont = hs._calculate_nir_score(source, target_continuation) -def test_calculate_nir_score_process () -> None: + assert score_rev > 1.0 + assert score_rev > score_cont - """ - Test Rule B: Process (Continuation) - If previous move was a Small Step (< 4 semitones), - BOOST targets that continue in the Same Direction. - """ - hs = subsequence.harmonic_state.HarmonicState(key_name="C") +def test_calculate_nir_score_process() -> None: + """ + Test Rule B: Process (Continuation) + If previous move was a Small Step (< 4 semitones), + BOOST targets that continue in the Same Direction. + """ - # History: C (0) -> D (2) = +2 semitones (Small Step Up) - c = subsequence.chords.Chord(root_pc=0, quality="major") - d = subsequence.chords.Chord(root_pc=2, quality="minor") + hs = subsequence.harmonic_state.HarmonicState(key_name="C") - # step() appends the source before scoring, so history is [prev, source]. - hs.history = [c, d] - source = d + # History: C (0) -> D (2) = +2 semitones (Small Step Up) + c = subsequence.chords.Chord(root_pc=0, quality="major") + d = subsequence.chords.Chord(root_pc=2, quality="minor") - # Target 1: E (4) = +2 semitones (Continuation Up) -> Expect BOOST - target_continuation = subsequence.chords.Chord(root_pc=4, quality="minor") + # step() appends the source before scoring, so history is [prev, source]. + hs.history = [c, d] + source = d - # Target 2: B (11) = -3 semitones (Reversal Down) -> Expect NEUTRAL - target_reversal = subsequence.chords.Chord(root_pc=11, quality="diminished") + # Target 1: E (4) = +2 semitones (Continuation Up) -> Expect BOOST + target_continuation = subsequence.chords.Chord(root_pc=4, quality="minor") - score_cont = hs._calculate_nir_score(source, target_continuation) - score_rev = hs._calculate_nir_score(source, target_reversal) + # Target 2: B (11) = -3 semitones (Reversal Down) -> Expect NEUTRAL + target_reversal = subsequence.chords.Chord(root_pc=11, quality="diminished") - assert score_cont > 1.0 - assert score_cont > score_rev + score_cont = hs._calculate_nir_score(source, target_continuation) + score_rev = hs._calculate_nir_score(source, target_reversal) + assert score_cont > 1.0 + assert score_cont > score_rev -def test_calculate_nir_score_closure () -> None: - """ - Test Rule C: Closure - Return to Tonic implies closure and should be boosted if other conditions match. - """ +def test_calculate_nir_score_closure() -> None: + """ + Test Rule C: Closure + Return to Tonic implies closure and should be boosted if other conditions match. + """ - hs = subsequence.harmonic_state.HarmonicState(key_name="C") + hs = subsequence.harmonic_state.HarmonicState(key_name="C") - # History: C (0) -> E (4). - # Interval = 4 (Neutral - neither Small Step <3 nor Large Leap >4). - # Rule A and Rule B should NOT fire. - c = subsequence.chords.Chord(root_pc=0, quality="major") - e = subsequence.chords.Chord(root_pc=4, quality="minor") + # History: C (0) -> E (4). + # Interval = 4 (Neutral - neither Small Step <3 nor Large Leap >4). + # Rule A and Rule B should NOT fire. + c = subsequence.chords.Chord(root_pc=0, quality="major") + e = subsequence.chords.Chord(root_pc=4, quality="minor") - hs.history = [c, e] - source = e + hs.history = [c, e] + source = e - # Target 1: C (0). E->C is -4. Neutral interval. - # Gets Closure Boost (+0.2). - target_tonic = subsequence.chords.Chord(root_pc=0, quality="major") + # Target 1: C (0). E->C is -4. Neutral interval. + # Gets Closure Boost (+0.2). + target_tonic = subsequence.chords.Chord(root_pc=0, quality="major") - # Target 2: G# (8). E->G# is +4. Neutral interval. - # No Closure Boost. - target_other = subsequence.chords.Chord(root_pc=8, quality="major") + # Target 2: G# (8). E->G# is +4. Neutral interval. + # No Closure Boost. + target_other = subsequence.chords.Chord(root_pc=8, quality="major") - score_tonic = hs._calculate_nir_score(source, target_tonic) - score_other = hs._calculate_nir_score(source, target_other) + score_tonic = hs._calculate_nir_score(source, target_tonic) + score_other = hs._calculate_nir_score(source, target_other) - # Tonic return usually gets a small boost for closure - assert score_tonic > score_other + # Tonic return usually gets a small boost for closure + assert score_tonic > score_other -def test_calculate_nir_score_proximity () -> None: +def test_calculate_nir_score_proximity() -> None: + """ + Test Rule D: Proximity + Small intervals (≤ 3 semitones) get a general boost. + """ - """ - Test Rule D: Proximity - Small intervals (≤ 3 semitones) get a general boost. - """ + hs = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=1.0) - hs = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=1.0) + # History: C (0) -> E (4). Interval = 4 (neutral zone). + c = subsequence.chords.Chord(root_pc=0, quality="major") + e = subsequence.chords.Chord(root_pc=4, quality="minor") - # History: C (0) -> E (4). Interval = 4 (neutral zone). - c = subsequence.chords.Chord(root_pc=0, quality="major") - e = subsequence.chords.Chord(root_pc=4, quality="minor") + hs.history = [c, e] + source = e - hs.history = [c, e] - source = e + # Target 1: F (5). E->F is +1 semitone (small, proximate). + target_close = subsequence.chords.Chord(root_pc=5, quality="major") - # Target 1: F (5). E->F is +1 semitone (small, proximate). - target_close = subsequence.chords.Chord(root_pc=5, quality="major") + # Target 2: Bb (10). E->Bb is +6 semitones (large, not proximate). + target_far = subsequence.chords.Chord(root_pc=10, quality="major") - # Target 2: Bb (10). E->Bb is +6 semitones (large, not proximate). - target_far = subsequence.chords.Chord(root_pc=10, quality="major") + score_close = hs._calculate_nir_score(source, target_close) + score_far = hs._calculate_nir_score(source, target_far) - score_close = hs._calculate_nir_score(source, target_close) - score_far = hs._calculate_nir_score(source, target_far) + assert score_close > 1.0 + assert score_close > score_far - assert score_close > 1.0 - assert score_close > score_far +def test_nir_strength_zero_disables() -> None: + """With nir_strength=0.0, all NIR scores should return 1.0 (neutral).""" -def test_nir_strength_zero_disables () -> None: + hs = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=0.0) - """With nir_strength=0.0, all NIR scores should return 1.0 (neutral).""" + c = subsequence.chords.Chord(root_pc=0, quality="major") + d = subsequence.chords.Chord(root_pc=2, quality="minor") + e = subsequence.chords.Chord(root_pc=4, quality="minor") - hs = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=0.0) + hs.history = [c, d] - c = subsequence.chords.Chord(root_pc=0, quality="major") - d = subsequence.chords.Chord(root_pc=2, quality="minor") - e = subsequence.chords.Chord(root_pc=4, quality="minor") + # C -> D is a small step (+2), D -> E continues (+2). + # At full strength this would get boosted. At 0.0 it should be neutral. + score = hs._calculate_nir_score(d, e) + assert score == 1.0 - hs.history = [c, d] - # C -> D is a small step (+2), D -> E continues (+2). - # At full strength this would get boosted. At 0.0 it should be neutral. - score = hs._calculate_nir_score(d, e) - assert score == 1.0 +def test_nir_strength_scales_boost() -> None: + """With nir_strength=0.5, boosts should be halved compared to nir_strength=1.0.""" + c = subsequence.chords.Chord(root_pc=0, quality="major") + d = subsequence.chords.Chord(root_pc=2, quality="minor") + e = subsequence.chords.Chord(root_pc=4, quality="minor") -def test_nir_strength_scales_boost () -> None: + hs_full = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=1.0) + hs_full.history = [c, d] + score_full = hs_full._calculate_nir_score(d, e) - """With nir_strength=0.5, boosts should be halved compared to nir_strength=1.0.""" + hs_half = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=0.5) + hs_half.history = [c, d] + score_half = hs_half._calculate_nir_score(d, e) - c = subsequence.chords.Chord(root_pc=0, quality="major") - d = subsequence.chords.Chord(root_pc=2, quality="minor") - e = subsequence.chords.Chord(root_pc=4, quality="minor") + # Both should be boosted above 1.0 + assert score_full > 1.0 + assert score_half > 1.0 - hs_full = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=1.0) - hs_full.history = [c, d] - score_full = hs_full._calculate_nir_score(d, e) - - hs_half = subsequence.harmonic_state.HarmonicState(key_name="C", nir_strength=0.5) - hs_half.history = [c, d] - score_half = hs_half._calculate_nir_score(d, e) - - # Both should be boosted above 1.0 - assert score_full > 1.0 - assert score_half > 1.0 - - # Half-strength boost should be half the full-strength boost - boost_full = score_full - 1.0 - boost_half = score_half - 1.0 - assert abs(boost_half - boost_full * 0.5) < 0.001 + # Half-strength boost should be half the full-strength boost + boost_full = score_full - 1.0 + boost_half = score_half - 1.0 + assert abs(boost_half - boost_full * 0.5) < 0.001 # --- Root Diversity --- -def test_root_diversity_reduces_same_root_frequency () -> None: - - """The suspended graph at gravity=0.0 should no longer get stuck on one root.""" - - rng = random.Random(42) - - hs = subsequence.harmonic_state.HarmonicState( - key_name = "C", - graph_style = "suspended", - key_gravity_blend = 0.0, - nir_strength = 0.5, - rng = rng - ) - - roots: typing.List[int] = [] - - for _ in range(500): - chord = hs.step() - roots.append(chord.root_pc) - - counts = collections.Counter(roots) - top_pct = counts.most_common(1)[0][1] / len(roots) - - # Was 0.63 before the fix; should now stay below 0.50. - assert top_pct < 0.50 - - -def test_root_diversity_does_not_suppress_entirely () -> None: - - """Even with 4 same-root chords in history, step() should still return a chord.""" - - rng = random.Random(99) - - hs = subsequence.harmonic_state.HarmonicState( - key_name = "C", - graph_style = "suspended", - key_gravity_blend = 0.0, - nir_strength = 0.5, - rng = rng - ) +def test_root_diversity_reduces_same_root_frequency() -> None: + """The suspended graph at gravity=0.0 should no longer get stuck on one root.""" - # Fill history with 4 C-root chords. - c_sus2 = subsequence.chords.Chord(root_pc=0, quality="sus2") - c_sus4 = subsequence.chords.Chord(root_pc=0, quality="sus4") - hs.history = [c_sus2, c_sus4, c_sus2, c_sus4] - hs.current_chord = c_sus2 + rng = random.Random(42) - # Should still produce a valid chord (modifier is 0.4^4 ≈ 0.026, not 0). - result = hs.step() + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", + graph_style="suspended", + key_gravity_blend=0.0, + nir_strength=0.5, + rng=rng, + ) - assert isinstance(result, subsequence.chords.Chord) + roots: typing.List[int] = [] + for _ in range(500): + chord = hs.step() + roots.append(chord.root_pc) -def test_root_diversity_counts_root_not_quality () -> None: + counts = collections.Counter(roots) + top_pct = counts.most_common(1)[0][1] / len(roots) - """History with Csus2 and Csus4 should both count toward the C-root penalty.""" + # Was 0.63 before the fix; should now stay below 0.50. + assert top_pct < 0.50 - rng = random.Random(42) - hs = subsequence.harmonic_state.HarmonicState( - key_name = "C", - graph_style = "suspended", - key_gravity_blend = 1.0, - nir_strength = 0.0, - rng = rng - ) +def test_root_diversity_does_not_suppress_entirely() -> None: + """Even with 4 same-root chords in history, step() should still return a chord.""" - # History: two different qualities on root C. - c_sus2 = subsequence.chords.Chord(root_pc=0, quality="sus2") - c_sus4 = subsequence.chords.Chord(root_pc=0, quality="sus4") - hs.history = [c_sus2, c_sus4] - hs.current_chord = c_sus4 + rng = random.Random(99) - # Run many steps from this state and count C-root results. - c_root_count = 0 - trials = 200 + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", + graph_style="suspended", + key_gravity_blend=0.0, + nir_strength=0.5, + rng=rng, + ) - for _ in range(trials): - # Reset to the same state each trial. - hs.history = [c_sus2, c_sus4] - hs.current_chord = c_sus4 - result = hs.step() + # Fill history with 4 C-root chords. + c_sus2 = subsequence.chords.Chord(root_pc=0, quality="sus2") + c_sus4 = subsequence.chords.Chord(root_pc=0, quality="sus4") + hs.history = [c_sus2, c_sus4, c_sus2, c_sus4] + hs.current_chord = c_sus2 - if result.root_pc == 0: - c_root_count += 1 + # Should still produce a valid chord (modifier is 0.4^4 ≈ 0.026, not 0). + result = hs.step() - # With 2 C-root entries in history, the penalty is 0.4² = 0.16×. - # C-root should appear far less than half the time. - assert c_root_count / trials < 0.45 + assert isinstance(result, subsequence.chords.Chord) -def test_root_diversity_disabled_at_one () -> None: +def test_root_diversity_counts_root_not_quality() -> None: + """History with Csus2 and Csus4 should both count toward the C-root penalty.""" - """Setting root_diversity=1.0 should disable the penalty entirely.""" + rng = random.Random(42) - rng_a = random.Random(42) - rng_b = random.Random(42) + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", + graph_style="suspended", + key_gravity_blend=1.0, + nir_strength=0.0, + rng=rng, + ) - # With penalty (default 0.5) - hs_with = subsequence.harmonic_state.HarmonicState( - key_name = "C", - graph_style = "suspended", - key_gravity_blend = 0.0, - nir_strength = 0.5, - root_diversity = 0.5, - rng = rng_a - ) + # History: two different qualities on root C. + c_sus2 = subsequence.chords.Chord(root_pc=0, quality="sus2") + c_sus4 = subsequence.chords.Chord(root_pc=0, quality="sus4") + hs.history = [c_sus2, c_sus4] + hs.current_chord = c_sus4 - # Without penalty (1.0 = disabled) - hs_without = subsequence.harmonic_state.HarmonicState( - key_name = "C", - graph_style = "suspended", - key_gravity_blend = 0.0, - nir_strength = 0.5, - root_diversity = 1.0, - rng = rng_b - ) + # Run many steps from this state and count C-root results. + c_root_count = 0 + trials = 200 - roots_with: typing.List[int] = [] - roots_without: typing.List[int] = [] + for _ in range(trials): + # Reset to the same state each trial. + hs.history = [c_sus2, c_sus4] + hs.current_chord = c_sus4 + result = hs.step() - for _ in range(200): - roots_with.append(hs_with.step().root_pc) - roots_without.append(hs_without.step().root_pc) + if result.root_pc == 0: + c_root_count += 1 - top_with = collections.Counter(roots_with).most_common(1)[0][1] / 200 - top_without = collections.Counter(roots_without).most_common(1)[0][1] / 200 + # With 2 C-root entries in history, the penalty is 0.4² = 0.16×. + # C-root should appear far less than half the time. + assert c_root_count / trials < 0.45 - # Disabled penalty should produce more concentrated distribution. - assert top_without > top_with +def test_root_diversity_disabled_at_one() -> None: + """Setting root_diversity=1.0 should disable the penalty entirely.""" -def test_step_scoring_sees_distinct_previous_chord () -> None: + rng_a = random.Random(42) + rng_b = random.Random(42) - """During step(), the NIR scorer must compare against the chord BEFORE the - current one - not the current chord itself. + # With penalty (default 0.5) + hs_with = subsequence.harmonic_state.HarmonicState( + key_name="C", + graph_style="suspended", + key_gravity_blend=0.0, + nir_strength=0.5, + root_diversity=0.5, + rng=rng_a, + ) - Regression: step() appends the current chord to history before choosing, - so reading history[-1] as "previous" made prev == source on every call and - left the reversal/continuation rules permanently inert (pre-2026-06 bug). - """ + # Without penalty (1.0 = disabled) + hs_without = subsequence.harmonic_state.HarmonicState( + key_name="C", + graph_style="suspended", + key_gravity_blend=0.0, + nir_strength=0.5, + root_diversity=1.0, + rng=rng_b, + ) - rng = random.Random(5) - hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="functional_major", rng=rng) + roots_with: typing.List[int] = [] + roots_without: typing.List[int] = [] - calls = [] - original = hs._calculate_nir_score + for _ in range(200): + roots_with.append(hs_with.step().root_pc) + roots_without.append(hs_without.step().root_pc) - def spy (source: subsequence.chords.Chord, target: subsequence.chords.Chord) -> float: - calls.append((list(hs.history), source)) - return original(source, target) + top_with = collections.Counter(roots_with).most_common(1)[0][1] / 200 + top_without = collections.Counter(roots_without).most_common(1)[0][1] / 200 - hs._calculate_nir_score = spy # type: ignore[method-assign] + # Disabled penalty should produce more concentrated distribution. + assert top_without > top_with + + +def test_step_scoring_sees_distinct_previous_chord() -> None: + """During step(), the NIR scorer must compare against the chord BEFORE the + current one - not the current chord itself. + + Regression: step() appends the current chord to history before choosing, + so reading history[-1] as "previous" made prev == source on every call and + left the reversal/continuation rules permanently inert (pre-2026-06 bug). + """ - for _ in range(30): - hs.step() + rng = random.Random(5) + hs = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="functional_major", rng=rng + ) - distinct = [ - 1 for history, source in calls - if len(history) >= 2 and history[-2] != source - ] + calls = [] + original = hs._calculate_nir_score - assert distinct, "scorer never saw a previous chord distinct from the source - implication rules are inert" + def spy( + source: subsequence.chords.Chord, target: subsequence.chords.Chord + ) -> float: + calls.append((list(hs.history), source)) + return original(source, target) + + hs._calculate_nir_score = spy # type: ignore[method-assign] + + for _ in range(30): + hs.step() + + distinct = [ + 1 for history, source in calls if len(history) >= 2 and history[-2] != source + ] + + assert distinct, ( + "scorer never saw a previous chord distinct from the source - implication rules are inert" + ) diff --git a/tests/test_harmony.py b/tests/test_harmony.py index 5ba1fcf..622abee 100644 --- a/tests/test_harmony.py +++ b/tests/test_harmony.py @@ -11,230 +11,236 @@ # diatonic_chords() tests # --------------------------------------------------------------------------- -def test_diatonic_chords_c_major () -> None: - """C Ionian should produce the standard major-key triads.""" +def test_diatonic_chords_c_major() -> None: + """C Ionian should produce the standard major-key triads.""" - chords = subsequence.harmony.diatonic_chords("C", mode="ionian") + chords = subsequence.harmony.diatonic_chords("C", mode="ionian") - assert len(chords) == 7 + assert len(chords) == 7 - # Root pitch classes: C D E F G A B = 0 2 4 5 7 9 11 - expected_pcs = [0, 2, 4, 5, 7, 9, 11] - expected_qualities = subsequence.intervals.IONIAN_QUALITIES + # Root pitch classes: C D E F G A B = 0 2 4 5 7 9 11 + expected_pcs = [0, 2, 4, 5, 7, 9, 11] + expected_qualities = subsequence.intervals.IONIAN_QUALITIES - for chord, pc, quality in zip(chords, expected_pcs, expected_qualities): - assert chord.root_pc == pc - assert chord.quality == quality + for chord, pc, quality in zip(chords, expected_pcs, expected_qualities): + assert chord.root_pc == pc + assert chord.quality == quality -def test_diatonic_chords_major_alias () -> None: +def test_diatonic_chords_major_alias() -> None: + """'major' should be an alias for 'ionian'.""" - """'major' should be an alias for 'ionian'.""" + assert subsequence.harmony.diatonic_chords( + "C", "major" + ) == subsequence.harmony.diatonic_chords("C", "ionian") - assert subsequence.harmony.diatonic_chords("C", "major") == subsequence.harmony.diatonic_chords("C", "ionian") +def test_diatonic_chords_minor_alias() -> None: + """'minor' should be an alias for 'aeolian'.""" -def test_diatonic_chords_minor_alias () -> None: + assert subsequence.harmony.diatonic_chords( + "A", "minor" + ) == subsequence.harmony.diatonic_chords("A", "aeolian") - """'minor' should be an alias for 'aeolian'.""" - assert subsequence.harmony.diatonic_chords("A", "minor") == subsequence.harmony.diatonic_chords("A", "aeolian") +def test_diatonic_chords_a_minor() -> None: + """A Aeolian should produce natural minor triads.""" + chords = subsequence.harmony.diatonic_chords("A", mode="minor") -def test_diatonic_chords_a_minor () -> None: + assert len(chords) == 7 - """A Aeolian should produce natural minor triads.""" + # A natural minor: A B C D E F G = 9 11 0 2 4 5 7 + expected_pcs = [9, 11, 0, 2, 4, 5, 7] + expected_qualities = subsequence.intervals.AEOLIAN_QUALITIES - chords = subsequence.harmony.diatonic_chords("A", mode="minor") + for chord, pc, quality in zip(chords, expected_pcs, expected_qualities): + assert chord.root_pc == pc + assert chord.quality == quality - assert len(chords) == 7 - # A natural minor: A B C D E F G = 9 11 0 2 4 5 7 - expected_pcs = [9, 11, 0, 2, 4, 5, 7] - expected_qualities = subsequence.intervals.AEOLIAN_QUALITIES +def test_diatonic_chords_all_modes() -> None: + """Every mode with chord qualities should return chords without error.""" - for chord, pc, quality in zip(chords, expected_pcs, expected_qualities): - assert chord.root_pc == pc - assert chord.quality == quality + for mode_name, (_, qualities) in subsequence.intervals.SCALE_MODE_MAP.items(): + if qualities is None: + continue + chords = subsequence.harmony.diatonic_chords("C", mode=mode_name) + assert len(chords) == len(qualities), f"{mode_name} chord count mismatch" -def test_diatonic_chords_all_modes () -> None: +def test_diatonic_chords_invalid_mode() -> None: + """An unknown mode should raise ValueError.""" - """Every mode with chord qualities should return chords without error.""" + with pytest.raises(ValueError, match="Unknown mode"): + subsequence.harmony.diatonic_chords("C", mode="bebop") - for mode_name, (_, qualities) in subsequence.intervals.SCALE_MODE_MAP.items(): - if qualities is None: - continue - chords = subsequence.harmony.diatonic_chords("C", mode=mode_name) - assert len(chords) == len(qualities), f"{mode_name} chord count mismatch" +def test_diatonic_chords_invalid_key() -> None: + """An unknown key name should raise ValueError.""" -def test_diatonic_chords_invalid_mode () -> None: - - """An unknown mode should raise ValueError.""" - - with pytest.raises(ValueError, match="Unknown mode"): - subsequence.harmony.diatonic_chords("C", mode="bebop") - - -def test_diatonic_chords_invalid_key () -> None: - - """An unknown key name should raise ValueError.""" - - with pytest.raises(ValueError, match="Unknown key name"): - subsequence.harmony.diatonic_chords("Z", mode="ionian") + with pytest.raises(ValueError, match="Unknown key name"): + subsequence.harmony.diatonic_chords("Z", mode="ionian") # --------------------------------------------------------------------------- # diatonic_chord() tests # --------------------------------------------------------------------------- -def test_diatonic_chord_default_is_tonic () -> None: - - """Default degree (0) should return the tonic chord.""" - - tonic = subsequence.harmony.diatonic_chord("C") - all_chords = subsequence.harmony.diatonic_chords("C") - assert tonic == all_chords[0] +def test_diatonic_chord_default_is_tonic() -> None: + """Default degree (0) should return the tonic chord.""" -def test_diatonic_chord_degree () -> None: + tonic = subsequence.harmony.diatonic_chord("C") + all_chords = subsequence.harmony.diatonic_chords("C") + assert tonic == all_chords[0] - """Explicit degree should return the matching chord.""" - dominant = subsequence.harmony.diatonic_chord("C", degree=4) - all_chords = subsequence.harmony.diatonic_chords("C") - assert dominant == all_chords[4] +def test_diatonic_chord_degree() -> None: + """Explicit degree should return the matching chord.""" + dominant = subsequence.harmony.diatonic_chord("C", degree=4) + all_chords = subsequence.harmony.diatonic_chords("C") + assert dominant == all_chords[4] -def test_diatonic_chord_out_of_range () -> None: - """Degree outside the scale should raise ValueError.""" +def test_diatonic_chord_out_of_range() -> None: + """Degree outside the scale should raise ValueError.""" - with pytest.raises(ValueError, match="degree 7 out of range"): - subsequence.harmony.diatonic_chord("C", degree=7) + with pytest.raises(ValueError, match="degree 7 out of range"): + subsequence.harmony.diatonic_chord("C", degree=7) - with pytest.raises(ValueError, match="degree -1 out of range"): - subsequence.harmony.diatonic_chord("C", degree=-1) + with pytest.raises(ValueError, match="degree -1 out of range"): + subsequence.harmony.diatonic_chord("C", degree=-1) # --------------------------------------------------------------------------- # diatonic_chord_sequence() tests # --------------------------------------------------------------------------- -def test_diatonic_chord_sequence_d_major () -> None: - - """D major from MIDI 50 should return correct roots for all 7 degrees.""" - - seq = subsequence.harmony.diatonic_chord_sequence("D", root_midi=50, count=7) - - assert len(seq) == 7 - - # D major scale: D E F# G A B C# → intervals 0 2 4 5 7 9 11 - expected_roots = [50, 52, 54, 55, 57, 59, 61] - expected_qualities = subsequence.intervals.IONIAN_QUALITIES - - for (chord, root), exp_root, exp_quality in zip(seq, expected_roots, expected_qualities): - assert root == exp_root - assert chord.quality == exp_quality - - -def test_diatonic_chord_sequence_octave_wrap () -> None: - """Step 8 (count=8) should wrap into the next octave.""" +def test_diatonic_chord_sequence_d_major() -> None: + """D major from MIDI 50 should return correct roots for all 7 degrees.""" - seq = subsequence.harmony.diatonic_chord_sequence("D", root_midi=50, count=8) + seq = subsequence.harmony.diatonic_chord_sequence("D", root_midi=50, count=7) - assert len(seq) == 8 - # 8th entry wraps: D3 (50) + 12 = D4 (62) - _, eighth_root = seq[7] - assert eighth_root == 62 + assert len(seq) == 7 + # D major scale: D E F# G A B C# → intervals 0 2 4 5 7 9 11 + expected_roots = [50, 52, 54, 55, 57, 59, 61] + expected_qualities = subsequence.intervals.IONIAN_QUALITIES -def test_diatonic_chord_sequence_count_one () -> None: + for (chord, root), exp_root, exp_quality in zip( + seq, expected_roots, expected_qualities + ): + assert root == exp_root + assert chord.quality == exp_quality - """count=1 should return exactly the starting chord.""" - seq = subsequence.harmony.diatonic_chord_sequence("C", root_midi=60, count=1) +def test_diatonic_chord_sequence_octave_wrap() -> None: + """Step 8 (count=8) should wrap into the next octave.""" - assert len(seq) == 1 - chord, root = seq[0] - assert root == 60 - assert chord.quality == "major" + seq = subsequence.harmony.diatonic_chord_sequence("D", root_midi=50, count=8) + assert len(seq) == 8 + # 8th entry wraps: D3 (50) + 12 = D4 (62) + _, eighth_root = seq[7] + assert eighth_root == 62 -def test_diatonic_chord_sequence_invalid_root_midi () -> None: - """A chromatic note not in the scale should raise ValueError.""" +def test_diatonic_chord_sequence_count_one() -> None: + """count=1 should return exactly the starting chord.""" - with pytest.raises(ValueError, match="not a scale degree"): - # C natural (MIDI 48, pitch class 0) is not a degree of D major (which has C#) - subsequence.harmony.diatonic_chord_sequence("D", root_midi=48, count=4) + seq = subsequence.harmony.diatonic_chord_sequence("C", root_midi=60, count=1) + assert len(seq) == 1 + chord, root = seq[0] + assert root == 60 + assert chord.quality == "major" -def test_diatonic_chord_sequence_invalid_mode () -> None: - """An unknown mode should raise ValueError.""" +def test_diatonic_chord_sequence_invalid_root_midi() -> None: + """A chromatic note not in the scale should raise ValueError.""" - with pytest.raises(ValueError, match="Unknown mode"): - subsequence.harmony.diatonic_chord_sequence("C", root_midi=60, count=4, mode="bebop") + with pytest.raises(ValueError, match="not a scale degree"): + # C natural (MIDI 48, pitch class 0) is not a degree of D major (which has C#) + subsequence.harmony.diatonic_chord_sequence("D", root_midi=48, count=4) -def test_dominant_7th_included () -> None: +def test_diatonic_chord_sequence_invalid_mode() -> None: + """An unknown mode should raise ValueError.""" - """ - Dominant seventh should appear when enabled and resolve to tonic. - """ + with pytest.raises(ValueError, match="Unknown mode"): + subsequence.harmony.diatonic_chord_sequence( + "C", root_midi=60, count=4, mode="bebop" + ) - graph, tonic = subsequence.chord_graphs.functional_major.build_graph("E", include_dominant_7th=True) - dominant = subsequence.chords.Chord(root_pc=11, quality="major") - dominant_7th = subsequence.chords.Chord(root_pc=11, quality="dominant_7th") +def test_dominant_7th_included() -> None: + """ + Dominant seventh should appear when enabled and resolve to tonic. + """ - transitions = graph.get_transitions(dominant) - assert any(chord == dominant_7th for chord, _ in transitions) + graph, tonic = subsequence.chord_graphs.functional_major.build_graph( + "E", include_dominant_7th=True + ) - transitions_7th = graph.get_transitions(dominant_7th) - assert any(chord == tonic for chord, _ in transitions_7th) + dominant = subsequence.chords.Chord(root_pc=11, quality="major") + dominant_7th = subsequence.chords.Chord(root_pc=11, quality="dominant_7th") + transitions = graph.get_transitions(dominant) + assert any(chord == dominant_7th for chord, _ in transitions) -def test_invalid_key_name () -> None: + transitions_7th = graph.get_transitions(dominant_7th) + assert any(chord == tonic for chord, _ in transitions_7th) - """ - Invalid key names should raise an error. - """ - with pytest.raises(ValueError): - subsequence.chord_graphs.functional_major.build_graph("H", include_dominant_7th=True) +def test_invalid_key_name() -> None: + """ + Invalid key names should raise an error. + """ + with pytest.raises(ValueError): + subsequence.chord_graphs.functional_major.build_graph( + "H", include_dominant_7th=True + ) -def test_key_gravity_blend_changes_weights () -> None: - """ - HarmonicState's key_gravity_blend setting alters real transition weights. - """ +def test_key_gravity_blend_changes_weights() -> None: + """ + HarmonicState's key_gravity_blend setting alters real transition weights. + """ - diatonic, function_chords = subsequence.chord_graphs.functional_major.DiatonicMajor().gravity_sets("E") + diatonic, function_chords = ( + subsequence.chord_graphs.functional_major.DiatonicMajor().gravity_sets("E") + ) - functional_state = subsequence.harmonic_state.HarmonicState(key_name="E", key_gravity_blend=0.0) - diatonic_state = subsequence.harmonic_state.HarmonicState(key_name="E", key_gravity_blend=1.0) + functional_state = subsequence.harmonic_state.HarmonicState( + key_name="E", key_gravity_blend=0.0 + ) + diatonic_state = subsequence.harmonic_state.HarmonicState( + key_name="E", key_gravity_blend=1.0 + ) - source = functional_state.current_chord - target_function = next(iter(chord for chord in function_chords if chord in diatonic)) - target_diatonic = next(iter(chord for chord in diatonic if chord not in function_chords)) + source = functional_state.current_chord + target_function = next( + iter(chord for chord in function_chords if chord in diatonic) + ) + target_diatonic = next( + iter(chord for chord in diatonic if chord not in function_chords) + ) - # A diatonic-but-not-function chord is boosted only under full diatonic - # gravity (blend=1.0); functional-only gravity (blend=0.0) leaves it flat. - weight_functional = functional_state._transition_weight(source, target_diatonic, 10) - weight_diatonic = diatonic_state._transition_weight(source, target_diatonic, 10) + # A diatonic-but-not-function chord is boosted only under full diatonic + # gravity (blend=1.0); functional-only gravity (blend=0.0) leaves it flat. + weight_functional = functional_state._transition_weight(source, target_diatonic, 10) + weight_diatonic = diatonic_state._transition_weight(source, target_diatonic, 10) - assert weight_diatonic > weight_functional - assert weight_diatonic == pytest.approx(2.0 * weight_functional) + assert weight_diatonic > weight_functional + assert weight_diatonic == pytest.approx(2.0 * weight_functional) - # A function chord is also diatonic, so it earns the boost at BOTH blend - # extremes — the blend only redistributes gravity over non-function chords. - assert ( - functional_state._transition_weight(source, target_function, 10) - == pytest.approx(diatonic_state._transition_weight(source, target_function, 10)) - ) + # A function chord is also diatonic, so it earns the boost at BOTH blend + # extremes — the blend only redistributes gravity over non-function chords. + assert functional_state._transition_weight( + source, target_function, 10 + ) == pytest.approx(diatonic_state._transition_weight(source, target_function, 10)) diff --git a/tests/test_harmony_window.py b/tests/test_harmony_window.py index b66ad70..65dc96a 100644 --- a/tests/test_harmony_window.py +++ b/tests/test_harmony_window.py @@ -20,366 +20,405 @@ # harmony(progression=) binding # --------------------------------------------------------------------------- -def test_bind_resolves_degrees_against_key_and_scale (patch_midi: None) -> None: - """Key-relative content resolves at bind time, against key + scale.""" +def test_bind_resolves_degrees_against_key_and_scale(patch_midi: None) -> None: + """Key-relative content resolves at bind time, against key + scale.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="A", scale="minor") - composition.harmony(progression=[1, 6, 3, 7]) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="A", scale="minor" + ) + composition.harmony(progression=[1, 6, 3, 7]) - bound = composition._bound_progression + bound = composition._bound_progression - assert bound is not None and bound.is_concrete - assert [chord.name() for chord in bound.chords] == ["Am", "F", "C", "G"] + assert bound is not None and bound.is_concrete + assert [chord.name() for chord in bound.chords] == ["Am", "F", "C", "G"] -def test_bind_relative_without_key_raises (patch_midi: None) -> None: +def test_bind_relative_without_key_raises(patch_midi: None) -> None: + """Degrees cannot resolve without a composition key.""" - """Degrees cannot resolve without a composition key.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + with pytest.raises(ValueError, match="key"): + composition.harmony(progression=[1, 6, 3, 7]) - with pytest.raises(ValueError, match="key"): - composition.harmony(progression=[1, 6, 3, 7]) +def test_bind_concrete_chords_needs_no_key(patch_midi: None) -> None: + """Chord names bind without a key, and no live engine is created.""" -def test_bind_concrete_chords_needs_no_key (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition.harmony(progression=["Am", "F"]) - """Chord names bind without a key, and no live engine is created.""" + assert composition._bound_progression is not None + assert composition._harmonic_state is None # progression-only: loop mode - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition.harmony(progression=["Am", "F"]) - assert composition._bound_progression is not None - assert composition._harmonic_state is None # progression-only: loop mode +def test_harmony_default_keeps_the_live_engine(patch_midi: None) -> None: + """comp.harmony() with no arguments still means functional_major (compat).""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony() -def test_harmony_default_keeps_the_live_engine (patch_midi: None) -> None: + assert composition._harmonic_state is not None + assert composition._bound_progression is None - """comp.harmony() with no arguments still means functional_major (compat).""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony() +def test_style_plus_progression_keeps_both(patch_midi: None) -> None: + """harmony(style=, progression=) configures the fall-through bridge.""" - assert composition._harmonic_state is not None - assert composition._bound_progression is None + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + composition.harmony(style="functional_major", progression=["Am", "F"]) - -def test_style_plus_progression_keeps_both (patch_midi: None) -> None: - - """harmony(style=, progression=) configures the fall-through bridge.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition.harmony(style="functional_major", progression=["Am", "F"]) - - assert composition._harmonic_state is not None - assert composition._bound_progression is not None + assert composition._harmonic_state is not None + assert composition._bound_progression is not None # --------------------------------------------------------------------------- # freeze() — span-based capture # --------------------------------------------------------------------------- -def test_freeze_returns_span_progression (patch_midi: None) -> None: - - """freeze() captures spans of cycle_beats each, with trailing history.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=42) - composition.harmony(style="functional_major", cycle_beats=8) +def test_freeze_returns_span_progression(patch_midi: None) -> None: + """freeze() captures spans of cycle_beats each, with trailing history.""" - frozen = composition.freeze(4) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=42 + ) + composition.harmony(style="functional_major", cycle_beats=8) - assert isinstance(frozen, subsequence.Progression) - assert len(frozen.spans) == 4 - assert all(span.beats == 8.0 for span in frozen.spans) - assert frozen.is_concrete - assert len(frozen.chords) == 4 - assert frozen.trailing_history # NIR context captured + frozen = composition.freeze(4) + assert isinstance(frozen, subsequence.Progression) + assert len(frozen.spans) == 4 + assert all(span.beats == 8.0 for span in frozen.spans) + assert frozen.is_concrete + assert len(frozen.chords) == 4 + assert frozen.trailing_history # NIR context captured -def test_frozen_value_takes_spice (patch_midi: None) -> None: - """A frozen capture is an ordinary value — spice and editing apply.""" +def test_frozen_value_takes_spice(patch_midi: None) -> None: + """A frozen capture is an ordinary value — spice and editing apply.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=42) - composition.harmony(cycle_beats=4) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=42 + ) + composition.harmony(cycle_beats=4) - spiced = composition.freeze(4).extend(7).over("tonic") + spiced = composition.freeze(4).extend(7).over("tonic") - assert all(span.extensions == (7,) for span in spiced.spans) - assert all(span.bass == "tonic" for span in spiced.spans) - assert spiced.trailing_history # metadata survives decoration + assert all(span.extensions == (7,) for span in spiced.spans) + assert all(span.bass == "tonic" for span in spiced.spans) + assert spiced.trailing_history # metadata survives decoration # --------------------------------------------------------------------------- # section_chords coercion # --------------------------------------------------------------------------- -def test_section_chords_accepts_element_lists (patch_midi: None) -> None: - """section_chords coerces lists through the factory and keeps them RELATIVE. +def test_section_chords_accepts_element_lists(patch_midi: None) -> None: + """section_chords coerces lists through the factory and keeps them RELATIVE. - Key-relative section harmony re-keys per occurrence, so the stored value - stays un-resolved (degrees/romans) and resolves late against the - section's effective key. - """ + Key-relative section harmony re-keys per occurrence, so the stored value + stays un-resolved (degrees/romans) and resolves late against the + section's effective key. + """ - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="A", scale="minor") - composition.section_chords("verse", [1, 6]) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="A", scale="minor" + ) + composition.section_chords("verse", [1, 6]) - bound = composition._section_progressions["verse"] + bound = composition._section_progressions["verse"] - assert isinstance(bound, subsequence.Progression) - assert not bound.is_concrete # stored relative, not frozen at bind - assert [chord.name() for chord in bound.resolve("A", "minor").chords] == ["Am", "F"] + assert isinstance(bound, subsequence.Progression) + assert not bound.is_concrete # stored relative, not frozen at bind + assert [chord.name() for chord in bound.resolve("A", "minor").chords] == ["Am", "F"] # --------------------------------------------------------------------------- # pin_chord # --------------------------------------------------------------------------- -def test_pin_chord_stores_and_unpins (patch_midi: None) -> None: - - """Pins parse like progression elements; None removes them. - Concrete pins resolve to their exact chord; a relative pin re-keys - against the section in force (here, just the composition's A minor). - """ +def test_pin_chord_stores_and_unpins(patch_midi: None) -> None: + """Pins parse like progression elements; None removes them. - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="A", scale="minor") + Concrete pins resolve to their exact chord; a relative pin re-keys + against the section in force (here, just the composition's A minor). + """ - composition.pin_chord(8, "E7") - assert composition._resolve_pin(8).name() == "E7" # concrete: exact + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="A", scale="minor" + ) - composition.pin_chord(8, 5) # a degree pin re-keys against the effective key - assert composition._resolve_pin(8).name() == "Em" # degree 5 in A minor + composition.pin_chord(8, "E7") + assert composition._resolve_pin(8).name() == "E7" # concrete: exact - composition.pin_chord(8, None) - assert 8 not in composition._pinned_chords + composition.pin_chord(8, 5) # a degree pin re-keys against the effective key + assert composition._resolve_pin(8).name() == "Em" # degree 5 in A minor + composition.pin_chord(8, None) + assert 8 not in composition._pinned_chords -def test_pin_chord_validates (patch_midi: None) -> None: - """Bars are 1-based; relative pins need a key.""" +def test_pin_chord_validates(patch_midi: None) -> None: + """Bars are 1-based; relative pins need a key.""" - keyless = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + keyless = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - with pytest.raises(ValueError): - keyless.pin_chord(0, "Am") - with pytest.raises(ValueError, match="key"): - keyless.pin_chord(4, 5) + with pytest.raises(ValueError): + keyless.pin_chord(0, "Am") + with pytest.raises(ValueError, match="key"): + keyless.pin_chord(4, 5) # --------------------------------------------------------------------------- # End-to-end: the window drives what actually sounds # --------------------------------------------------------------------------- -def _note_pcs_by_bar (filename: str, ticks_per_bar: int) -> typing.Dict[int, set]: - - """Pitch classes of note_ons grouped by the bar they sound in.""" - - mid = mido.MidiFile(filename) - by_bar: typing.Dict[int, set] = {} - - for track in mid.tracks: - now = 0 - for msg in track: - now += msg.time - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0: - by_bar.setdefault(now // ticks_per_bar, set()).add(msg.note % 12) - - return by_bar - -def test_render_bound_progression_drives_the_chord_pattern (tmp_path: pathlib.Path, patch_midi: None) -> None: +def _note_pcs_by_bar(filename: str, ticks_per_bar: int) -> typing.Dict[int, set]: + """Pitch classes of note_ons grouped by the bar they sound in.""" - """A two-parameter chord builder hears the bound progression, bar by bar, from bar 1.""" + mid = mido.MidiFile(filename) + by_bar: typing.Dict[int, set] = {} - filename = str(tmp_path / "bound.mid") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) - composition.harmony(progression=["Am", "F"]) + for track in mid.tracks: + now = 0 + for msg in track: + now += msg.time + if ( + not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + ): + by_bar.setdefault(now // ticks_per_bar, set()).add(msg.note % 12) - @composition.pattern(channel=1, beats=4) - def pad (p, chord) -> None: - for pitch in chord.tones(60): - p.note(pitch, beat=0, duration=4) + return by_bar - composition.render(bars=2, filename=filename) - ticks_per_bar = mido.MidiFile(filename).ticks_per_beat * 4 - by_bar = _note_pcs_by_bar(filename, ticks_per_bar) +def test_render_bound_progression_drives_the_chord_pattern( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """A two-parameter chord builder hears the bound progression, bar by bar, from bar 1.""" - assert by_bar[0] == {9, 0, 4} # A minor — the FIRST chord sounds at bar 1 - assert by_bar[1] == {5, 9, 0} # F major + filename = str(tmp_path / "bound.mid") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) + composition.harmony(progression=["Am", "F"]) + @composition.pattern(channel=1, beats=4) + def pad(p, chord) -> None: + for pitch in chord.tones(60): + p.note(pitch, beat=0, duration=4) -def test_render_exposes_p_harmony_and_anticipation (tmp_path: pathlib.Path, patch_midi: None) -> None: + composition.render(bars=2, filename=filename) - """Inside builds, p.harmony and the injected chord's window mirrors tell the truth.""" + ticks_per_bar = mido.MidiFile(filename).ticks_per_beat * 4 + by_bar = _note_pcs_by_bar(filename, ticks_per_bar) - filename = str(tmp_path / "window.mid") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) - composition.harmony(progression=["Am", "F", "C", "G"]) + assert by_bar[0] == {9, 0, 4} # A minor — the FIRST chord sounds at bar 1 + assert by_bar[1] == {5, 9, 0} # F major - seen: typing.List[typing.Tuple[str, typing.Optional[str], typing.Optional[float], str]] = [] - @composition.pattern(channel=1, beats=4) - def watcher (p, chord) -> None: - next_name = chord.next.name() if chord.next is not None else None - seen.append((p.harmony.chord.name(), next_name, chord.beats_remaining, p.harmony.chord_at(2.0).name())) - p.note(60, beat=0) +def test_render_exposes_p_harmony_and_anticipation( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Inside builds, p.harmony and the injected chord's window mirrors tell the truth.""" - composition.render(bars=3, filename=filename) + filename = str(tmp_path / "window.mid") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) + composition.harmony(progression=["Am", "F", "C", "G"]) - assert seen[0] == ("Am", "F", 4.0, "Am") - assert seen[1] == ("F", "C", 4.0, "F") - assert seen[2] == ("C", "G", 4.0, "C") + seen: typing.List[ + typing.Tuple[str, typing.Optional[str], typing.Optional[float], str] + ] = [] + @composition.pattern(channel=1, beats=4) + def watcher(p, chord) -> None: + next_name = chord.next.name() if chord.next is not None else None + seen.append( + ( + p.harmony.chord.name(), + next_name, + chord.beats_remaining, + p.harmony.chord_at(2.0).name(), + ) + ) + p.note(60, beat=0) -def test_render_variable_harmonic_rhythm (tmp_path: pathlib.Path, patch_midi: None) -> None: + composition.render(bars=3, filename=filename) - """Spans of 2 beats change chords mid-bar — the part hears each span via chord_at.""" + assert seen[0] == ("Am", "F", 4.0, "Am") + assert seen[1] == ("F", "C", 4.0, "F") + assert seen[2] == ("C", "G", 4.0, "C") - filename = str(tmp_path / "spans.mid") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) - composition.harmony(progression=subsequence.progression(["Am", "F"], beats=2)) - @composition.pattern(channel=1, beats=4) - def stabs (p) -> None: - for beat in (0.0, 2.0): - chord = p.harmony.chord_at(beat) - p.note(chord.tones(60)[0], beat=beat, duration=1) +def test_render_variable_harmonic_rhythm( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Spans of 2 beats change chords mid-bar — the part hears each span via chord_at.""" - composition.render(bars=1, filename=filename) + filename = str(tmp_path / "spans.mid") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) + composition.harmony(progression=subsequence.progression(["Am", "F"], beats=2)) - ticks_per_beat = mido.MidiFile(filename).ticks_per_beat - by_half_bar = _note_pcs_by_bar(filename, ticks_per_beat * 2) + @composition.pattern(channel=1, beats=4) + def stabs(p) -> None: + for beat in (0.0, 2.0): + chord = p.harmony.chord_at(beat) + p.note(chord.tones(60)[0], beat=beat, duration=1) - assert by_half_bar[0] == {9} # Am root in beats 0–2 - assert by_half_bar[1] == {5} # F root in beats 2–4 + composition.render(bars=1, filename=filename) + ticks_per_beat = mido.MidiFile(filename).ticks_per_beat + by_half_bar = _note_pcs_by_bar(filename, ticks_per_beat * 2) -def test_render_chord_tones_follow_the_changes (tmp_path: pathlib.Path, patch_midi: None) -> None: + assert by_half_bar[0] == {9} # Am root in beats 0–2 + assert by_half_bar[1] == {5} # F root in beats 2–4 - """ChordTone motif events resolve against the chord under each event.""" - filename = str(tmp_path / "tones.mid") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) - composition.harmony(progression=subsequence.progression(["Am", "F"], beats=2)) +def test_render_chord_tones_follow_the_changes( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """ChordTone motif events resolve against the chord under each event.""" - riff = subsequence.Motif.from_events([ - subsequence.MotifEvent(beat=0.0, pitch=subsequence.ChordTone("root"), duration=1.0), - subsequence.MotifEvent(beat=2.0, pitch=subsequence.ChordTone("root"), duration=1.0), - ], length=4) + filename = str(tmp_path / "tones.mid") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) + composition.harmony(progression=subsequence.progression(["Am", "F"], beats=2)) - @composition.pattern(channel=1, beats=4) - def lead (p) -> None: - p.motif(riff, root=60) + riff = subsequence.Motif.from_events( + [ + subsequence.MotifEvent( + beat=0.0, pitch=subsequence.ChordTone("root"), duration=1.0 + ), + subsequence.MotifEvent( + beat=2.0, pitch=subsequence.ChordTone("root"), duration=1.0 + ), + ], + length=4, + ) - composition.render(bars=1, filename=filename) + @composition.pattern(channel=1, beats=4) + def lead(p) -> None: + p.motif(riff, root=60) - ticks_per_beat = mido.MidiFile(filename).ticks_per_beat - by_half_bar = _note_pcs_by_bar(filename, ticks_per_beat * 2) + composition.render(bars=1, filename=filename) - assert by_half_bar[0] == {9} # Am root - assert by_half_bar[1] == {5} # F root + ticks_per_beat = mido.MidiFile(filename).ticks_per_beat + by_half_bar = _note_pcs_by_bar(filename, ticks_per_beat * 2) + assert by_half_bar[0] == {9} # Am root + assert by_half_bar[1] == {5} # F root -def test_render_section_progressions_with_form (tmp_path: pathlib.Path, patch_midi: None) -> None: - """Section-bound progressions follow the form, restarting on entry.""" +def test_render_section_progressions_with_form( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Section-bound progressions follow the form, restarting on entry.""" - filename = str(tmp_path / "sections.mid") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C") - composition.form([("verse", 2), ("chorus", 2)]) - composition.section_chords("verse", ["Am", "F"]) - composition.section_chords("chorus", ["C", "G"]) + filename = str(tmp_path / "sections.mid") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C") + composition.form([("verse", 2), ("chorus", 2)]) + composition.section_chords("verse", ["Am", "F"]) + composition.section_chords("chorus", ["C", "G"]) - @composition.pattern(channel=1, beats=4) - def pad (p, chord) -> None: - p.note(chord.tones(60)[0], beat=0, duration=4) + @composition.pattern(channel=1, beats=4) + def pad(p, chord) -> None: + p.note(chord.tones(60)[0], beat=0, duration=4) - composition.render(bars=4, filename=filename) + composition.render(bars=4, filename=filename) - ticks_per_bar = mido.MidiFile(filename).ticks_per_beat * 4 - by_bar = _note_pcs_by_bar(filename, ticks_per_bar) + ticks_per_bar = mido.MidiFile(filename).ticks_per_beat * 4 + by_bar = _note_pcs_by_bar(filename, ticks_per_bar) - assert by_bar[0] == {9} # verse: Am - assert by_bar[1] == {5} # verse: F - assert by_bar[2] == {0} # chorus: C — restarted at section entry - assert by_bar[3] == {7} # chorus: G + assert by_bar[0] == {9} # verse: Am + assert by_bar[1] == {5} # verse: F + assert by_bar[2] == {0} # chorus: C — restarted at section entry + assert by_bar[3] == {7} # chorus: G -def test_min_span_below_lookahead_raises_at_play (tmp_path: pathlib.Path, patch_midi: None) -> None: +def test_min_span_below_lookahead_raises_at_play( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """The span floor: chords shorter than the largest pattern lookahead refuse to bind.""" - """The span floor: chords shorter than the largest pattern lookahead refuse to bind.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) + composition.harmony(progression=subsequence.progression(["Am", "F"], beats=0.5)) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480) - composition.harmony(progression=subsequence.progression(["Am", "F"], beats=0.5)) + @composition.pattern(channel=1, beats=4) + def pad(p) -> None: + p.note(60, beat=0) - @composition.pattern(channel=1, beats=4) - def pad (p) -> None: - p.note(60, beat=0) + with pytest.raises(ValueError, match="span"): + composition.render(bars=1, filename=str(tmp_path / "floor.mid")) - with pytest.raises(ValueError, match="span"): - composition.render(bars=1, filename=str(tmp_path / "floor.mid")) +def test_current_chord_reads_the_window(patch_midi: None) -> None: + """current_chord() answers in progression-only mode (no engine) before playback.""" -def test_current_chord_reads_the_window (patch_midi: None) -> None: + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition.harmony(progression=["Am", "F"]) - """current_chord() answers in progression-only mode (no engine) before playback.""" + assert composition.current_chord() is None # nothing published yet - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition.harmony(progression=["Am", "F"]) + composition._harmony_horizon.commit(0.0, 4.0, subsequence.chords.parse_chord("Am")) - assert composition.current_chord() is None # nothing published yet - - composition._harmony_horizon.commit(0.0, 4.0, subsequence.chords.parse_chord("Am")) - - assert composition.current_chord().name() == "Am" + assert composition.current_chord().name() == "Am" # --------------------------------------------------------------------------- # Sketch (a) — the acceptance contract, end to end (stage 3 completes it) # --------------------------------------------------------------------------- -def test_sketch_a_verse_by_hand_chorus_generated_under_a_constraint (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """Verse hand-written (spiced), chorus generated with end="V", both bound and - inspectable before play — sketch (a), as written in the design document.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="A", scale="minor", seed=42) - - verse = subsequence.progression([1, 6, 3, 7]).extend(9).borrow(2) - chorus = subsequence.progression(style="aeolian_minor", bars=4, end="V", seed=7) - - # Both print before binding — degrees/romans unbound, names under a key. - assert "9" in verse.describe() - assert "V" in chorus.describe() - assert not chorus.is_concrete - - composition.form([("verse", 4), ("chorus", 4)], loop=True) - composition.section_chords("verse", verse) - composition.section_chords("chorus", chorus) - - heard: typing.List[str] = [] - - @composition.pattern(channel=1, beats=4) - def pads (p, chord) -> None: - heard.append(chord.name()) - p.note(60, beat=0) - - composition.render(bars=8, filename=str(tmp_path / "sketch_a.mid")) - - # Verse: i VI III VII in A minor, extended (9ths) with degree 2 borrowed - # from the parallel major (VI of A major = F#m). - assert heard[0] == "Am9" - assert heard[1] == "F#m9" - assert heard[2] == "C9" - assert heard[3] == "G9" - # Chorus: generated under the constraint — the last bar is the cadential - # major dominant, resolved against the section's effective key at the - # clock (the chorus has no key override, so the composition's A minor). - assert heard[7] == "E" - assert composition._section_progressions["chorus"].resolve("A", "minor").chords[0].name() == "Am" +def test_sketch_a_verse_by_hand_chorus_generated_under_a_constraint( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Verse hand-written (spiced), chorus generated with end="V", both bound and + inspectable before play — sketch (a), as written in the design document.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="A", scale="minor", seed=42 + ) + + verse = subsequence.progression([1, 6, 3, 7]).extend(9).borrow(2) + chorus = subsequence.progression(style="aeolian_minor", bars=4, end="V", seed=7) + + # Both print before binding — degrees/romans unbound, names under a key. + assert "9" in verse.describe() + assert "V" in chorus.describe() + assert not chorus.is_concrete + + composition.form([("verse", 4), ("chorus", 4)], loop=True) + composition.section_chords("verse", verse) + composition.section_chords("chorus", chorus) + + heard: typing.List[str] = [] + + @composition.pattern(channel=1, beats=4) + def pads(p, chord) -> None: + heard.append(chord.name()) + p.note(60, beat=0) + + composition.render(bars=8, filename=str(tmp_path / "sketch_a.mid")) + + # Verse: i VI III VII in A minor, extended (9ths) with degree 2 borrowed + # from the parallel major (VI of A major = F#m). + assert heard[0] == "Am9" + assert heard[1] == "F#m9" + assert heard[2] == "C9" + assert heard[3] == "G9" + + # Chorus: generated under the constraint — the last bar is the cadential + # major dominant, resolved against the section's effective key at the + # clock (the chorus has no key override, so the composition's A minor). + assert heard[7] == "E" + assert ( + composition._section_progressions["chorus"] + .resolve("A", "minor") + .chords[0] + .name() + == "Am" + ) diff --git a/tests/test_held_notes.py b/tests/test_held_notes.py index e544a4e..c05569c 100644 --- a/tests/test_held_notes.py +++ b/tests/test_held_notes.py @@ -15,211 +15,237 @@ # HeldNotes — pure unit tests (time is injected, so these are deterministic) # --------------------------------------------------------------------------- -def test_held_notes_on_off_snapshot () -> None: - """A pressed note appears in the snapshot; releasing it removes it.""" - h = subsequence.held_notes.HeldNotes() - h.note_on(60, 100, now=0.0) - assert h.snapshot(now=0.0) == [60] - h.note_off(60, now=0.0) - assert h.snapshot(now=0.0) == [] - - -def test_held_notes_snapshot_sorted_and_deduped () -> None: - """Snapshot is sorted ascending regardless of press order.""" - h = subsequence.held_notes.HeldNotes() - for pitch in (67, 60, 64): - h.note_on(pitch, 100, now=0.0) - assert h.snapshot(now=0.0) == [60, 64, 67] - - -def test_held_notes_unknown_note_off_is_noop () -> None: - """A note-off for a pitch that was never down does nothing.""" - h = subsequence.held_notes.HeldNotes() - h.note_off(99, now=0.0) - assert h.snapshot(now=0.0) == [] - - -def test_held_notes_release_window_holds_then_drops () -> None: - """With release_ms, a released note lingers until its deadline passes.""" - h = subsequence.held_notes.HeldNotes(release_ms=50.0) # 0.05 s - h.note_on(60, 100, now=0.0) - h.note_off(60, now=1.0) - # Still within the 50 ms window — counts as held. - assert h.snapshot(now=1.04) == [60] - # Just past the deadline — gone. - assert h.snapshot(now=1.06) == [] - - -def test_held_notes_release_zero_drops_instantly () -> None: - """release_ms=0 removes a note the instant its note-off arrives.""" - h = subsequence.held_notes.HeldNotes(release_ms=0.0) - h.note_on(60, 100, now=0.0) - h.note_off(60, now=1.0) - assert h.snapshot(now=1.0) == [] - - -def test_held_notes_repress_cancels_release () -> None: - """Re-pressing a note inside the release window keeps it held indefinitely.""" - h = subsequence.held_notes.HeldNotes(release_ms=50.0) - h.note_on(60, 100, now=0.0) - h.note_off(60, now=1.0) - h.note_on(60, 100, now=1.02) # quick re-press during changeover - # Long after the original deadline it is still held (no dropout). - assert h.snapshot(now=5.0) == [60] - - -def test_held_notes_latch_persists_after_release () -> None: - """In latch mode the chord stays held after every key is lifted.""" - h = subsequence.held_notes.HeldNotes(latch=True) - h.note_on(60, 100, now=0.0) - h.note_on(64, 100, now=0.0) - h.note_off(60, now=1.0) - h.note_off(64, now=1.0) - assert h.snapshot(now=2.0) == [60, 64] - - -def test_held_notes_latch_new_chord_replaces () -> None: - """The first key after a full release starts a fresh latched chord.""" - h = subsequence.held_notes.HeldNotes(latch=True) - h.note_on(60, 100, now=0.0) - h.note_off(60, now=1.0) - assert h.snapshot(now=1.5) == [60] # latched - h.note_on(67, 100, now=2.0) # new chord — replaces, not adds - assert h.snapshot(now=2.0) == [67] + +def test_held_notes_on_off_snapshot() -> None: + """A pressed note appears in the snapshot; releasing it removes it.""" + h = subsequence.held_notes.HeldNotes() + h.note_on(60, 100, now=0.0) + assert h.snapshot(now=0.0) == [60] + h.note_off(60, now=0.0) + assert h.snapshot(now=0.0) == [] + + +def test_held_notes_snapshot_sorted_and_deduped() -> None: + """Snapshot is sorted ascending regardless of press order.""" + h = subsequence.held_notes.HeldNotes() + for pitch in (67, 60, 64): + h.note_on(pitch, 100, now=0.0) + assert h.snapshot(now=0.0) == [60, 64, 67] + + +def test_held_notes_unknown_note_off_is_noop() -> None: + """A note-off for a pitch that was never down does nothing.""" + h = subsequence.held_notes.HeldNotes() + h.note_off(99, now=0.0) + assert h.snapshot(now=0.0) == [] + + +def test_held_notes_release_window_holds_then_drops() -> None: + """With release_ms, a released note lingers until its deadline passes.""" + h = subsequence.held_notes.HeldNotes(release_ms=50.0) # 0.05 s + h.note_on(60, 100, now=0.0) + h.note_off(60, now=1.0) + # Still within the 50 ms window — counts as held. + assert h.snapshot(now=1.04) == [60] + # Just past the deadline — gone. + assert h.snapshot(now=1.06) == [] + + +def test_held_notes_release_zero_drops_instantly() -> None: + """release_ms=0 removes a note the instant its note-off arrives.""" + h = subsequence.held_notes.HeldNotes(release_ms=0.0) + h.note_on(60, 100, now=0.0) + h.note_off(60, now=1.0) + assert h.snapshot(now=1.0) == [] + + +def test_held_notes_repress_cancels_release() -> None: + """Re-pressing a note inside the release window keeps it held indefinitely.""" + h = subsequence.held_notes.HeldNotes(release_ms=50.0) + h.note_on(60, 100, now=0.0) + h.note_off(60, now=1.0) + h.note_on(60, 100, now=1.02) # quick re-press during changeover + # Long after the original deadline it is still held (no dropout). + assert h.snapshot(now=5.0) == [60] + + +def test_held_notes_latch_persists_after_release() -> None: + """In latch mode the chord stays held after every key is lifted.""" + h = subsequence.held_notes.HeldNotes(latch=True) + h.note_on(60, 100, now=0.0) + h.note_on(64, 100, now=0.0) + h.note_off(60, now=1.0) + h.note_off(64, now=1.0) + assert h.snapshot(now=2.0) == [60, 64] + + +def test_held_notes_latch_new_chord_replaces() -> None: + """The first key after a full release starts a fresh latched chord.""" + h = subsequence.held_notes.HeldNotes(latch=True) + h.note_on(60, 100, now=0.0) + h.note_off(60, now=1.0) + assert h.snapshot(now=1.5) == [60] # latched + h.note_on(67, 100, now=2.0) # new chord — replaces, not adds + assert h.snapshot(now=2.0) == [67] # --------------------------------------------------------------------------- # Sequencer integration — _on_midi_input buffering + the loop-thread drain # --------------------------------------------------------------------------- -def _make_seq (release_ms: float = 0.0, latch: bool = False, channel: typing.Optional[int] = None, device: typing.Optional[int] = None) -> subsequence.sequencer.Sequencer: - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - # _on_midi_input early-returns unless these are set. - seq._midi_input_queue = asyncio.Queue() - seq._input_loop = asyncio.new_event_loop() - seq._held_notes = subsequence.held_notes.HeldNotes(release_ms=release_ms, latch=latch) - seq._note_input_channel = channel - seq._note_input_device = device - return seq - - -def _note_on (note: int, channel: int = 0, velocity: int = 100) -> mido.Message: - return mido.Message('note_on', channel=channel, note=note, velocity=velocity) - - -def _note_off (note: int, channel: int = 0) -> mido.Message: - return mido.Message('note_off', channel=channel, note=note, velocity=0) - - -def test_on_midi_input_buffers_note_events () -> None: - """note_on/note_off append (is_on, pitch, …) tuples to the buffer.""" - seq = _make_seq() - seq._on_midi_input(_note_on(60), device_idx=0) - seq._on_midi_input(_note_off(60), device_idx=0) - events = list(seq._note_input_buffer) - assert events[0][:3] == (True, 60, 100) - assert events[1][:3] == (False, 60, 0) - -def test_on_midi_input_velocity_zero_is_note_off () -> None: - """A note_on with velocity 0 (running-status off) buffers as a release.""" - seq = _make_seq() - seq._on_midi_input(mido.Message('note_on', channel=0, note=60, velocity=0), device_idx=0) - assert list(seq._note_input_buffer)[0][:3] == (False, 60, 0) - - -def test_on_midi_input_channel_filter () -> None: - """With a channel filter set, notes on other channels are ignored.""" - seq = _make_seq(channel=0) - seq._on_midi_input(_note_on(60, channel=0), device_idx=0) - seq._on_midi_input(_note_on(64, channel=5), device_idx=0) - pitches = [ev[1] for ev in seq._note_input_buffer] - assert pitches == [60] - - -def test_on_midi_input_device_filter () -> None: - """With an input_device filter set, notes from other devices are ignored.""" - seq = _make_seq(device=0) - seq._on_midi_input(_note_on(60), device_idx=0) - seq._on_midi_input(_note_on(64), device_idx=1) - pitches = [ev[1] for ev in seq._note_input_buffer] - assert pitches == [60] - - -def test_advance_pulse_drains_into_tracker () -> None: - """The real loop-thread drain in _advance_pulse feeds the tracker.""" - seq = _make_seq() - seq._on_midi_input(_note_on(60), device_idx=0) - seq._on_midi_input(_note_on(64), device_idx=0) - asyncio.run(seq._advance_pulse()) - assert len(seq._note_input_buffer) == 0 # drained - assert seq._held_notes.snapshot(time.perf_counter()) == [60, 64] +def _make_seq( + release_ms: float = 0.0, + latch: bool = False, + channel: typing.Optional[int] = None, + device: typing.Optional[int] = None, +) -> subsequence.sequencer.Sequencer: + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + # _on_midi_input early-returns unless these are set. + seq._midi_input_queue = asyncio.Queue() + seq._input_loop = asyncio.new_event_loop() + seq._held_notes = subsequence.held_notes.HeldNotes( + release_ms=release_ms, latch=latch + ) + seq._note_input_channel = channel + seq._note_input_device = device + return seq + + +def _note_on(note: int, channel: int = 0, velocity: int = 100) -> mido.Message: + return mido.Message("note_on", channel=channel, note=note, velocity=velocity) + + +def _note_off(note: int, channel: int = 0) -> mido.Message: + return mido.Message("note_off", channel=channel, note=note, velocity=0) + + +def test_on_midi_input_buffers_note_events() -> None: + """note_on/note_off append (is_on, pitch, …) tuples to the buffer.""" + seq = _make_seq() + seq._on_midi_input(_note_on(60), device_idx=0) + seq._on_midi_input(_note_off(60), device_idx=0) + events = list(seq._note_input_buffer) + assert events[0][:3] == (True, 60, 100) + assert events[1][:3] == (False, 60, 0) + + +def test_on_midi_input_velocity_zero_is_note_off() -> None: + """A note_on with velocity 0 (running-status off) buffers as a release.""" + seq = _make_seq() + seq._on_midi_input( + mido.Message("note_on", channel=0, note=60, velocity=0), device_idx=0 + ) + assert list(seq._note_input_buffer)[0][:3] == (False, 60, 0) + + +def test_on_midi_input_channel_filter() -> None: + """With a channel filter set, notes on other channels are ignored.""" + seq = _make_seq(channel=0) + seq._on_midi_input(_note_on(60, channel=0), device_idx=0) + seq._on_midi_input(_note_on(64, channel=5), device_idx=0) + pitches = [ev[1] for ev in seq._note_input_buffer] + assert pitches == [60] + + +def test_on_midi_input_device_filter() -> None: + """With an input_device filter set, notes from other devices are ignored.""" + seq = _make_seq(device=0) + seq._on_midi_input(_note_on(60), device_idx=0) + seq._on_midi_input(_note_on(64), device_idx=1) + pitches = [ev[1] for ev in seq._note_input_buffer] + assert pitches == [60] + + +def test_advance_pulse_drains_into_tracker() -> None: + """The real loop-thread drain in _advance_pulse feeds the tracker.""" + seq = _make_seq() + seq._on_midi_input(_note_on(60), device_idx=0) + seq._on_midi_input(_note_on(64), device_idx=0) + asyncio.run(seq._advance_pulse()) + assert len(seq._note_input_buffer) == 0 # drained + assert seq._held_notes.snapshot(time.perf_counter()) == [60, 64] # --------------------------------------------------------------------------- # PatternBuilder.held_notes() accessor # --------------------------------------------------------------------------- -def _builder (held: typing.Optional[subsequence.held_notes.HeldNotes] = None) -> subsequence.pattern_builder.PatternBuilder: - pattern = subsequence.pattern.Pattern(channel=0, length=4) - return subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0, default_grid=16, held_notes=held) +def _builder( + held: typing.Optional[subsequence.held_notes.HeldNotes] = None, +) -> subsequence.pattern_builder.PatternBuilder: + pattern = subsequence.pattern.Pattern(channel=0, length=4) + return subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, default_grid=16, held_notes=held + ) -def test_held_notes_accessor_empty_without_tracker () -> None: - """No note_input declared (or headless render) → held_notes() is empty.""" - assert _builder().held_notes() == [] +def test_held_notes_accessor_empty_without_tracker() -> None: + """No note_input declared (or headless render) → held_notes() is empty.""" + assert _builder().held_notes() == [] -def test_held_notes_accessor_returns_snapshot () -> None: - """With a tracker, held_notes() returns its current sorted snapshot.""" - h = subsequence.held_notes.HeldNotes() - h.note_on(67, 100, now=0.0) - h.note_on(60, 100, now=0.0) - assert _builder(h).held_notes() == [60, 67] +def test_held_notes_accessor_returns_snapshot() -> None: + """With a tracker, held_notes() returns its current sorted snapshot.""" + h = subsequence.held_notes.HeldNotes() + h.note_on(67, 100, now=0.0) + h.note_on(60, 100, now=0.0) + assert _builder(h).held_notes() == [60, 67] -def test_arpeggio_over_held_notes_rests_when_empty () -> None: - """p.arpeggio(p.held_notes()) places nothing when no keys are held.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0, default_grid=16, held_notes=subsequence.held_notes.HeldNotes()) - result = builder.arpeggio(builder.held_notes(), direction="up") - assert result is builder - assert pattern.steps == {} +def test_arpeggio_over_held_notes_rests_when_empty() -> None: + """p.arpeggio(p.held_notes()) places nothing when no keys are held.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + default_grid=16, + held_notes=subsequence.held_notes.HeldNotes(), + ) + result = builder.arpeggio(builder.held_notes(), direction="up") + assert result is builder + assert pattern.steps == {} -def test_arpeggio_over_held_notes_places_notes () -> None: - """p.arpeggio(p.held_notes()) arpeggiates whatever the player is holding.""" - h = subsequence.held_notes.HeldNotes() - for pitch in (60, 64, 67): - h.note_on(pitch, 100, now=0.0) - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0, default_grid=16, held_notes=h) - builder.arpeggio(builder.held_notes(), spacing=1.0, direction="up") +def test_arpeggio_over_held_notes_places_notes() -> None: + """p.arpeggio(p.held_notes()) arpeggiates whatever the player is holding.""" + h = subsequence.held_notes.HeldNotes() + for pitch in (60, 64, 67): + h.note_on(pitch, 100, now=0.0) + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, default_grid=16, held_notes=h + ) - placed = [note.pitch for step in pattern.steps.values() for note in step.notes] - assert sorted(set(placed)) == [60, 64, 67] + builder.arpeggio(builder.held_notes(), spacing=1.0, direction="up") + + placed = [note.pitch for step in pattern.steps.values() for note in step.notes] + assert sorted(set(placed)) == [60, 64, 67] # --------------------------------------------------------------------------- # Composition API — note_input() registration # --------------------------------------------------------------------------- -def test_note_input_stores_config (patch_midi: None) -> None: - """note_input() records its config on the composition.""" - comp = subsequence.Composition(bpm=120) - comp.note_input(channel=1, release_ms=40, latch=True) - assert comp._note_input is not None - assert comp._note_input['release_ms'] == 40 - assert comp._note_input['latch'] is True - - -def test_note_input_second_call_raises (patch_midi: None) -> None: - """v1 supports a single note_input source — a second call is rejected.""" - comp = subsequence.Composition(bpm=120) - comp.note_input() - try: - comp.note_input() - assert False, "expected RuntimeError" - except RuntimeError as exc: - assert "one note_input source" in str(exc) + +def test_note_input_stores_config(patch_midi: None) -> None: + """note_input() records its config on the composition.""" + comp = subsequence.Composition(bpm=120) + comp.note_input(channel=1, release_ms=40, latch=True) + assert comp._note_input is not None + assert comp._note_input["release_ms"] == 40 + assert comp._note_input["latch"] is True + + +def test_note_input_second_call_raises(patch_midi: None) -> None: + """v1 supports a single note_input source — a second call is rejected.""" + comp = subsequence.Composition(bpm=120) + comp.note_input() + try: + comp.note_input() + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "one note_input source" in str(exc) diff --git a/tests/test_hotkeys.py b/tests/test_hotkeys.py index bdd4030..aefb53b 100644 --- a/tests/test_hotkeys.py +++ b/tests/test_hotkeys.py @@ -26,332 +26,344 @@ # KeystrokeListener — platform detection # --------------------------------------------------------------------------- + class TestKeystrokeListenerPlatform: + def test_supported_flag_is_bool(self) -> None: + assert isinstance(keystroke_mod.HOTKEYS_SUPPORTED, bool) + + def test_reason_is_none_when_supported(self) -> None: + if keystroke_mod.HOTKEYS_SUPPORTED: + assert keystroke_mod.HOTKEYS_UNAVAILABLE_REASON is None + + def test_reason_is_string_when_unsupported(self) -> None: + if not keystroke_mod.HOTKEYS_SUPPORTED: + assert isinstance(keystroke_mod.HOTKEYS_UNAVAILABLE_REASON, str) + assert len(keystroke_mod.HOTKEYS_UNAVAILABLE_REASON) > 0 + + def test_start_on_unsupported_platform_logs_warning_and_does_not_raise( + self, + ) -> None: + """Simulate an unsupported platform by patching HOTKEYS_SUPPORTED to False.""" + listener = keystroke_mod.KeystrokeListener() + with unittest.mock.patch.object(keystroke_mod, "HOTKEYS_SUPPORTED", False): + with unittest.mock.patch.object( + keystroke_mod, + "HOTKEYS_UNAVAILABLE_REASON", + "Test: platform not supported", + ): + # Should be a safe no-op — no exception, no thread started. + listener.start() + + assert listener.active is False + assert listener._thread is None + + def test_drain_returns_empty_when_never_started(self) -> None: + listener = keystroke_mod.KeystrokeListener() + assert listener.drain() == [] + + def test_stop_safe_when_never_started(self) -> None: + listener = keystroke_mod.KeystrokeListener() + listener.stop() # Should be a no-op. - def test_supported_flag_is_bool (self) -> None: - assert isinstance(keystroke_mod.HOTKEYS_SUPPORTED, bool) - def test_reason_is_none_when_supported (self) -> None: - if keystroke_mod.HOTKEYS_SUPPORTED: - assert keystroke_mod.HOTKEYS_UNAVAILABLE_REASON is None +# --------------------------------------------------------------------------- +# _derive_label +# --------------------------------------------------------------------------- - def test_reason_is_string_when_unsupported (self) -> None: - if not keystroke_mod.HOTKEYS_SUPPORTED: - assert isinstance(keystroke_mod.HOTKEYS_UNAVAILABLE_REASON, str) - assert len(keystroke_mod.HOTKEYS_UNAVAILABLE_REASON) > 0 - def test_start_on_unsupported_platform_logs_warning_and_does_not_raise (self) -> None: - """Simulate an unsupported platform by patching HOTKEYS_SUPPORTED to False.""" - listener = keystroke_mod.KeystrokeListener() - with unittest.mock.patch.object(keystroke_mod, "HOTKEYS_SUPPORTED", False): - with unittest.mock.patch.object( - keystroke_mod, "HOTKEYS_UNAVAILABLE_REASON", "Test: platform not supported" - ): - # Should be a safe no-op — no exception, no thread started. - listener.start() +class TestDeriveLabel: + def test_named_function(self) -> None: + def jump_to_chorus() -> None: + pass - assert listener.active is False - assert listener._thread is None + assert comp_mod._derive_label(jump_to_chorus) == "jump_to_chorus" - def test_drain_returns_empty_when_never_started (self) -> None: - listener = keystroke_mod.KeystrokeListener() - assert listener.drain() == [] + def test_named_method(self) -> None: + class Foo: + def my_action(self) -> None: + pass - def test_stop_safe_when_never_started (self) -> None: - listener = keystroke_mod.KeystrokeListener() - listener.stop() # Should be a no-op. + assert comp_mod._derive_label(Foo().my_action) == "my_action" + def test_lambda_fallback_no_source(self) -> None: + """Lambdas defined inline in test code cannot be inspected — fallback applies.""" + lam = lambda: None # noqa: E731 + result = comp_mod._derive_label(lam) + # Either extracted body or fallback — must be a non-empty string. + assert isinstance(result, str) + assert len(result) > 0 -# --------------------------------------------------------------------------- -# _derive_label -# --------------------------------------------------------------------------- + def test_unknown_callable_fallback(self) -> None: + """A callable with no __name__ and no source returns ''.""" -class TestDeriveLabel: + class CallableObj: + pass - def test_named_function (self) -> None: - def jump_to_chorus () -> None: - pass - assert comp_mod._derive_label(jump_to_chorus) == "jump_to_chorus" - - def test_named_method (self) -> None: - class Foo: - def my_action (self) -> None: - pass - assert comp_mod._derive_label(Foo().my_action) == "my_action" - - def test_lambda_fallback_no_source (self) -> None: - """Lambdas defined inline in test code cannot be inspected — fallback applies.""" - lam = lambda: None # noqa: E731 - result = comp_mod._derive_label(lam) - # Either extracted body or fallback — must be a non-empty string. - assert isinstance(result, str) - assert len(result) > 0 - - def test_unknown_callable_fallback (self) -> None: - """A callable with no __name__ and no source returns ''.""" - class CallableObj: - pass - result = comp_mod._derive_label(CallableObj()) - assert result == "" + result = comp_mod._derive_label(CallableObj()) + assert result == "" # --------------------------------------------------------------------------- # HotkeyBinding # --------------------------------------------------------------------------- -class TestHotkeyBinding: - def test_fields (self) -> None: - action = lambda: None # noqa: E731 - binding = comp_mod.HotkeyBinding(key="a", action=action, quantize=0, label="do something") - assert binding.key == "a" - assert binding.action is action - assert binding.quantize == 0 - assert binding.label == "do something" +class TestHotkeyBinding: + def test_fields(self) -> None: + action = lambda: None # noqa: E731 + binding = comp_mod.HotkeyBinding( + key="a", action=action, quantize=0, label="do something" + ) + assert binding.key == "a" + assert binding.action is action + assert binding.quantize == 0 + assert binding.label == "do something" # --------------------------------------------------------------------------- # Composition.hotkeys() and Composition.hotkey() # --------------------------------------------------------------------------- -class TestHotkeyRegistration: - - @pytest.fixture(autouse=True) - def _setup (self, patch_midi: None) -> None: - """Build a real Composition (against the fake MIDI backend) so registration runs through __init__-initialised state.""" +class TestHotkeyRegistration: + @pytest.fixture(autouse=True) + def _setup(self, patch_midi: None) -> None: + """Build a real Composition (against the fake MIDI backend) so registration runs through __init__-initialised state.""" - self.comp = comp_mod.Composition(output_device="Dummy MIDI", bpm=120) + self.comp = comp_mod.Composition(output_device="Dummy MIDI", bpm=120) - def test_hotkeys_enables (self) -> None: - self.comp.hotkeys() - assert self.comp._hotkeys_enabled is True + def test_hotkeys_enables(self) -> None: + self.comp.hotkeys() + assert self.comp._hotkeys_enabled is True - def test_hotkeys_disables (self) -> None: - self.comp.hotkeys(enabled=False) - assert self.comp._hotkeys_enabled is False + def test_hotkeys_disables(self) -> None: + self.comp.hotkeys(enabled=False) + assert self.comp._hotkeys_enabled is False - def test_hotkey_registers (self) -> None: - action = lambda: None # noqa: E731 - self.comp.hotkey("a", action) - assert "a" in self.comp._hotkey_bindings - b = self.comp._hotkey_bindings["a"] - assert b.key == "a" - assert b.action is action - assert b.quantize == 0 + def test_hotkey_registers(self) -> None: + action = lambda: None # noqa: E731 + self.comp.hotkey("a", action) + assert "a" in self.comp._hotkey_bindings + b = self.comp._hotkey_bindings["a"] + assert b.key == "a" + assert b.action is action + assert b.quantize == 0 - def test_hotkey_overwrites_existing (self) -> None: - a1 = lambda: None # noqa: E731 - a2 = lambda: None # noqa: E731 - self.comp.hotkey("a", a1) - self.comp.hotkey("a", a2) - assert self.comp._hotkey_bindings["a"].action is a2 + def test_hotkey_overwrites_existing(self) -> None: + a1 = lambda: None # noqa: E731 + a2 = lambda: None # noqa: E731 + self.comp.hotkey("a", a1) + self.comp.hotkey("a", a2) + assert self.comp._hotkey_bindings["a"].action is a2 - def test_hotkey_explicit_label (self) -> None: - self.comp.hotkey("a", lambda: None, label="my label") # noqa: E731 - assert self.comp._hotkey_bindings["a"].label == "my label" + def test_hotkey_explicit_label(self) -> None: + self.comp.hotkey("a", lambda: None, label="my label") # noqa: E731 + assert self.comp._hotkey_bindings["a"].label == "my label" - def test_hotkey_explicit_quantize (self) -> None: - self.comp.hotkey("a", lambda: None, quantize=4) # noqa: E731 - assert self.comp._hotkey_bindings["a"].quantize == 4 + def test_hotkey_explicit_quantize(self) -> None: + self.comp.hotkey("a", lambda: None, quantize=4) # noqa: E731 + assert self.comp._hotkey_bindings["a"].quantize == 4 - def test_hotkey_rejects_reserved_key (self) -> None: - with pytest.raises(ValueError, match="reserved"): - self.comp.hotkey(comp_mod._HOTKEY_RESERVED, lambda: None) # noqa: E731 + def test_hotkey_rejects_reserved_key(self) -> None: + with pytest.raises(ValueError, match="reserved"): + self.comp.hotkey(comp_mod._HOTKEY_RESERVED, lambda: None) # noqa: E731 - def test_hotkey_rejects_multi_char_key (self) -> None: - with pytest.raises(ValueError, match="single character"): - self.comp.hotkey("ab", lambda: None) # noqa: E731 + def test_hotkey_rejects_multi_char_key(self) -> None: + with pytest.raises(ValueError, match="single character"): + self.comp.hotkey("ab", lambda: None) # noqa: E731 # --------------------------------------------------------------------------- # subsequence.form_state.FormState.jump_to() # --------------------------------------------------------------------------- -class TestFormStateJumpTo: - def _make_graph_form (self) -> subsequence.form_state.FormState: - return subsequence.form_state.FormState( - sections = { - "intro": (4, [("verse", 1)]), - "verse": (8, [("chorus", 1), ("verse", 2)]), - "chorus": (8, [("verse", 1)]), - "outro": (4, None), - }, - start = "intro", - ) - - def test_jump_changes_current_section (self) -> None: - state = self._make_graph_form() - assert state.get_section_info().name == "intro" - state.jump_to("chorus") - assert state.get_section_info().name == "chorus" - - def test_jump_resets_bar_in_section (self) -> None: - state = self._make_graph_form() - # Advance a few bars into 'intro'. - state.advance() - state.advance() - state.jump_to("chorus") - section = state.get_section_info() - assert section.bar == 0 - - def test_jump_increments_section_index (self) -> None: - state = self._make_graph_form() - original_index = state.get_section_info().index - state.jump_to("verse") - assert state.get_section_info().index == original_index + 1 - - def test_jump_clears_finished_flag (self) -> None: - """Jumping to a non-terminal section after finishing should revive the form.""" - state = self._make_graph_form() - # Force finish. - state._finished = True - state.jump_to("verse") - assert not state._finished - assert state.get_section_info() is not None - - def test_jump_unknown_section_raises (self) -> None: - state = self._make_graph_form() - with pytest.raises(ValueError, match="unknown_section"): - state.jump_to("unknown_section") - - def test_jump_list_mode_navigates (self) -> None: - """List forms are navigable (stage 7): the jump lands on the named occurrence.""" - state = subsequence.form_state.FormState(sections=[("verse", 4), ("chorus", 4)]) - state.jump_to("chorus") - info = state.get_section_info() - assert info is not None and info.name == "chorus" and info.bar == 0 - - def test_jump_generator_mode_raises (self) -> None: - """A generator form cannot be navigated — there is no timeline to search.""" - state = subsequence.form_state.FormState(sections=iter([("verse", 4), ("chorus", 4)])) - with pytest.raises(ValueError, match="generator"): - state.jump_to("chorus") +class TestFormStateJumpTo: + def _make_graph_form(self) -> subsequence.form_state.FormState: + return subsequence.form_state.FormState( + sections={ + "intro": (4, [("verse", 1)]), + "verse": (8, [("chorus", 1), ("verse", 2)]), + "chorus": (8, [("verse", 1)]), + "outro": (4, None), + }, + start="intro", + ) + + def test_jump_changes_current_section(self) -> None: + state = self._make_graph_form() + assert state.get_section_info().name == "intro" + state.jump_to("chorus") + assert state.get_section_info().name == "chorus" + + def test_jump_resets_bar_in_section(self) -> None: + state = self._make_graph_form() + # Advance a few bars into 'intro'. + state.advance() + state.advance() + state.jump_to("chorus") + section = state.get_section_info() + assert section.bar == 0 + + def test_jump_increments_section_index(self) -> None: + state = self._make_graph_form() + original_index = state.get_section_info().index + state.jump_to("verse") + assert state.get_section_info().index == original_index + 1 + + def test_jump_clears_finished_flag(self) -> None: + """Jumping to a non-terminal section after finishing should revive the form.""" + state = self._make_graph_form() + # Force finish. + state._finished = True + state.jump_to("verse") + assert not state._finished + assert state.get_section_info() is not None + + def test_jump_unknown_section_raises(self) -> None: + state = self._make_graph_form() + with pytest.raises(ValueError, match="unknown_section"): + state.jump_to("unknown_section") + + def test_jump_list_mode_navigates(self) -> None: + """List forms are navigable (stage 7): the jump lands on the named occurrence.""" + state = subsequence.form_state.FormState(sections=[("verse", 4), ("chorus", 4)]) + state.jump_to("chorus") + info = state.get_section_info() + assert info is not None and info.name == "chorus" and info.bar == 0 + + def test_jump_generator_mode_raises(self) -> None: + """A generator form cannot be navigated — there is no timeline to search.""" + state = subsequence.form_state.FormState( + sections=iter([("verse", 4), ("chorus", 4)]) + ) + with pytest.raises(ValueError, match="generator"): + state.jump_to("chorus") # --------------------------------------------------------------------------- # Composition.form_jump() # --------------------------------------------------------------------------- -class TestFormJump: - def setup_method (self) -> None: - self.comp = comp_mod.Composition.__new__(comp_mod.Composition) - self.comp._form_state = None - self.comp._hotkey_bindings = {} - self.comp._pending_hotkey_actions = [] - self.comp._keystroke_listener = None - self.comp._hotkeys_enabled = False - self.comp._harmony_horizon = comp_mod._HarmonyHorizon() - - def test_form_jump_no_form_raises (self) -> None: - with pytest.raises(ValueError, match="form"): - self.comp.form_jump("chorus") - - def test_form_jump_delegates_to_form_state (self) -> None: - mock_state = unittest.mock.MagicMock(spec=subsequence.form_state.FormState) - self.comp._form_state = mock_state - self.comp.form_jump("chorus") - mock_state.jump_to.assert_called_once_with("chorus") +class TestFormJump: + def setup_method(self) -> None: + self.comp = comp_mod.Composition.__new__(comp_mod.Composition) + self.comp._form_state = None + self.comp._hotkey_bindings = {} + self.comp._pending_hotkey_actions = [] + self.comp._keystroke_listener = None + self.comp._hotkeys_enabled = False + self.comp._harmony_horizon = comp_mod._HarmonyHorizon() + + def test_form_jump_no_form_raises(self) -> None: + with pytest.raises(ValueError, match="form"): + self.comp.form_jump("chorus") + + def test_form_jump_delegates_to_form_state(self) -> None: + mock_state = unittest.mock.MagicMock(spec=subsequence.form_state.FormState) + self.comp._form_state = mock_state + self.comp.form_jump("chorus") + mock_state.jump_to.assert_called_once_with("chorus") # --------------------------------------------------------------------------- # Composition._process_hotkeys() # --------------------------------------------------------------------------- -class TestProcessHotkeys: - def setup_method (self) -> None: - self.comp = comp_mod.Composition.__new__(comp_mod.Composition) - self.comp._hotkeys_enabled = True - self.comp._hotkey_bindings = {} - self.comp._pending_hotkey_actions = [] - self.comp._form_state = None - - # Replace the real keystroke listener with a controllable mock. - self.mock_listener = unittest.mock.MagicMock() - self.mock_listener.drain.return_value = [] - self.comp._keystroke_listener = self.mock_listener - - def _register (self, key: str, fn: typing.Callable, quantize: int = 0) -> None: - self.comp._hotkey_bindings[key] = comp_mod.HotkeyBinding( - key=key, action=fn, quantize=quantize, label=key - ) - - # --- immediate actions -------------------------------------------------- - - def test_immediate_action_fires_on_bar (self) -> None: - called = [] - self._register("a", lambda: called.append(True)) - self.mock_listener.drain.return_value = ["a"] - self.comp._process_hotkeys(bar=1) - assert called == [True] - - def test_unknown_key_is_ignored (self) -> None: - self.mock_listener.drain.return_value = ["z"] - # Should not raise. - self.comp._process_hotkeys(bar=1) - - def test_action_exception_is_swallowed (self) -> None: - def boom () -> None: - raise RuntimeError("oops") - self._register("a", boom) - self.mock_listener.drain.return_value = ["a"] - # Should not propagate. - self.comp._process_hotkeys(bar=1) - - # --- quantized actions -------------------------------------------------- - - def test_quantized_action_is_deferred (self) -> None: - called = [] - self._register("a", lambda: called.append(True), quantize=4) - self.mock_listener.drain.return_value = ["a"] - # Bar 1 is not divisible by 4 — should not fire yet. - self.comp._process_hotkeys(bar=1) - assert called == [] - assert len(self.comp._pending_hotkey_actions) == 1 - - def test_quantized_action_fires_at_boundary (self) -> None: - called = [] - self._register("a", lambda: called.append(True), quantize=4) - - # Enqueue manually at bar 1. - self.comp._pending_hotkey_actions.append( - comp_mod._PendingHotkeyAction( - binding=self.comp._hotkey_bindings["a"], - ) - ) - - # Bar 4 is divisible by 4 — should fire. - self.comp._process_hotkeys(bar=4) - assert called == [True] - assert len(self.comp._pending_hotkey_actions) == 0 - - def test_quantized_action_remains_pending_before_boundary (self) -> None: - called = [] - self._register("a", lambda: called.append(True), quantize=4) - self.comp._pending_hotkey_actions.append( - comp_mod._PendingHotkeyAction( - binding=self.comp._hotkey_bindings["a"], - ) - ) - # Bar 2 is not divisible by 4. - self.comp._process_hotkeys(bar=2) - assert called == [] - assert len(self.comp._pending_hotkey_actions) == 1 - - # --- ? key -------------------------------------------------------------- - - def test_question_mark_calls_list_hotkeys (self) -> None: - self.mock_listener.drain.return_value = [comp_mod._HOTKEY_RESERVED] - self.comp._list_hotkeys = unittest.mock.MagicMock() - self.comp._process_hotkeys(bar=1) - self.comp._list_hotkeys.assert_called_once() - - # --- no listener -------------------------------------------------------- - - def test_no_listener_returns_early (self) -> None: - self.comp._keystroke_listener = None - # Should be a no-op. - self.comp._process_hotkeys(bar=1) +class TestProcessHotkeys: + def setup_method(self) -> None: + self.comp = comp_mod.Composition.__new__(comp_mod.Composition) + self.comp._hotkeys_enabled = True + self.comp._hotkey_bindings = {} + self.comp._pending_hotkey_actions = [] + self.comp._form_state = None + + # Replace the real keystroke listener with a controllable mock. + self.mock_listener = unittest.mock.MagicMock() + self.mock_listener.drain.return_value = [] + self.comp._keystroke_listener = self.mock_listener + + def _register(self, key: str, fn: typing.Callable, quantize: int = 0) -> None: + self.comp._hotkey_bindings[key] = comp_mod.HotkeyBinding( + key=key, action=fn, quantize=quantize, label=key + ) + + # --- immediate actions -------------------------------------------------- + + def test_immediate_action_fires_on_bar(self) -> None: + called = [] + self._register("a", lambda: called.append(True)) + self.mock_listener.drain.return_value = ["a"] + self.comp._process_hotkeys(bar=1) + assert called == [True] + + def test_unknown_key_is_ignored(self) -> None: + self.mock_listener.drain.return_value = ["z"] + # Should not raise. + self.comp._process_hotkeys(bar=1) + + def test_action_exception_is_swallowed(self) -> None: + def boom() -> None: + raise RuntimeError("oops") + + self._register("a", boom) + self.mock_listener.drain.return_value = ["a"] + # Should not propagate. + self.comp._process_hotkeys(bar=1) + + # --- quantized actions -------------------------------------------------- + + def test_quantized_action_is_deferred(self) -> None: + called = [] + self._register("a", lambda: called.append(True), quantize=4) + self.mock_listener.drain.return_value = ["a"] + # Bar 1 is not divisible by 4 — should not fire yet. + self.comp._process_hotkeys(bar=1) + assert called == [] + assert len(self.comp._pending_hotkey_actions) == 1 + + def test_quantized_action_fires_at_boundary(self) -> None: + called = [] + self._register("a", lambda: called.append(True), quantize=4) + + # Enqueue manually at bar 1. + self.comp._pending_hotkey_actions.append( + comp_mod._PendingHotkeyAction( + binding=self.comp._hotkey_bindings["a"], + ) + ) + + # Bar 4 is divisible by 4 — should fire. + self.comp._process_hotkeys(bar=4) + assert called == [True] + assert len(self.comp._pending_hotkey_actions) == 0 + + def test_quantized_action_remains_pending_before_boundary(self) -> None: + called = [] + self._register("a", lambda: called.append(True), quantize=4) + self.comp._pending_hotkey_actions.append( + comp_mod._PendingHotkeyAction( + binding=self.comp._hotkey_bindings["a"], + ) + ) + # Bar 2 is not divisible by 4. + self.comp._process_hotkeys(bar=2) + assert called == [] + assert len(self.comp._pending_hotkey_actions) == 1 + + # --- ? key -------------------------------------------------------------- + + def test_question_mark_calls_list_hotkeys(self) -> None: + self.mock_listener.drain.return_value = [comp_mod._HOTKEY_RESERVED] + self.comp._list_hotkeys = unittest.mock.MagicMock() + self.comp._process_hotkeys(bar=1) + self.comp._list_hotkeys.assert_called_once() + + # --- no listener -------------------------------------------------------- + + def test_no_listener_returns_early(self) -> None: + self.comp._keystroke_listener = None + # Should be a no-op. + self.comp._process_hotkeys(bar=1) diff --git a/tests/test_instrument_drum_maps.py b/tests/test_instrument_drum_maps.py index d6434ad..1a83740 100644 --- a/tests/test_instrument_drum_maps.py +++ b/tests/test_instrument_drum_maps.py @@ -18,156 +18,189 @@ # ── DRM1: faithful aliases kept, approximations removed ───────────────────── -def test_drm1_keeps_faithful_gm_aliases () -> None: - """The DRM1 map aliases GM names only for the voices it really has.""" - - m = drm1.VERMONA_DRM1_DRUM_MAP - - assert m["kick_1"] == drm1.KICK - assert m["kick_2"] == drm1.KICK - assert m["snare_1"] == drm1.SNARE - assert m["snare_2"] == drm1.SNARE - assert m["hand_clap"] == drm1.CLAP # GM 39 == DRM1 CLAP 39 - assert m["hi_hat_closed"] == drm1.HIHAT_1_CLOSED # note 44 (not GM's 42) - assert m["hi_hat_pedal"] == drm1.HIHAT_1_CLOSED - assert m["hi_hat_open"] == drm1.HIHAT_1_OPEN - - -def test_drm1_native_names_unchanged () -> None: - - """The DRM1's own voice names are untouched by the trim.""" - - m = drm1.VERMONA_DRM1_DRUM_MAP - - for name in ("kick", "snare", "clap", "drum_1", "drum_2", "multi", - "hihat_1_closed", "hihat_1_open", "hihat_2_closed", "hihat_2_open"): - assert name in m - - -def test_drm1_approximations_removed () -> None: - - """The subjective GM approximations are gone — the DRM1 lacks these voices.""" - - m = drm1.VERMONA_DRM1_DRUM_MAP - - for name in ("side_stick", "low_tom", "low_floor_tom", "high_tom", - "crash_1", "crash_2", "splash_cymbal", "chinese_cymbal", - "ride_1", "ride_2", "ride_bell", "tambourine", "cabasa", - "maracas", "shaker", "cowbell", "claves", "high_woodblock", - "low_bongo", "mute_high_conga", "high_agogo", "vibraslap"): - assert name not in m +def test_drm1_keeps_faithful_gm_aliases() -> None: + """The DRM1 map aliases GM names only for the voices it really has.""" + + m = drm1.VERMONA_DRM1_DRUM_MAP + + assert m["kick_1"] == drm1.KICK + assert m["kick_2"] == drm1.KICK + assert m["snare_1"] == drm1.SNARE + assert m["snare_2"] == drm1.SNARE + assert m["hand_clap"] == drm1.CLAP # GM 39 == DRM1 CLAP 39 + assert m["hi_hat_closed"] == drm1.HIHAT_1_CLOSED # note 44 (not GM's 42) + assert m["hi_hat_pedal"] == drm1.HIHAT_1_CLOSED + assert m["hi_hat_open"] == drm1.HIHAT_1_OPEN + + +def test_drm1_native_names_unchanged() -> None: + """The DRM1's own voice names are untouched by the trim.""" + + m = drm1.VERMONA_DRM1_DRUM_MAP + + for name in ( + "kick", + "snare", + "clap", + "drum_1", + "drum_2", + "multi", + "hihat_1_closed", + "hihat_1_open", + "hihat_2_closed", + "hihat_2_open", + ): + assert name in m + + +def test_drm1_approximations_removed() -> None: + """The subjective GM approximations are gone — the DRM1 lacks these voices.""" + + m = drm1.VERMONA_DRM1_DRUM_MAP + + for name in ( + "side_stick", + "low_tom", + "low_floor_tom", + "high_tom", + "crash_1", + "crash_2", + "splash_cymbal", + "chinese_cymbal", + "ride_1", + "ride_2", + "ride_bell", + "tambourine", + "cabasa", + "maracas", + "shaker", + "cowbell", + "claves", + "high_woodblock", + "low_bongo", + "mute_high_conga", + "high_agogo", + "vibraslap", + ): + assert name not in m # ── TR-8S: faithful GM aliases added ──────────────────────────────────────── -def test_tr8s_faithful_gm_aliases () -> None: - - """The TR-8S gains GM aliases for the voices it genuinely has.""" - - m = tr8s.ROLAND_TR8S_DRUM_MAP - assert m["kick_1"] == tr8s.BD - assert m["snare_1"] == tr8s.SD - assert m["side_stick"] == tr8s.RS # GM 37 == TR-8S RS 37 - assert m["hand_clap"] == tr8s.HC - assert m["hi_hat_closed"] == tr8s.CH # note 42 - assert m["hi_hat_pedal"] == tr8s.CH - assert m["hi_hat_open"] == tr8s.OH - assert m["crash_1"] == tr8s.CC - assert m["ride_1"] == tr8s.RC +def test_tr8s_faithful_gm_aliases() -> None: + """The TR-8S gains GM aliases for the voices it genuinely has.""" + m = tr8s.ROLAND_TR8S_DRUM_MAP -def test_tr8s_toms_grouped_by_register () -> None: + assert m["kick_1"] == tr8s.BD + assert m["snare_1"] == tr8s.SD + assert m["side_stick"] == tr8s.RS # GM 37 == TR-8S RS 37 + assert m["hand_clap"] == tr8s.HC + assert m["hi_hat_closed"] == tr8s.CH # note 42 + assert m["hi_hat_pedal"] == tr8s.CH + assert m["hi_hat_open"] == tr8s.OH + assert m["crash_1"] == tr8s.CC + assert m["ride_1"] == tr8s.RC - """GM's six toms map onto the TR-8S's three by register.""" - m = tr8s.ROLAND_TR8S_DRUM_MAP +def test_tr8s_toms_grouped_by_register() -> None: + """GM's six toms map onto the TR-8S's three by register.""" - assert m["low_floor_tom"] == tr8s.LT - assert m["high_floor_tom"] == tr8s.LT - assert m["low_tom"] == tr8s.MT - assert m["low_mid_tom"] == tr8s.MT - assert m["high_mid_tom"] == tr8s.HT - assert m["high_tom"] == tr8s.HT + m = tr8s.ROLAND_TR8S_DRUM_MAP + assert m["low_floor_tom"] == tr8s.LT + assert m["high_floor_tom"] == tr8s.LT + assert m["low_tom"] == tr8s.MT + assert m["low_mid_tom"] == tr8s.MT + assert m["high_mid_tom"] == tr8s.HT + assert m["high_tom"] == tr8s.HT -def test_tr8s_omits_absent_voices () -> None: - """GM voices the TR-8S lacks are not aliased (no approximations).""" +def test_tr8s_omits_absent_voices() -> None: + """GM voices the TR-8S lacks are not aliased (no approximations).""" - m = tr8s.ROLAND_TR8S_DRUM_MAP + m = tr8s.ROLAND_TR8S_DRUM_MAP - for name in ("cowbell", "tambourine", "splash_cymbal", "chinese_cymbal", - "ride_bell", "low_conga", "high_bongo", "claves", "shaker", - "short_guiro", "mute_triangle"): - assert name not in m + for name in ( + "cowbell", + "tambourine", + "splash_cymbal", + "chinese_cymbal", + "ride_bell", + "low_conga", + "high_bongo", + "claves", + "shaker", + "short_guiro", + "mute_triangle", + ): + assert name not in m # ── Cross-map canonical contract (the symbolic-mirror premise) ────────────── -def test_shared_gm_vocabulary_across_devices () -> None: - """The faithful kit names exist in all three maps — the shared vocabulary.""" +def test_shared_gm_vocabulary_across_devices() -> None: + """The faithful kit names exist in all three maps — the shared vocabulary.""" - for name in ("kick_1", "snare_1", "hand_clap", "hi_hat_closed", "hi_hat_open"): - assert name in drm1.VERMONA_DRM1_DRUM_MAP - assert name in tr8s.ROLAND_TR8S_DRUM_MAP - assert name in gm.GM_DRUM_MAP + for name in ("kick_1", "snare_1", "hand_clap", "hi_hat_closed", "hi_hat_open"): + assert name in drm1.VERMONA_DRM1_DRUM_MAP + assert name in tr8s.ROLAND_TR8S_DRUM_MAP + assert name in gm.GM_DRUM_MAP -def test_same_name_resolves_to_device_specific_notes () -> None: +def test_same_name_resolves_to_device_specific_notes() -> None: + """One canonical name → the correct, *different* note on each device. - """One canonical name → the correct, *different* note on each device. + This is exactly what symbolic mirroring relies on: ``"hi_hat_closed"`` is + note 44 on the DRM1 but 42 in General MIDI / on the TR-8S. + """ - This is exactly what symbolic mirroring relies on: ``"hi_hat_closed"`` is - note 44 on the DRM1 but 42 in General MIDI / on the TR-8S. - """ - - assert drm1.VERMONA_DRM1_DRUM_MAP["hi_hat_closed"] == 44 - assert gm.GM_DRUM_MAP["hi_hat_closed"] == 42 - assert tr8s.ROLAND_TR8S_DRUM_MAP["hi_hat_closed"] == 42 + assert drm1.VERMONA_DRM1_DRUM_MAP["hi_hat_closed"] == 44 + assert gm.GM_DRUM_MAP["hi_hat_closed"] == 42 + assert tr8s.ROLAND_TR8S_DRUM_MAP["hi_hat_closed"] == 42 # ── Unnumbered primary aliases (kick/snare/crash/ride) ────────────────────── -def test_gm_map_includes_primary_aliases () -> None: - - """gm_drums.GM_DRUM_MAP resolves both the bare primaries and the numbered names.""" - - m = gm.GM_DRUM_MAP - assert m["kick"] == m["kick_1"] == 36 - assert m["snare"] == m["snare_1"] == 38 - assert m["crash"] == m["crash_1"] == 49 - assert m["ride"] == m["ride_1"] == 51 - -def test_gm_map_is_superset_of_pure_spec () -> None: +def test_gm_map_includes_primary_aliases() -> None: + """gm_drums.GM_DRUM_MAP resolves both the bare primaries and the numbered names.""" - """Subsequence merges the aliases in; the upstream pymididefs spec stays clean.""" + m = gm.GM_DRUM_MAP + assert m["kick"] == m["kick_1"] == 36 + assert m["snare"] == m["snare_1"] == 38 + assert m["crash"] == m["crash_1"] == 49 + assert m["ride"] == m["ride_1"] == 51 - assert gm.GM_DRUM_MAP["kick"] == 36 - assert "kick" not in pymididefs.drums.GM_DRUM_MAP # pure spec is one name per note - assert len(gm.GM_DRUM_MAP) > len(pymididefs.drums.GM_DRUM_MAP) # aliases merged on top of the clean spec +def test_gm_map_is_superset_of_pure_spec() -> None: + """Subsequence merges the aliases in; the upstream pymididefs spec stays clean.""" -def test_tr8s_primary_aliases () -> None: + assert gm.GM_DRUM_MAP["kick"] == 36 + assert "kick" not in pymididefs.drums.GM_DRUM_MAP # pure spec is one name per note + assert len(gm.GM_DRUM_MAP) > len( + pymididefs.drums.GM_DRUM_MAP + ) # aliases merged on top of the clean spec - """The TR-8S has a real kick / snare / crash / ride, so all four bare names resolve.""" - m = tr8s.ROLAND_TR8S_DRUM_MAP - assert m["kick"] == tr8s.BD - assert m["snare"] == tr8s.SD - assert m["crash"] == tr8s.CC - assert m["ride"] == tr8s.RC +def test_tr8s_primary_aliases() -> None: + """The TR-8S has a real kick / snare / crash / ride, so all four bare names resolve.""" + m = tr8s.ROLAND_TR8S_DRUM_MAP + assert m["kick"] == tr8s.BD + assert m["snare"] == tr8s.SD + assert m["crash"] == tr8s.CC + assert m["ride"] == tr8s.RC -def test_drm1_primary_aliases_only_where_voiced () -> None: - """The DRM1 has kick/snare (native) but no crash/ride voice — so neither is aliased.""" +def test_drm1_primary_aliases_only_where_voiced() -> None: + """The DRM1 has kick/snare (native) but no crash/ride voice — so neither is aliased.""" - m = drm1.VERMONA_DRM1_DRUM_MAP - assert m["kick"] == drm1.KICK - assert m["snare"] == drm1.SNARE - assert "crash" not in m - assert "ride" not in m + m = drm1.VERMONA_DRM1_DRUM_MAP + assert m["kick"] == drm1.KICK + assert m["snare"] == drm1.SNARE + assert "crash" not in m + assert "ride" not in m diff --git a/tests/test_intervals.py b/tests/test_intervals.py index d7a4211..7d8e8ef 100644 --- a/tests/test_intervals.py +++ b/tests/test_intervals.py @@ -2,81 +2,72 @@ import subsequence.intervals -def test_get_intervals () -> None: +def test_get_intervals() -> None: + """ + Interval lookup should return a known definition. + """ - """ - Interval lookup should return a known definition. - """ + assert subsequence.intervals.get_intervals("major_triad") == [0, 4, 7] - assert subsequence.intervals.get_intervals("major_triad") == [0, 4, 7] +def test_get_diatonic_intervals() -> None: + """ + Diatonic chord construction should return the expected tonic triad. + """ -def test_get_diatonic_intervals () -> None: + major_scale = [0, 2, 4, 5, 7, 9, 11] + chords = subsequence.intervals.get_diatonic_intervals(major_scale) - """ - Diatonic chord construction should return the expected tonic triad. - """ + assert chords[0] == [0, 4, 7] - major_scale = [0, 2, 4, 5, 7, 9, 11] - chords = subsequence.intervals.get_diatonic_intervals(major_scale) - assert chords[0] == [0, 4, 7] +def test_scale_notes_range_c_major() -> None: + """C major from C4 to C5 should return all 8 notes.""" + result = subsequence.scale_notes("C", "ionian", low=60, high=72) + assert result == [60, 62, 64, 65, 67, 69, 71, 72] -def test_scale_notes_range_c_major () -> None: - """C major from C4 to C5 should return all 8 notes.""" +def test_scale_notes_range_e_minor() -> None: + """E natural minor (aeolian) from E2 to E3 should return one octave.""" - result = subsequence.scale_notes("C", "ionian", low=60, high=72) - assert result == [60, 62, 64, 65, 67, 69, 71, 72] + result = subsequence.scale_notes("E", "aeolian", low=40, high=52) + assert result == [40, 42, 43, 45, 47, 48, 50, 52] -def test_scale_notes_range_e_minor () -> None: +def test_scale_notes_count_one_octave() -> None: + """count=8 from C4 should match range C4–C5.""" - """E natural minor (aeolian) from E2 to E3 should return one octave.""" + result = subsequence.scale_notes("C", "ionian", low=60, count=8) + assert result == [60, 62, 64, 65, 67, 69, 71, 72] - result = subsequence.scale_notes("E", "aeolian", low=40, high=52) - assert result == [40, 42, 43, 45, 47, 48, 50, 52] +def test_scale_notes_count_multi_octave() -> None: + """count=15 should continue ascending into higher octaves.""" -def test_scale_notes_count_one_octave () -> None: + result = subsequence.scale_notes("C", "ionian", low=60, count=15) + assert len(result) == 15 + assert result[0] == 60 + assert result[7] == 72 # C5 — octave above start + assert result[14] == 84 # C6 — two octaves above C4 - """count=8 from C4 should match range C4–C5.""" - result = subsequence.scale_notes("C", "ionian", low=60, count=8) - assert result == [60, 62, 64, 65, 67, 69, 71, 72] +def test_scale_notes_pentatonic() -> None: + """Major pentatonic has 5 notes per octave.""" + result = subsequence.scale_notes("C", "major_pentatonic", low=60, high=72) + assert result == [60, 62, 64, 67, 69, 72] -def test_scale_notes_count_multi_octave () -> None: - """count=15 should continue ascending into higher octaves.""" +def test_scale_notes_custom_scale() -> None: + """Custom registered scales should work with scale_notes().""" - result = subsequence.scale_notes("C", "ionian", low=60, count=15) - assert len(result) == 15 - assert result[0] == 60 - assert result[7] == 72 # C5 — octave above start - assert result[14] == 84 # C6 — two octaves above C4 + subsequence.register_scale("test_wholetone_sn", [0, 2, 4, 6, 8, 10]) + result = subsequence.scale_notes("C", "test_wholetone_sn", low=60, high=71) + assert result == [60, 62, 64, 66, 68, 70] -def test_scale_notes_pentatonic () -> None: +def test_scale_notes_exported_at_package_level() -> None: + """scale_notes should be importable directly from subsequence.""" - """Major pentatonic has 5 notes per octave.""" - - result = subsequence.scale_notes("C", "major_pentatonic", low=60, high=72) - assert result == [60, 62, 64, 67, 69, 72] - - -def test_scale_notes_custom_scale () -> None: - - """Custom registered scales should work with scale_notes().""" - - subsequence.register_scale("test_wholetone_sn", [0, 2, 4, 6, 8, 10]) - result = subsequence.scale_notes("C", "test_wholetone_sn", low=60, high=71) - assert result == [60, 62, 64, 66, 68, 70] - - -def test_scale_notes_exported_at_package_level () -> None: - - """scale_notes should be importable directly from subsequence.""" - - assert callable(subsequence.scale_notes) + assert callable(subsequence.scale_notes) diff --git a/tests/test_key_resolution.py b/tests/test_key_resolution.py index d6e4a4e..617525c 100644 --- a/tests/test_key_resolution.py +++ b/tests/test_key_resolution.py @@ -26,12 +26,15 @@ import subsequence.melodic_state -def _placed_pitches (composition: "subsequence.composition.Composition", index: int = 0) -> typing.List[int]: +def _placed_pitches( + composition: "subsequence.composition.Composition", index: int = 0 +) -> typing.List[int]: + """Build the pending pattern at *index* and return the MIDI note numbers it placed.""" - """Build the pending pattern at *index* and return the MIDI note numbers it placed.""" - - pattern = composition._build_pattern_from_pending(composition._pending_patterns[index]) - return [note.pitch for step in pattern.steps.values() for note in step.notes] + pattern = composition._build_pattern_from_pending( + composition._pending_patterns[index] + ) + return [note.pitch for step in pattern.steps.values() for note in step.notes] # --------------------------------------------------------------------------- @@ -39,34 +42,41 @@ def _placed_pitches (composition: "subsequence.composition.Composition", index: # --------------------------------------------------------------------------- -def test_absolute_notes_ignore_section_key (patch_midi: None) -> None: - - """MIDI note atoms stay put regardless of the section key.""" +def test_absolute_notes_ignore_section_key(patch_midi: None) -> None: + """MIDI note atoms stay put regardless of the section key.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4, key="A")])) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form(subsequence.Form([subsequence.Section("verse", 4, key="A")])) - @composition.pattern(channel=1, beats=4) - def lead (p: typing.Any) -> None: - p.motif(subsequence.Motif.notes([60, 64, 67]), root=60) + @composition.pattern(channel=1, beats=4) + def lead(p: typing.Any) -> None: + p.motif(subsequence.Motif.notes([60, 64, 67]), root=60) - assert _placed_pitches(composition) == [60, 64, 67] + assert _placed_pitches(composition) == [60, 64, 67] -def test_chord_name_progression_ignores_section_key (patch_midi: None) -> None: +def test_chord_name_progression_ignores_section_key(patch_midi: None) -> None: + """A name-spelled progression is absolute — the section key does not move it.""" - """A name-spelled progression is absolute — the section key does not move it.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.section_chords("verse", ["Am", "F"]) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.section_chords("verse", ["Am", "F"]) + bound = composition._section_progressions["verse"] + assert bound.is_concrete # names froze at parse — nothing to re-key - bound = composition._section_progressions["verse"] - assert bound.is_concrete # names froze at parse — nothing to re-key - - info = subsequence.form_state.SectionInfo(name="verse", bar=0, bars=4, index=0, key="A") - resolved = composition._resolve_section_progression(info) - assert resolved is not None - assert [c.name() for c in resolved.chords] == ["Am", "F"] # still A minor / F, not transposed + info = subsequence.form_state.SectionInfo( + name="verse", bar=0, bars=4, index=0, key="A" + ) + resolved = composition._resolve_section_progression(info) + assert resolved is not None + assert [c.name() for c in resolved.chords] == [ + "Am", + "F", + ] # still A minor / F, not transposed # --------------------------------------------------------------------------- @@ -74,83 +84,101 @@ def test_chord_name_progression_ignores_section_key (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_effective_key_precedence () -> None: - - """Section > form > composition, key and scale resolved independently.""" - - comp = subsequence.composition.Composition.__new__(subsequence.composition.Composition) - comp.key = "C" - comp.scale = "major" - comp._form_key = None - comp._form_scale = None - - S = subsequence.form_state.SectionInfo - - # Nothing overrides → composition. - assert comp._effective_key_scale(S("v", 0, 4, 0)) == ("C", "major") +def test_effective_key_precedence() -> None: + """Section > form > composition, key and scale resolved independently.""" - # Form tier overrides composition. - comp._form_key, comp._form_scale = "G", None - assert comp._effective_key_scale(S("v", 0, 4, 0)) == ("G", "major") # scale falls to composition + comp = subsequence.composition.Composition.__new__( + subsequence.composition.Composition + ) + comp.key = "C" + comp.scale = "major" + comp._form_key = None + comp._form_scale = None - # Section overrides form; key and scale independent. - assert comp._effective_key_scale(S("v", 0, 4, 0, key="A", scale="minor")) == ("A", "minor") - assert comp._effective_key_scale(S("v", 0, 4, 0, key="A")) == ("A", "major") # tonic moves, mode stays + S = subsequence.form_state.SectionInfo + # Nothing overrides → composition. + assert comp._effective_key_scale(S("v", 0, 4, 0)) == ("C", "major") -def test_section_key_re_anchors_degrees (patch_midi: None) -> None: + # Form tier overrides composition. + comp._form_key, comp._form_scale = "G", None + assert comp._effective_key_scale(S("v", 0, 4, 0)) == ( + "G", + "major", + ) # scale falls to composition - """Degree 1 follows the section key.""" + # Section overrides form; key and scale independent. + assert comp._effective_key_scale(S("v", 0, 4, 0, key="A", scale="minor")) == ( + "A", + "minor", + ) + assert comp._effective_key_scale(S("v", 0, 4, 0, key="A")) == ( + "A", + "major", + ) # tonic moves, mode stays - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4, key="D")])) - @composition.pattern(channel=1, beats=4) - def lead (p: typing.Any) -> None: - p.motif(subsequence.motif([1]), root=60) +def test_section_key_re_anchors_degrees(patch_midi: None) -> None: + """Degree 1 follows the section key.""" - assert _placed_pitches(composition) == [62] # D, nearest middle C + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form(subsequence.Form([subsequence.Section("verse", 4, key="D")])) + @composition.pattern(channel=1, beats=4) + def lead(p: typing.Any) -> None: + p.motif(subsequence.motif([1]), root=60) -def test_section_mode_travels (patch_midi: None) -> None: + assert _placed_pitches(composition) == [62] # D, nearest middle C - """Section.scale moves the mode: degree 3 of A minor is C, not C#.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4, key="A", scale="minor")])) +def test_section_mode_travels(patch_midi: None) -> None: + """Section.scale moves the mode: degree 3 of A minor is C, not C#.""" - @composition.pattern(channel=1, beats=4) - def lead (p: typing.Any) -> None: - p.motif(subsequence.motif([3]), root=60) + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1 + ) + composition.form( + subsequence.Form([subsequence.Section("verse", 4, key="A", scale="minor")]) + ) - # Degree 3 in A minor = C (60); in A major it would be C# (61). - assert _placed_pitches(composition) == [60] + @composition.pattern(channel=1, beats=4) + def lead(p: typing.Any) -> None: + p.motif(subsequence.motif([3]), root=60) + # Degree 3 in A minor = C (60); in A major it would be C# (61). + assert _placed_pitches(composition) == [60] -def test_form_key_tier (patch_midi: None) -> None: - """A form key re-anchors degrees when no section key overrides it.""" +def test_form_key_tier(patch_midi: None) -> None: + """A form key re-anchors degrees when no section key overrides it.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form([("verse", 4)], key="E") + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form([("verse", 4)], key="E") - @composition.pattern(channel=1, beats=4) - def lead (p: typing.Any) -> None: - p.motif(subsequence.motif([1]), root=60) + @composition.pattern(channel=1, beats=4) + def lead(p: typing.Any) -> None: + p.motif(subsequence.motif([1]), root=60) - assert _placed_pitches(composition) == [64] # E + assert _placed_pitches(composition) == [64] # E -def test_form_value_carries_its_own_key (patch_midi: None) -> None: +def test_form_value_carries_its_own_key(patch_midi: None) -> None: + """A Form(key=...) seeds the form tier; an explicit form(key=) overrides it.""" - """A Form(key=...) seeds the form tier; an explicit form(key=) overrides it.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form(subsequence.Form([subsequence.Section("verse", 4)], key="E")) + assert composition._form_key == "E" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4)], key="E")) - assert composition._form_key == "E" - - composition.form(subsequence.Form([subsequence.Section("verse", 4)], key="E"), key="G") - assert composition._form_key == "G" # explicit argument wins + composition.form( + subsequence.Form([subsequence.Section("verse", 4)], key="E"), key="G" + ) + assert composition._form_key == "G" # explicit argument wins # --------------------------------------------------------------------------- @@ -158,89 +186,114 @@ def test_form_value_carries_its_own_key (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_relative_section_progression_re_keys (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """The truck-driver's modulation: same numbered progression, two keys, two sections.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=1) - composition.form(subsequence.Form([ - subsequence.Section("chorus", 4), - subsequence.Section("chorus2", 4, key="D"), - ])) - - prog = subsequence.progression([1, 4, 5, 1]) - composition.section_chords("chorus", prog) - composition.section_chords("chorus2", prog) # the SAME relative value - - heard: typing.List[typing.Tuple[str, str]] = [] - - @composition.pattern(channel=1, beats=4) - def pads (p, chord) -> None: - heard.append((p.section.name if p.section else "?", chord.name())) - p.note(60, beat=0) - - composition.render(bars=8, filename=str(tmp_path / "m.mid")) - - chorus = [name for section, name in heard if section == "chorus"] - chorus2 = [name for section, name in heard if section == "chorus2"] - - assert chorus[0] == "C" # I-IV-V-I in C - assert chorus2[0] == "D" # the SAME numbers, a tone up in D - assert chorus2[:4] == ["D", "G", "A", "D"] - - -def test_section_key_moves_melody_and_chords_together (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """Within one re-keyed section, the degree melody and the numbered chords share a tonic.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4, key="A", scale="minor")])) - composition.section_chords("verse", subsequence.progression([1])) - - roots: typing.List[int] = [] - chord_roots: typing.List[str] = [] - - @composition.pattern(channel=1, beats=4) - def lead (p, chord) -> None: - chord_roots.append(chord.name()) - p.motif(subsequence.motif([1]), root=60) - roots.extend(note.pitch for step in p._pattern.steps.values() for note in step.notes) - - composition.render(bars=2, filename=str(tmp_path / "v.mid")) - - assert roots and roots[0] == 57 # degree 1 melody = A (nearest 60) - assert chord_roots[0] == "Am" # degree-1 chord = A minor — same tonic - - -def test_section_fall_through_uses_composition_key (patch_midi: None) -> None: - - """A re-keyed section that exhausts hands off to the live engine in the composition key.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1) - composition.harmony(style="functional_major", cycle_beats=4) - composition.form(subsequence.Form([subsequence.Section("verse", 8, key="D")])) - composition.section_chords("verse", subsequence.progression([1, 4])) # only 2 bars of an 8-bar section - - info = subsequence.form_state.SectionInfo(name="verse", bar=0, bars=8, index=0, key="D") - resolved = composition._resolve_section_progression(info) - - assert resolved is not None - assert [c.name() for c in resolved.chords] == ["D", "G"] # the written bars are in D - # Bars 3-8 fall through to the live engine, which is fixed in C (documented). - assert composition._harmonic_state is not None - assert composition._harmonic_state.key_root_pc == subsequence.chords.key_name_to_pc("C") - - -def test_relative_section_no_key_raises_at_run (patch_midi: None) -> None: - - """A key-relative section progression with no resolvable key fails at play/render, clearly.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, seed=1) # no key - composition.form([("verse", 4)]) - composition.section_chords("verse", subsequence.progression([1, 4, 5])) - - with pytest.raises(ValueError, match="no key resolves"): - composition.render(bars=4, filename="unused.mid") +def test_relative_section_progression_re_keys( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """The truck-driver's modulation: same numbered progression, two keys, two sections.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=1 + ) + composition.form( + subsequence.Form( + [ + subsequence.Section("chorus", 4), + subsequence.Section("chorus2", 4, key="D"), + ] + ) + ) + + prog = subsequence.progression([1, 4, 5, 1]) + composition.section_chords("chorus", prog) + composition.section_chords("chorus2", prog) # the SAME relative value + + heard: typing.List[typing.Tuple[str, str]] = [] + + @composition.pattern(channel=1, beats=4) + def pads(p, chord) -> None: + heard.append((p.section.name if p.section else "?", chord.name())) + p.note(60, beat=0) + + composition.render(bars=8, filename=str(tmp_path / "m.mid")) + + chorus = [name for section, name in heard if section == "chorus"] + chorus2 = [name for section, name in heard if section == "chorus2"] + + assert chorus[0] == "C" # I-IV-V-I in C + assert chorus2[0] == "D" # the SAME numbers, a tone up in D + assert chorus2[:4] == ["D", "G", "A", "D"] + + +def test_section_key_moves_melody_and_chords_together( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Within one re-keyed section, the degree melody and the numbered chords share a tonic.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=1 + ) + composition.form( + subsequence.Form([subsequence.Section("verse", 4, key="A", scale="minor")]) + ) + composition.section_chords("verse", subsequence.progression([1])) + + roots: typing.List[int] = [] + chord_roots: typing.List[str] = [] + + @composition.pattern(channel=1, beats=4) + def lead(p, chord) -> None: + chord_roots.append(chord.name()) + p.motif(subsequence.motif([1]), root=60) + roots.extend( + note.pitch for step in p._pattern.steps.values() for note in step.notes + ) + + composition.render(bars=2, filename=str(tmp_path / "v.mid")) + + assert roots and roots[0] == 57 # degree 1 melody = A (nearest 60) + assert chord_roots[0] == "Am" # degree-1 chord = A minor — same tonic + + +def test_section_fall_through_uses_composition_key(patch_midi: None) -> None: + """A re-keyed section that exhausts hands off to the live engine in the composition key.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1 + ) + composition.harmony(style="functional_major", cycle_beats=4) + composition.form(subsequence.Form([subsequence.Section("verse", 8, key="D")])) + composition.section_chords( + "verse", subsequence.progression([1, 4]) + ) # only 2 bars of an 8-bar section + + info = subsequence.form_state.SectionInfo( + name="verse", bar=0, bars=8, index=0, key="D" + ) + resolved = composition._resolve_section_progression(info) + + assert resolved is not None + assert [c.name() for c in resolved.chords] == [ + "D", + "G", + ] # the written bars are in D + # Bars 3-8 fall through to the live engine, which is fixed in C (documented). + assert composition._harmonic_state is not None + assert composition._harmonic_state.key_root_pc == subsequence.chords.key_name_to_pc( + "C" + ) + + +def test_relative_section_no_key_raises_at_run(patch_midi: None) -> None: + """A key-relative section progression with no resolvable key fails at play/render, clearly.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, seed=1 + ) # no key + composition.form([("verse", 4)]) + composition.section_chords("verse", subsequence.progression([1, 4, 5])) + + with pytest.raises(ValueError, match="no key resolves"): + composition.render(bars=4, filename="unused.mid") # --------------------------------------------------------------------------- @@ -248,36 +301,42 @@ def test_relative_section_no_key_raises_at_run (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_re_keying_is_deterministic (tmp_path: pathlib.Path, patch_midi: None) -> None: +def test_re_keying_is_deterministic(tmp_path: pathlib.Path, patch_midi: None) -> None: + """Same seed + same key → byte-identical render (deferred resolution stays pure).""" - """Same seed + same key → byte-identical render (deferred resolution stays pure).""" + def render(path: str) -> bytes: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=7 + ) + composition.form( + subsequence.Form([subsequence.Section("v", 4, key="A", scale="minor")]) + ) + composition.section_chords("v", subsequence.progression([1, 4, 5, 1])) - def render (path: str) -> bytes: - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C", scale="major", seed=7) - composition.form(subsequence.Form([subsequence.Section("v", 4, key="A", scale="minor")])) - composition.section_chords("v", subsequence.progression([1, 4, 5, 1])) + @composition.pattern(channel=1, beats=4) + def pads(p, chord) -> None: + p.chord(chord, root=48, beat=0) - @composition.pattern(channel=1, beats=4) - def pads (p, chord) -> None: - p.chord(chord, root=48, beat=0) + composition.render(bars=4, filename=path) + return pathlib.Path(path).read_bytes() - composition.render(bars=4, filename=path) - return pathlib.Path(path).read_bytes() + assert render(str(tmp_path / "a.mid")) == render(str(tmp_path / "b.mid")) - assert render(str(tmp_path / "a.mid")) == render(str(tmp_path / "b.mid")) +def test_resolution_cache_reuses_one_realisation(patch_midi: None) -> None: + """Repeated resolution of a stable section returns the same object (memoised).""" -def test_resolution_cache_reuses_one_realisation (patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1 + ) + composition.section_chords("verse", subsequence.progression([1, 4])) - """Repeated resolution of a stable section returns the same object (memoised).""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1) - composition.section_chords("verse", subsequence.progression([1, 4])) - - info = subsequence.form_state.SectionInfo(name="verse", bar=0, bars=4, index=0, key="A", scale="minor") - first = composition._resolve_section_progression(info) - second = composition._resolve_section_progression(info) - assert first is second # memoised by (name, key, scale) + info = subsequence.form_state.SectionInfo( + name="verse", bar=0, bars=4, index=0, key="A", scale="minor" + ) + first = composition._resolve_section_progression(info) + second = composition._resolve_section_progression(info) + assert first is second # memoised by (name, key, scale) # --------------------------------------------------------------------------- @@ -285,146 +344,178 @@ def test_resolution_cache_reuses_one_realisation (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_trigger_honours_the_key (patch_midi: None) -> None: - - """A degree in a one-shot resolves against the effective key (was: raised).""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="D", seed=1) - - placed: typing.List[int] = [] - composition._schedule_one_shot = lambda pattern, start_pulse: placed.extend( # type: ignore[method-assign] - note.pitch for step in pattern.steps.values() for note in step.notes - ) - - composition.trigger(lambda p: p.motif(subsequence.motif([1]), root=60), channel=1) - assert placed == [62] # degree 1 of D — no longer a crash - - -def test_trigger_uses_section_key (patch_midi: None) -> None: - - """A one-shot fired during a re-keyed section uses that section's key.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4, key="A")])) - - placed: typing.List[int] = [] - composition._schedule_one_shot = lambda pattern, start_pulse: placed.extend( # type: ignore[method-assign] - note.pitch for step in pattern.steps.values() for note in step.notes - ) - - composition.trigger(lambda p: p.motif(subsequence.motif([1]), root=60), channel=1) - assert placed == [57] # degree 1 of A (nearest 60) - - -def test_transition_fill_uses_section_key (patch_midi: None) -> None: - - """A degree-bearing fill resolves against the section it sounds in (was: composition key).""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 2, key="A"), subsequence.Section("chorus", 2)])) - composition.transition(before="*", fill=subsequence.motif([1], length=2), channel=1, beat=0.0) - - fired: typing.List[int] = [] - composition._schedule_one_shot = lambda pattern, start_pulse: fired.extend( # type: ignore[method-assign] - note.pitch for step in pattern.steps.values() for note in step.notes - ) - - state = composition._form_state - assert state is not None - state.advance() # into verse bar 1 (the last bar before chorus) - composition._check_transitions(96, False) - - assert fired == [57] # degree 1 of the verse's A, not C - +def test_trigger_honours_the_key(patch_midi: None) -> None: + """A degree in a one-shot resolves against the effective key (was: raised).""" -def test_pin_re_keys_to_the_pinned_bars_section_not_the_playhead (patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="D", seed=1 + ) - """A relative pin in a later, differently-keyed section keys to THAT section. + placed: typing.List[int] = [] + composition._schedule_one_shot = lambda pattern, start_pulse: placed.extend( # type: ignore[method-assign] + note.pitch for step in pattern.steps.values() for note in step.notes + ) - Regression for the review's HIGH finding: the clock's lookahead reads a - future bar's pin while the playhead is still in an earlier section, so - _resolve_pin must key the pin to the section that owns the bar. - """ + composition.trigger(lambda p: p.motif(subsequence.motif([1]), root=60), channel=1) + assert placed == [62] # degree 1 of D — no longer a crash - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1) - composition.form(subsequence.Form([ - subsequence.Section("verse", 8, key="C"), - subsequence.Section("chorus", 8, key="Eb"), - ])) - composition.pin_chord(9, "V") # bar 9 is the chorus (Eb) — V of Eb is Bb (pc 10) - # Regardless of where the playhead is, bar 9's pin resolves in Eb. - assert composition._resolve_pin(9).root_pc == subsequence.chords.key_name_to_pc("Bb") +def test_trigger_uses_section_key(patch_midi: None) -> None: + """A one-shot fired during a re-keyed section uses that section's key.""" - # And a pin in the verse keys to C. - composition.pin_chord(2, "V") - assert composition._resolve_pin(2).name() == "G" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form(subsequence.Form([subsequence.Section("verse", 4, key="A")])) + placed: typing.List[int] = [] + composition._schedule_one_shot = lambda pattern, start_pulse: placed.extend( # type: ignore[method-assign] + note.pitch for step in pattern.steps.values() for note in step.notes + ) -def test_pin_falls_back_to_playhead_for_graph_forms (patch_midi: None) -> None: + composition.trigger(lambda p: p.motif(subsequence.motif([1]), root=60), channel=1) + assert placed == [57] # degree 1 of A (nearest 60) - """Graph forms have no fixed layout, so a relative pin keys off the playhead section.""" - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1) - composition.form({"verse": (8, None)}, start="verse") - composition.pin_chord(2, "V") +def test_transition_fill_uses_section_key(patch_midi: None) -> None: + """A degree-bearing fill resolves against the section it sounds in (was: composition key).""" - assert composition._resolve_pin(2).name() == "G" # composition key, via playhead + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form( + subsequence.Form( + [subsequence.Section("verse", 2, key="A"), subsequence.Section("chorus", 2)] + ) + ) + composition.transition( + before="*", fill=subsequence.motif([1], length=2), channel=1, beat=0.0 + ) + fired: typing.List[int] = [] + composition._schedule_one_shot = lambda pattern, start_pulse: fired.extend( # type: ignore[method-assign] + note.pitch for step in pattern.steps.values() for note in step.notes + ) -def test_resolve_error_skips_section_not_crashes (patch_midi: None) -> None: + state = composition._form_state + assert state is not None + state.advance() # into verse bar 1 (the last bar before chorus) + composition._check_transitions(96, False) - """A degree out of range for the effective scale is caught at _run, with a clear message.""" + assert fired == [57] # degree 1 of the verse's A, not C - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1) - composition.form(subsequence.Form([subsequence.Section("verse", 4, scale="minor_pentatonic")])) - composition.section_chords("verse", subsequence.progression([1, 7])) # 7 is out of a 5-note scale - with pytest.raises(ValueError, match="does not resolve against its effective"): - composition.render(bars=4, filename="unused.mid") +def test_pin_re_keys_to_the_pinned_bars_section_not_the_playhead( + patch_midi: None, +) -> None: + """A relative pin in a later, differently-keyed section keys to THAT section. + Regression for the review's HIGH finding: the clock's lookahead reads a + future bar's pin while the playhead is still in an earlier section, so + _resolve_pin must key the pin to the section that owns the bar. + """ -def test_resolve_error_safety_net_returns_none (patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1 + ) + composition.form( + subsequence.Form( + [ + subsequence.Section("verse", 8, key="C"), + subsequence.Section("chorus", 8, key="Eb"), + ] + ) + ) + composition.pin_chord(9, "V") # bar 9 is the chorus (Eb) — V of Eb is Bb (pc 10) - """The clock-time resolve guard never lets a ValueError escape — it skips with a warning.""" + # Regardless of where the playhead is, bar 9's pin resolves in Eb. + assert composition._resolve_pin(9).root_pc == subsequence.chords.key_name_to_pc( + "Bb" + ) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1) - composition.section_chords("verse", subsequence.progression([1, 7])) + # And a pin in the verse keys to C. + composition.pin_chord(2, "V") + assert composition._resolve_pin(2).name() == "G" - # An effective scale that cannot hold degree 7 → None (fall-through), not a crash. - info = subsequence.form_state.SectionInfo(name="verse", bar=0, bars=4, index=0, scale="minor_pentatonic") - assert composition._resolve_section_progression(info) is None +def test_pin_falls_back_to_playhead_for_graph_forms(patch_midi: None) -> None: + """Graph forms have no fixed layout, so a relative pin keys off the playhead section.""" -def test_form_freeze_carries_form_key (patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1 + ) + composition.form({"verse": (8, None)}, start="verse") + composition.pin_chord(2, "V") - """A graph form's tier key survives freeze → rebind (the round-trip is lossless).""" + assert composition._resolve_pin(2).name() == "G" # composition key, via playhead - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=1) - composition.form({"intro": (4, [("verse", 1)]), "verse": (4, None)}, start="intro", key="A", scale="minor") - path = composition.form_freeze() - assert path.key == "A" and path.scale == "minor" +def test_resolve_error_skips_section_not_crashes(patch_midi: None) -> None: + """A degree out of range for the effective scale is caught at _run, with a clear message.""" - composition.form(path) # rebind without re-passing key - assert composition._form_key == "A" and composition._form_scale == "minor" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1 + ) + composition.form( + subsequence.Form([subsequence.Section("verse", 4, scale="minor_pentatonic")]) + ) + composition.section_chords( + "verse", subsequence.progression([1, 7]) + ) # 7 is out of a 5-note scale + + with pytest.raises(ValueError, match="does not resolve against its effective"): + composition.render(bars=4, filename="unused.mid") + + +def test_resolve_error_safety_net_returns_none(patch_midi: None) -> None: + """The clock-time resolve guard never lets a ValueError escape — it skips with a warning.""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", scale="major", seed=1 + ) + composition.section_chords("verse", subsequence.progression([1, 7])) + + # An effective scale that cannot hold degree 7 → None (fall-through), not a crash. + info = subsequence.form_state.SectionInfo( + name="verse", bar=0, bars=4, index=0, scale="minor_pentatonic" + ) + assert composition._resolve_section_progression(info) is None + + +def test_form_freeze_carries_form_key(patch_midi: None) -> None: + """A graph form's tier key survives freeze → rebind (the round-trip is lossless).""" + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=1 + ) + composition.form( + {"intro": (4, [("verse", 1)]), "verse": (4, None)}, + start="intro", + key="A", + scale="minor", + ) + path = composition.form_freeze() + assert path.key == "A" and path.scale == "minor" -def test_melodic_state_follows_section_key () -> None: + composition.form(path) # rebind without re-passing key + assert composition._form_key == "A" and composition._form_scale == "minor" - """A persistent MelodicState re-tracks the section key on each build (no first-use freeze).""" - state = subsequence.melodic_state.MelodicState() +def test_melodic_state_follows_section_key() -> None: + """A persistent MelodicState re-tracks the section key on each build (no first-use freeze).""" - state.configure_defaults("C", "major") - assert state.key == "C" + state = subsequence.melodic_state.MelodicState() + + state.configure_defaults("C", "major") + assert state.key == "C" - # A later section in A — the state tracks it (history would persist; the - # pool/tonic move). - state.configure_defaults("A", "minor") - assert state.key == "A" and state.mode == "minor" + # A later section in A — the state tracks it (history would persist; the + # pool/tonic move). + state.configure_defaults("A", "minor") + assert state.key == "A" and state.mode == "minor" - # An explicit constructor key is never overridden. - pinned = subsequence.melodic_state.MelodicState(key="F", mode="dorian") - pinned.configure_defaults("A", "minor") - assert pinned.key == "F" and pinned.mode == "dorian" + # An explicit constructor key is never overridden. + pinned = subsequence.melodic_state.MelodicState(key="F", mode="dorian") + pinned.configure_defaults("A", "minor") + assert pinned.key == "F" and pinned.mode == "dorian" diff --git a/tests/test_keystroke.py b/tests/test_keystroke.py index ff8ef9c..2a04ab3 100644 --- a/tests/test_keystroke.py +++ b/tests/test_keystroke.py @@ -21,69 +21,74 @@ class _FakeStdin: + """Minimal stdin stand-in: a fileno and a read that never produces keys.""" - """Minimal stdin stand-in: a fileno and a read that never produces keys.""" + def fileno(self) -> int: + return 99 - def fileno (self) -> int: + def read(self, n: int) -> str: + return "" - return 99 - - def read (self, n: int) -> str: - - return "" - - def isatty (self) -> bool: - - return True + def isatty(self) -> bool: + return True @pytest.fixture -def fake_terminal (monkeypatch: pytest.MonkeyPatch) -> typing.List[typing.Tuple[int, int, typing.Any]]: - - """Mock the terminal layer and return a recorder of tcsetattr calls.""" - - restored: typing.List[typing.Tuple[int, int, typing.Any]] = [] - - monkeypatch.setattr(subsequence.keystroke, "HOTKEYS_SUPPORTED", True) - monkeypatch.setattr(subsequence.keystroke, "HOTKEYS_UNAVAILABLE_REASON", None) +def fake_terminal( + monkeypatch: pytest.MonkeyPatch, +) -> typing.List[typing.Tuple[int, int, typing.Any]]: + """Mock the terminal layer and return a recorder of tcsetattr calls.""" - monkeypatch.setattr(termios, "tcgetattr", lambda fd: _SENTINEL_SETTINGS) - monkeypatch.setattr(termios, "tcsetattr", lambda fd, when, settings: restored.append((fd, when, settings))) - monkeypatch.setattr(tty, "setcbreak", lambda fd: None) - monkeypatch.setattr(select, "select", lambda rlist, wlist, xlist, timeout=None: ([], [], [])) - monkeypatch.setattr(sys, "stdin", _FakeStdin()) + restored: typing.List[typing.Tuple[int, int, typing.Any]] = [] - return restored + monkeypatch.setattr(subsequence.keystroke, "HOTKEYS_SUPPORTED", True) + monkeypatch.setattr(subsequence.keystroke, "HOTKEYS_UNAVAILABLE_REASON", None) + monkeypatch.setattr(termios, "tcgetattr", lambda fd: _SENTINEL_SETTINGS) + monkeypatch.setattr( + termios, + "tcsetattr", + lambda fd, when, settings: restored.append((fd, when, settings)), + ) + monkeypatch.setattr(tty, "setcbreak", lambda fd: None) + monkeypatch.setattr( + select, "select", lambda rlist, wlist, xlist, timeout=None: ([], [], []) + ) + monkeypatch.setattr(sys, "stdin", _FakeStdin()) -def test_start_stop_joins_thread_and_restores_terminal (fake_terminal: typing.List[typing.Tuple[int, int, typing.Any]]) -> None: + return restored - """stop() joins the listener thread and restores the original termios settings.""" - listener = subsequence.keystroke.KeystrokeListener() - listener.start() +def test_start_stop_joins_thread_and_restores_terminal( + fake_terminal: typing.List[typing.Tuple[int, int, typing.Any]], +) -> None: + """stop() joins the listener thread and restores the original termios settings.""" - assert listener.active is True - assert isinstance(listener._thread, threading.Thread) + listener = subsequence.keystroke.KeystrokeListener() + listener.start() - listener.stop() + assert listener.active is True + assert isinstance(listener._thread, threading.Thread) - # The background thread must have exited (joined), not been abandoned. - assert listener._thread is not None - assert not listener._thread.is_alive() - assert listener.active is False + listener.stop() - # The terminal was restored with the exact settings tcgetattr returned. - assert (99, termios.TCSADRAIN, _SENTINEL_SETTINGS) in fake_terminal + # The background thread must have exited (joined), not been abandoned. + assert listener._thread is not None + assert not listener._thread.is_alive() + assert listener.active is False + # The terminal was restored with the exact settings tcgetattr returned. + assert (99, termios.TCSADRAIN, _SENTINEL_SETTINGS) in fake_terminal -def test_stop_without_start_is_noop (fake_terminal: typing.List[typing.Tuple[int, int, typing.Any]]) -> None: - """stop() on a never-started listener does nothing and touches no terminal state.""" +def test_stop_without_start_is_noop( + fake_terminal: typing.List[typing.Tuple[int, int, typing.Any]], +) -> None: + """stop() on a never-started listener does nothing and touches no terminal state.""" - listener = subsequence.keystroke.KeystrokeListener() - listener.stop() + listener = subsequence.keystroke.KeystrokeListener() + listener.stop() - assert listener._thread is None - assert listener.active is False - assert fake_terminal == [] + assert listener._thread is None + assert listener.active is False + assert fake_terminal == [] diff --git a/tests/test_latency_compensation.py b/tests/test_latency_compensation.py index 2516d7b..9dcf953 100644 --- a/tests/test_latency_compensation.py +++ b/tests/test_latency_compensation.py @@ -25,414 +25,466 @@ # Registry (pure) # --------------------------------------------------------------------------- -def test_registry_add_default_latency_zero () -> None: - """A device added without a latency reports 0.0.""" +def test_registry_add_default_latency_zero() -> None: + """A device added without a latency reports 0.0.""" - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("a", conftest.SpyMidiOut()) - assert reg.latency_of(0) == 0.0 - assert reg.max_latency() == 0.0 + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("a", conftest.SpyMidiOut()) + assert reg.latency_of(0) == 0.0 + assert reg.max_latency() == 0.0 -def test_registry_add_with_latency () -> None: +def test_registry_add_with_latency() -> None: + """add(latency_ms=) stores per-device latency and feeds max_latency().""" - """add(latency_ms=) stores per-device latency and feeds max_latency().""" + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("fast", conftest.SpyMidiOut(), latency_ms=0) + reg.add("slow", conftest.SpyMidiOut(), latency_ms=20) + assert reg.latency_of(0) == 0.0 + assert reg.latency_of(1) == 20.0 + assert reg.max_latency() == 20.0 - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("fast", conftest.SpyMidiOut(), latency_ms=0) - reg.add("slow", conftest.SpyMidiOut(), latency_ms=20) - assert reg.latency_of(0) == 0.0 - assert reg.latency_of(1) == 20.0 - assert reg.max_latency() == 20.0 +def test_registry_set_latency_by_index_and_name() -> None: + """set_latency resolves int, name, and None like the rest of the registry.""" -def test_registry_set_latency_by_index_and_name () -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("primary", conftest.SpyMidiOut()) + reg.add("sampler", conftest.SpyMidiOut()) + reg.set_latency(0, 5) + reg.set_latency("sampler", 25) + assert reg.latency_of(None) == 5.0 # None → index 0 + assert reg.latency_of("sampler") == 25.0 + assert reg.max_latency() == 25.0 - """set_latency resolves int, name, and None like the rest of the registry.""" - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("primary", conftest.SpyMidiOut()) - reg.add("sampler", conftest.SpyMidiOut()) - reg.set_latency(0, 5) - reg.set_latency("sampler", 25) - assert reg.latency_of(None) == 5.0 # None → index 0 - assert reg.latency_of("sampler") == 25.0 - assert reg.max_latency() == 25.0 +def test_registry_set_latency_negative_raises() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("a", conftest.SpyMidiOut()) + with pytest.raises(ValueError, match="non-negative"): + reg.set_latency(0, -1) -def test_registry_set_latency_negative_raises () -> None: +def test_registry_set_latency_unknown_device_raises() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("a", conftest.SpyMidiOut()) + with pytest.raises(ValueError, match="Unknown output device"): + reg.set_latency(99, 10) + with pytest.raises(ValueError, match="Unknown output device"): + reg.set_latency("nope", 10) - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("a", conftest.SpyMidiOut()) - with pytest.raises(ValueError, match="non-negative"): - reg.set_latency(0, -1) +def test_registry_latency_of_unknown_is_zero() -> None: + """Defensive: unknown device on the hot path yields 0.0, never raises.""" -def test_registry_set_latency_unknown_device_raises () -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("a", conftest.SpyMidiOut()) + assert reg.latency_of(99) == 0.0 + assert reg.latency_of("nope") == 0.0 - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("a", conftest.SpyMidiOut()) - with pytest.raises(ValueError, match="Unknown output device"): - reg.set_latency(99, 10) - with pytest.raises(ValueError, match="Unknown output device"): - reg.set_latency("nope", 10) +def test_registry_replace_preserves_latency() -> None: + """replace() is a pure port swap — latency survives test-injection.""" -def test_registry_latency_of_unknown_is_zero () -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("a", conftest.SpyMidiOut(), latency_ms=15) + new_port = conftest.SpyMidiOut() + reg.replace(0, new_port) + assert reg.get(0) is new_port + assert reg.latency_of(0) == 15.0 - """Defensive: unknown device on the hot path yields 0.0, never raises.""" - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("a", conftest.SpyMidiOut()) - assert reg.latency_of(99) == 0.0 - assert reg.latency_of("nope") == 0.0 - - -def test_registry_replace_preserves_latency () -> None: - - """replace() is a pure port swap — latency survives test-injection.""" - - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("a", conftest.SpyMidiOut(), latency_ms=15) - new_port = conftest.SpyMidiOut() - reg.replace(0, new_port) - assert reg.get(0) is new_port - assert reg.latency_of(0) == 15.0 - - -def test_registry_close_all_clears_latencies () -> None: - - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("a", conftest.SpyMidiOut(), latency_ms=15) - reg.close_all() - assert reg.max_latency() == 0.0 - assert len(reg) == 0 +def test_registry_close_all_clears_latencies() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("a", conftest.SpyMidiOut(), latency_ms=15) + reg.close_all() + assert reg.max_latency() == 0.0 + assert len(reg) == 0 # --------------------------------------------------------------------------- # Normalization math (Sequencer) # --------------------------------------------------------------------------- -def _seq_with_devices (latencies: typing.List[float]) -> subsequence.sequencer.Sequencer: - - """Build a Sequencer with one spy device per latency (device 0..N-1).""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - # Device 0 already exists (Dummy MIDI). Set its latency and add the rest. - seq.midi_out = conftest.SpyMidiOut() - seq.set_device_latency(0, latencies[0]) - for i, lat in enumerate(latencies[1:], start=1): - seq.add_output_device(f"dev{i}", conftest.SpyMidiOut(), latency_ms=lat) - return seq +def _seq_with_devices(latencies: typing.List[float]) -> subsequence.sequencer.Sequencer: + """Build a Sequencer with one spy device per latency (device 0..N-1).""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + # Device 0 already exists (Dummy MIDI). Set its latency and add the rest. + seq.midi_out = conftest.SpyMidiOut() + seq.set_device_latency(0, latencies[0]) + for i, lat in enumerate(latencies[1:], start=1): + seq.add_output_device(f"dev{i}", conftest.SpyMidiOut(), latency_ms=lat) + return seq -def test_offset_normalization (patch_midi: None) -> None: - """offset = (max − latency)/1000, slowest device → 0.""" +def test_offset_normalization(patch_midi: None) -> None: + """offset = (max − latency)/1000, slowest device → 0.""" - seq = _seq_with_devices([20, 0, 5]) - assert seq._send_offset_seconds(0) == pytest.approx(0.0) # slowest - assert seq._send_offset_seconds(1) == pytest.approx(0.020) # fastest, most delayed - assert seq._send_offset_seconds(2) == pytest.approx(0.015) + seq = _seq_with_devices([20, 0, 5]) + assert seq._send_offset_seconds(0) == pytest.approx(0.0) # slowest + assert seq._send_offset_seconds(1) == pytest.approx(0.020) # fastest, most delayed + assert seq._send_offset_seconds(2) == pytest.approx(0.015) -def test_max_recomputed_on_set (patch_midi: None) -> None: +def test_max_recomputed_on_set(patch_midi: None) -> None: + """The cached max tracks raises and lowers.""" - """The cached max tracks raises and lowers.""" + seq = _seq_with_devices([10, 30]) + assert seq._max_device_latency_ms == 30.0 + seq.set_device_latency(1, 5) # lower the previous max + assert seq._max_device_latency_ms == 10.0 + seq.set_device_latency(0, 40) # raise a new max + assert seq._max_device_latency_ms == 40.0 - seq = _seq_with_devices([10, 30]) - assert seq._max_device_latency_ms == 30.0 - seq.set_device_latency(1, 5) # lower the previous max - assert seq._max_device_latency_ms == 10.0 - seq.set_device_latency(0, 40) # raise a new max - assert seq._max_device_latency_ms == 40.0 +def test_all_zero_latency_no_offset(patch_midi: None) -> None: + """The common case: nothing configured → every offset 0 (no deferral path).""" -def test_all_zero_latency_no_offset (patch_midi: None) -> None: + seq = _seq_with_devices([0, 0, 0]) + assert seq._send_offset_seconds(0) == 0.0 + assert seq._send_offset_seconds(1) == 0.0 + assert seq._send_offset_seconds(2) == 0.0 - """The common case: nothing configured → every offset 0 (no deferral path).""" - seq = _seq_with_devices([0, 0, 0]) - assert seq._send_offset_seconds(0) == 0.0 - assert seq._send_offset_seconds(1) == 0.0 - assert seq._send_offset_seconds(2) == 0.0 - - -def test_set_device_latency_negative_raises (patch_midi: None) -> None: - - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - with pytest.raises(ValueError, match="non-negative"): - seq.set_device_latency(0, -1) +def test_set_device_latency_negative_raises(patch_midi: None) -> None: + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + with pytest.raises(ValueError, match="non-negative"): + seq.set_device_latency(0, -1) # --------------------------------------------------------------------------- # Dispatch behaviour (async) # --------------------------------------------------------------------------- -def _push_note (seq: subsequence.sequencer.Sequencer, device: int, note: int = 60, pulse: int = 0) -> None: - seq._push_event(subsequence.sequencer.MidiEvent( - pulse=pulse, message_type='note_on', channel=0, note=note, velocity=100, device=device, - )) +def _push_note( + seq: subsequence.sequencer.Sequencer, device: int, note: int = 60, pulse: int = 0 +) -> None: + seq._push_event( + subsequence.sequencer.MidiEvent( + pulse=pulse, + message_type="note_on", + channel=0, + note=note, + velocity=100, + device=device, + ) + ) @pytest.mark.asyncio -async def test_slow_device_sends_immediately (patch_midi: None) -> None: - - """The slowest device (offset 0) dispatches synchronously inside _process_pulse.""" +async def test_slow_device_sends_immediately(patch_midi: None) -> None: + """The slowest device (offset 0) dispatches synchronously inside _process_pulse.""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq._event_loop = asyncio.get_running_loop() - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0 # fast - spy1 = conftest.SpyMidiOut(); seq.add_output_device("slow", spy1, latency_ms=20) # slowest + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq._event_loop = asyncio.get_running_loop() + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 # fast + spy1 = conftest.SpyMidiOut() + seq.add_output_device("slow", spy1, latency_ms=20) # slowest - _push_note(seq, device=1, note=64) - await seq._process_pulse(0) + _push_note(seq, device=1, note=64) + await seq._process_pulse(0) - assert len(spy1.sent) == 1 # slow device sent now - assert len(seq._pending_sends) == 0 + assert len(spy1.sent) == 1 # slow device sent now + assert len(seq._pending_sends) == 0 @pytest.mark.asyncio -async def test_fast_device_deferred_then_fires (patch_midi: None) -> None: - - """A faster device is deferred: empty right after _process_pulse, sent after the offset.""" - - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - loop = asyncio.get_running_loop() - seq._event_loop = loop - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0; seq.set_device_latency(0, 0) # fast - spy1 = conftest.SpyMidiOut(); seq.add_output_device("slow", spy1, latency_ms=10) # slowest - - scheduled_at = loop.time() - _push_note(seq, device=0, note=60) - await seq._process_pulse(0) - - # Deferred, not yet sent; one handle pending, scheduled ~10ms out. - assert len(spy0.sent) == 0 - assert len(seq._pending_sends) == 1 - handle = next(iter(seq._pending_sends)) - assert handle.when() - scheduled_at == pytest.approx(0.010, abs=0.005) - - await asyncio.sleep(0.05) - assert len(spy0.sent) == 1 # fired - assert len(seq._pending_sends) == 0 # self-discarded +async def test_fast_device_deferred_then_fires(patch_midi: None) -> None: + """A faster device is deferred: empty right after _process_pulse, sent after the offset.""" + + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + loop = asyncio.get_running_loop() + seq._event_loop = loop + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 + seq.set_device_latency(0, 0) # fast + spy1 = conftest.SpyMidiOut() + seq.add_output_device("slow", spy1, latency_ms=10) # slowest + + scheduled_at = loop.time() + _push_note(seq, device=0, note=60) + await seq._process_pulse(0) + + # Deferred, not yet sent; one handle pending, scheduled ~10ms out. + assert len(spy0.sent) == 0 + assert len(seq._pending_sends) == 1 + handle = next(iter(seq._pending_sends)) + assert handle.when() - scheduled_at == pytest.approx(0.010, abs=0.005) + + await asyncio.sleep(0.05) + assert len(spy0.sent) == 1 # fired + assert len(seq._pending_sends) == 0 # self-discarded @pytest.mark.asyncio -async def test_two_devices_same_pulse_split (patch_midi: None) -> None: - - """Same-pulse events split: slow sends now, fast is deferred.""" +async def test_two_devices_same_pulse_split(patch_midi: None) -> None: + """Same-pulse events split: slow sends now, fast is deferred.""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq._event_loop = asyncio.get_running_loop() - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0; seq.set_device_latency(0, 0) - spy1 = conftest.SpyMidiOut(); seq.add_output_device("slow", spy1, latency_ms=10) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq._event_loop = asyncio.get_running_loop() + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 + seq.set_device_latency(0, 0) + spy1 = conftest.SpyMidiOut() + seq.add_output_device("slow", spy1, latency_ms=10) - _push_note(seq, device=0, note=60) - _push_note(seq, device=1, note=64) - await seq._process_pulse(0) + _push_note(seq, device=0, note=60) + _push_note(seq, device=1, note=64) + await seq._process_pulse(0) - assert len(spy1.sent) == 1 # slow: immediate - assert len(spy0.sent) == 0 # fast: deferred - await asyncio.sleep(0.05) - assert len(spy0.sent) == 1 + assert len(spy1.sent) == 1 # slow: immediate + assert len(spy0.sent) == 0 # fast: deferred + await asyncio.sleep(0.05) + assert len(spy0.sent) == 1 @pytest.mark.asyncio -async def test_intra_device_order_preserved_through_deferral (patch_midi: None) -> None: - - """An NRPN-style burst on one deferred device keeps its CC order (99→98→6→38).""" - - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq._event_loop = asyncio.get_running_loop() - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0; seq.set_device_latency(0, 0) # deferred - seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=10) # sets max - - for control in (99, 98, 6, 38): - seq._push_event(subsequence.sequencer.MidiEvent( - pulse=0, message_type='control_change', channel=0, control=control, value=1, device=0, - )) - await seq._process_pulse(0) - assert len(seq._pending_sends) == 4 - - await asyncio.sleep(0.05) - assert [m.control for m in spy0.sent if m.type == 'control_change'] == [99, 98, 6, 38] +async def test_intra_device_order_preserved_through_deferral(patch_midi: None) -> None: + """An NRPN-style burst on one deferred device keeps its CC order (99→98→6→38).""" + + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq._event_loop = asyncio.get_running_loop() + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 + seq.set_device_latency(0, 0) # deferred + seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=10) # sets max + + for control in (99, 98, 6, 38): + seq._push_event( + subsequence.sequencer.MidiEvent( + pulse=0, + message_type="control_change", + channel=0, + control=control, + value=1, + device=0, + ) + ) + await seq._process_pulse(0) + assert len(seq._pending_sends) == 4 + + await asyncio.sleep(0.05) + assert [m.control for m in spy0.sent if m.type == "control_change"] == [ + 99, + 98, + 6, + 38, + ] @pytest.mark.asyncio -async def test_render_mode_sends_immediately (patch_midi: None) -> None: +async def test_render_mode_sends_immediately(patch_midi: None) -> None: + """Render mode never defers (no real clock) — output stays logical/uncompensated.""" - """Render mode never defers (no real clock) — output stays logical/uncompensated.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq._event_loop = asyncio.get_running_loop() + seq.render_mode = True + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 + seq.set_device_latency(0, 0) + seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=20) - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq._event_loop = asyncio.get_running_loop() - seq.render_mode = True - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0; seq.set_device_latency(0, 0) - seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=20) + _push_note(seq, device=0, note=60) + await seq._process_pulse(0) - _push_note(seq, device=0, note=60) - await seq._process_pulse(0) - - assert len(spy0.sent) == 1 # immediate despite offset - assert len(seq._pending_sends) == 0 + assert len(spy0.sent) == 1 # immediate despite offset + assert len(seq._pending_sends) == 0 @pytest.mark.asyncio -async def test_no_event_loop_sends_immediately (patch_midi: None) -> None: - - """With no running loop captured (the unit-test default), dispatch is synchronous.""" +async def test_no_event_loop_sends_immediately(patch_midi: None) -> None: + """With no running loop captured (the unit-test default), dispatch is synchronous.""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - # Deliberately leave seq._event_loop = None. - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0; seq.set_device_latency(0, 0) - seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=20) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + # Deliberately leave seq._event_loop = None. + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 + seq.set_device_latency(0, 0) + seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=20) - _push_note(seq, device=0, note=60) - await seq._process_pulse(0) + _push_note(seq, device=0, note=60) + await seq._process_pulse(0) - assert len(spy0.sent) == 1 - assert len(seq._pending_sends) == 0 + assert len(spy0.sent) == 1 + assert len(seq._pending_sends) == 0 # --------------------------------------------------------------------------- # Teardown (async) # --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_cancel_pending_sends_idempotent (patch_midi: None) -> None: - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq._cancel_pending_sends() # empty — no error - seq._cancel_pending_sends() +@pytest.mark.asyncio +async def test_cancel_pending_sends_idempotent(patch_midi: None) -> None: + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq._cancel_pending_sends() # empty — no error + seq._cancel_pending_sends() @pytest.mark.asyncio -async def test_stop_cancels_deferred_and_panic_silences (patch_midi: None) -> None: +async def test_stop_cancels_deferred_and_panic_silences(patch_midi: None) -> None: + """stop() cancels in-flight deferrals (never sent) and panic sweeps notes off.""" - """stop() cancels in-flight deferrals (never sent) and panic sweeps notes off.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq._event_loop = asyncio.get_running_loop() + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 + seq.set_device_latency(0, 0) # fast → deferred + # A very slow second device makes device 0's offset huge (5s), so the deferred + # note-on cannot fire during the test — "empty + never sent" proves cancellation. + seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=5000) - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq._event_loop = asyncio.get_running_loop() - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0; seq.set_device_latency(0, 0) # fast → deferred - # A very slow second device makes device 0's offset huge (5s), so the deferred - # note-on cannot fire during the test — "empty + never sent" proves cancellation. - seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=5000) + _push_note(seq, device=0, note=60) + await seq._process_pulse(0) + assert len(seq._pending_sends) == 1 + assert not any(m.type == "note_on" for m in spy0.sent) - _push_note(seq, device=0, note=60) - await seq._process_pulse(0) - assert len(seq._pending_sends) == 1 - assert not any(m.type == 'note_on' for m in spy0.sent) + await seq.stop() - await seq.stop() - - assert len(seq._pending_sends) == 0 # cancelled - assert not any(m.type == 'note_on' for m in spy0.sent) # never fired - # panic() is the silence authority: CC 123 (all-notes-off) swept all channels. - assert any(m.type == 'control_change' and m.control == 123 for m in spy0.sent) + assert len(seq._pending_sends) == 0 # cancelled + assert not any(m.type == "note_on" for m in spy0.sent) # never fired + # panic() is the silence authority: CC 123 (all-notes-off) swept all channels. + assert any(m.type == "control_change" and m.control == 123 for m in spy0.sent) @pytest.mark.asyncio -async def test_no_send_after_close (patch_midi: None) -> None: - - """Cancel-before-close: a deferred send never fires once ports are closed.""" +async def test_no_send_after_close(patch_midi: None) -> None: + """Cancel-before-close: a deferred send never fires once ports are closed.""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq._event_loop = asyncio.get_running_loop() - spy0 = conftest.SpyMidiOut(); seq.midi_out = spy0; seq.set_device_latency(0, 0) - seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=5) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq._event_loop = asyncio.get_running_loop() + spy0 = conftest.SpyMidiOut() + seq.midi_out = spy0 + seq.set_device_latency(0, 0) + seq.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=5) - _push_note(seq, device=0, note=60) - await seq._process_pulse(0) - seq._cancel_pending_sends() - seq._output_devices.close_all() + _push_note(seq, device=0, note=60) + await seq._process_pulse(0) + seq._cancel_pending_sends() + seq._output_devices.close_all() - await asyncio.sleep(0.03) - assert not any(m.type == 'note_on' for m in spy0.sent) + await asyncio.sleep(0.03) + assert not any(m.type == "note_on" for m in spy0.sent) # --------------------------------------------------------------------------- # Composition wiring # --------------------------------------------------------------------------- -def test_composition_latency_param_validation (patch_midi: None) -> None: - - with pytest.raises(ValueError, match="non-negative"): - subsequence.Composition(bpm=120, latency_ms=-1) - - comp = subsequence.Composition(bpm=120) - with pytest.raises(ValueError, match="non-negative"): - comp.midi_output("Synth", latency_ms=-5) - - -def test_additional_output_carries_latency (patch_midi: None) -> None: - comp = subsequence.Composition(bpm=120) - idx = comp.midi_output("Synth", name="s", latency_ms=12) - assert idx == 1 - entry = comp._additional_outputs[0] - assert entry.device == "Synth" - assert entry.alias == "s" - assert entry.latency_ms == 12 +def test_composition_latency_param_validation(patch_midi: None) -> None: + with pytest.raises(ValueError, match="non-negative"): + subsequence.Composition(bpm=120, latency_ms=-1) + comp = subsequence.Composition(bpm=120) + with pytest.raises(ValueError, match="non-negative"): + comp.midi_output("Synth", latency_ms=-5) -def test_warn_if_high_latency_fires (patch_midi: None, caplog: pytest.LogCaptureFixture) -> None: - """A whole-rig latency above the threshold logs a warning naming the slowest device.""" +def test_additional_output_carries_latency(patch_midi: None) -> None: + comp = subsequence.Composition(bpm=120) + idx = comp.midi_output("Synth", name="s", latency_ms=12) + assert idx == 1 + entry = comp._additional_outputs[0] + assert entry.device == "Synth" + assert entry.alias == "s" + assert entry.latency_ms == 12 - caplog.set_level(logging.WARNING, logger="subsequence.composition") - comp = subsequence.Composition(bpm=120, latency_ms=50) - comp.midi_output("Sampler", name="sampler", latency_ms=10) - comp._warn_if_high_latency() +def test_warn_if_high_latency_fires( + patch_midi: None, caplog: pytest.LogCaptureFixture +) -> None: + """A whole-rig latency above the threshold logs a warning naming the slowest device.""" - warnings = [r.getMessage() for r in caplog.records if "latency compensation" in r.getMessage().lower()] - assert len(warnings) == 1 - assert "primary output" in warnings[0] # 50ms primary is the slowest - assert "50" in warnings[0] + caplog.set_level(logging.WARNING, logger="subsequence.composition") + comp = subsequence.Composition(bpm=120, latency_ms=50) + comp.midi_output("Sampler", name="sampler", latency_ms=10) + comp._warn_if_high_latency() -def test_warn_if_high_latency_silent_below_threshold (patch_midi: None, caplog: pytest.LogCaptureFixture) -> None: + warnings = [ + r.getMessage() + for r in caplog.records + if "latency compensation" in r.getMessage().lower() + ] + assert len(warnings) == 1 + assert "primary output" in warnings[0] # 50ms primary is the slowest + assert "50" in warnings[0] - caplog.set_level(logging.WARNING, logger="subsequence.composition") - comp = subsequence.Composition(bpm=120, latency_ms=20) # below 30ms threshold - comp._warn_if_high_latency() - assert not [r for r in caplog.records if "latency compensation" in r.getMessage().lower()] +def test_warn_if_high_latency_silent_below_threshold( + patch_midi: None, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="subsequence.composition") + comp = subsequence.Composition(bpm=120, latency_ms=20) # below 30ms threshold + comp._warn_if_high_latency() + assert not [ + r for r in caplog.records if "latency compensation" in r.getMessage().lower() + ] -def test_render_wires_device_latencies (tmp_path: pathlib.Path, patch_midi_multi: typing.Dict[str, conftest.NamedSpyMidiOut]) -> None: - """_run applies the configured latencies to the sequencer's devices. +def test_render_wires_device_latencies( + tmp_path: pathlib.Path, patch_midi_multi: typing.Dict[str, conftest.NamedSpyMidiOut] +) -> None: + """_run applies the configured latencies to the sequencer's devices. - stop() clears the registry at the end of render, so we capture the wiring - calls as they happen rather than inspecting post-run registry state. - Uses patch_midi_multi so the additional device actually opens. - """ + stop() clears the registry at the end of render, so we capture the wiring + calls as they happen rather than inspecting post-run registry state. + Uses patch_midi_multi so the additional device actually opens. + """ - filename = str(tmp_path / "out.mid") - comp = subsequence.Composition(bpm=480, output_device="Primary MIDI", latency_ms=20) - comp.midi_output("Secondary MIDI", name="sampler", latency_ms=15) + filename = str(tmp_path / "out.mid") + comp = subsequence.Composition(bpm=480, output_device="Primary MIDI", latency_ms=20) + comp.midi_output("Secondary MIDI", name="sampler", latency_ms=15) - set_calls: typing.List[typing.Tuple[typing.Any, float]] = [] - add_calls: typing.List[typing.Tuple[str, float]] = [] - orig_set = comp._sequencer.set_device_latency - orig_add = comp._sequencer.add_output_device + set_calls: typing.List[typing.Tuple[typing.Any, float]] = [] + add_calls: typing.List[typing.Tuple[str, float]] = [] + orig_set = comp._sequencer.set_device_latency + orig_add = comp._sequencer.add_output_device - def spy_set (device: typing.Any, latency_ms: float) -> None: - set_calls.append((device, latency_ms)); orig_set(device, latency_ms) + def spy_set(device: typing.Any, latency_ms: float) -> None: + set_calls.append((device, latency_ms)) + orig_set(device, latency_ms) - def spy_add (name: str, port: typing.Any, latency_ms: float = 0.0) -> int: - add_calls.append((name, latency_ms)); return orig_add(name, port, latency_ms) + def spy_add(name: str, port: typing.Any, latency_ms: float = 0.0) -> int: + add_calls.append((name, latency_ms)) + return orig_add(name, port, latency_ms) - comp._sequencer.set_device_latency = spy_set # type: ignore[method-assign] - comp._sequencer.add_output_device = spy_add # type: ignore[method-assign] + comp._sequencer.set_device_latency = spy_set # type: ignore[method-assign] + comp._sequencer.add_output_device = spy_add # type: ignore[method-assign] - @comp.pattern(channel=1, beats=4) - def p (p) -> None: - pass + @comp.pattern(channel=1, beats=4) + def p(p) -> None: + pass - comp.render(bars=4, max_minutes=None, filename=filename) + comp.render(bars=4, max_minutes=None, filename=filename) - assert (0, 20.0) in set_calls # primary device 0 → 20ms - assert any(lat == 15.0 for _, lat in add_calls) # additional device → 15ms + assert (0, 20.0) in set_calls # primary device 0 → 20ms + assert any(lat == 15.0 for _, lat in add_calls) # additional device → 15ms diff --git a/tests/test_legato.py b/tests/test_legato.py index 68faac4..0ec2daf 100644 --- a/tests/test_legato.py +++ b/tests/test_legato.py @@ -1,113 +1,106 @@ - import subsequence.pattern import subsequence.pattern_builder -def test_legato_uniform_gap () -> None: - - """Test legato transform with uniform note spacing.""" - - # 4-beat pattern (96 pulses) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - - # Notes at beat 0 and 2 (pulses 0 and 48) - builder.note(pitch=60, beat=0.0, duration=0.1) - builder.note(pitch=62, beat=2.0, duration=0.1) - - # Apply legato (defaults to ratio 1.0) - builder.legato() - - # Pulse 0 -> 48 gap is 48 - # Pulse 48 -> 96 (wrap to 0) gap is 48 - assert pat.steps[0].notes[0].duration == 48 - assert pat.steps[48].notes[0].duration == 48 +def test_legato_uniform_gap() -> None: + """Test legato transform with uniform note spacing.""" + # 4-beat pattern (96 pulses) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) -def test_legato_varying_gaps () -> None: + # Notes at beat 0 and 2 (pulses 0 and 48) + builder.note(pitch=60, beat=0.0, duration=0.1) + builder.note(pitch=62, beat=2.0, duration=0.1) - """Test legato transform with irregular spacing.""" + # Apply legato (defaults to ratio 1.0) + builder.legato() - # 4-beat pattern (96 pulses) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # Pulse 0 -> 48 gap is 48 + # Pulse 48 -> 96 (wrap to 0) gap is 48 + assert pat.steps[0].notes[0].duration == 48 + assert pat.steps[48].notes[0].duration == 48 - # Notes at 0, 1.0 (24), 3.0 (72) - builder.note(pitch=60, beat=0.0, duration=0.1) - builder.note(pitch=60, beat=1.0, duration=0.1) - builder.note(pitch=60, beat=3.0, duration=0.1) - builder.legato() +def test_legato_varying_gaps() -> None: + """Test legato transform with irregular spacing.""" - # 0 -> 24 (gap 24) - assert pat.steps[0].notes[0].duration == 24 - # 24 -> 72 (gap 48) - assert pat.steps[24].notes[0].duration == 48 - # 72 -> 0 (wrap: 96-72 = 24) - assert pat.steps[72].notes[0].duration == 24 + # 4-beat pattern (96 pulses) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # Notes at 0, 1.0 (24), 3.0 (72) + builder.note(pitch=60, beat=0.0, duration=0.1) + builder.note(pitch=60, beat=1.0, duration=0.1) + builder.note(pitch=60, beat=3.0, duration=0.1) -def test_legato_ratio () -> None: + builder.legato() - """Test legato with a ratio < 1.0.""" + # 0 -> 24 (gap 24) + assert pat.steps[0].notes[0].duration == 24 + # 24 -> 72 (gap 48) + assert pat.steps[24].notes[0].duration == 48 + # 72 -> 0 (wrap: 96-72 = 24) + assert pat.steps[72].notes[0].duration == 24 - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - # Notes at 0 and 2.0 (48) - builder.note(pitch=60, beat=0.0, duration=0.1) - builder.note(pitch=60, beat=2.0, duration=0.1) +def test_legato_ratio() -> None: + """Test legato with a ratio < 1.0.""" - # 50% legato - builder.legato(ratio=0.5) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - # Gap 48 * 0.5 = 24 - assert pat.steps[0].notes[0].duration == 24 - assert pat.steps[48].notes[0].duration == 24 + # Notes at 0 and 2.0 (48) + builder.note(pitch=60, beat=0.0, duration=0.1) + builder.note(pitch=60, beat=2.0, duration=0.1) + # 50% legato + builder.legato(ratio=0.5) -def test_legato_single_note () -> None: + # Gap 48 * 0.5 = 24 + assert pat.steps[0].notes[0].duration == 24 + assert pat.steps[48].notes[0].duration == 24 - """Test legato with a single note (should fill pattern).""" - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) +def test_legato_single_note() -> None: + """Test legato with a single note (should fill pattern).""" - # Note at beat 1.0 (24) - builder.note(pitch=60, beat=1.0, duration=0.1) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - builder.legato() + # Note at beat 1.0 (24) + builder.note(pitch=60, beat=1.0, duration=0.1) - # Should fill 96 pulses (full length) - assert pat.steps[24].notes[0].duration == 96 + builder.legato() + # Should fill 96 pulses (full length) + assert pat.steps[24].notes[0].duration == 96 -def test_legato_empty () -> None: - """Test legato on empty pattern (no-op).""" +def test_legato_empty() -> None: + """Test legato on empty pattern (no-op).""" - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - builder.legato() - assert len(pat.steps) == 0 + builder.legato() + assert len(pat.steps) == 0 -def test_legato_minimum_duration () -> None: +def test_legato_minimum_duration() -> None: + """Ensure duration is at least 1 pulse.""" - """Ensure duration is at least 1 pulse.""" + pat = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) - pat = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=0) + # Notes at 0 and 0 + epsilon (simulate tight spacing or tiny ratio) + # But pulse resolution is integers. + # Let's use a very small ratio on a normal gap. - # Notes at 0 and 0 + epsilon (simulate tight spacing or tiny ratio) - # But pulse resolution is integers. - # Let's use a very small ratio on a normal gap. - - builder.note(pitch=60, beat=0.0) - builder.note(pitch=60, beat=1.0) # Gap 24 + builder.note(pitch=60, beat=0.0) + builder.note(pitch=60, beat=1.0) # Gap 24 - # Ratio 0.0001 -> duration would be 0 - builder.legato(ratio=0.0001) + # Ratio 0.0001 -> duration would be 0 + builder.legato(ratio=0.0001) - assert pat.steps[0].notes[0].duration == 1 + assert pat.steps[0].notes[0].duration == 1 diff --git a/tests/test_link.py b/tests/test_link.py index d742daa..c810678 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -15,151 +15,145 @@ # Helpers # --------------------------------------------------------------------------- -def _make_composition (patch_midi: None) -> subsequence.Composition: - return subsequence.Composition(output_device="Dummy MIDI", bpm=120) + +def _make_composition(patch_midi: None) -> subsequence.Composition: + return subsequence.Composition(output_device="Dummy MIDI", bpm=120) # --------------------------------------------------------------------------- # _require_aalink() # --------------------------------------------------------------------------- -def test_require_aalink_raises_when_not_installed () -> None: - - """_require_aalink() raises RuntimeError with install instructions when aalink is absent.""" - with unittest.mock.patch.dict(sys.modules, {"aalink": None}): - with pytest.raises(RuntimeError) as exc_info: - subsequence.link_clock._require_aalink() +def test_require_aalink_raises_when_not_installed() -> None: + """_require_aalink() raises RuntimeError with install instructions when aalink is absent.""" - assert "pip install subsequence[link]" in str(exc_info.value) + with unittest.mock.patch.dict(sys.modules, {"aalink": None}): + with pytest.raises(RuntimeError) as exc_info: + subsequence.link_clock._require_aalink() + assert "pip install subsequence[link]" in str(exc_info.value) -def test_require_aalink_succeeds_when_installed () -> None: - """_require_aalink() returns the aalink module when it is available.""" +def test_require_aalink_succeeds_when_installed() -> None: + """_require_aalink() returns the aalink module when it is available.""" - fake_aalink = types.ModuleType("aalink") - with unittest.mock.patch.dict(sys.modules, {"aalink": fake_aalink}): - result = subsequence.link_clock._require_aalink() + fake_aalink = types.ModuleType("aalink") + with unittest.mock.patch.dict(sys.modules, {"aalink": fake_aalink}): + result = subsequence.link_clock._require_aalink() - assert result is fake_aalink + assert result is fake_aalink # --------------------------------------------------------------------------- # Composition.link() # --------------------------------------------------------------------------- -def test_link_quantum_default_is_none (patch_midi: None) -> None: - - """_link_quantum should be None when link() has not been called.""" - - comp = _make_composition(patch_midi) - assert comp._link_quantum is None +def test_link_quantum_default_is_none(patch_midi: None) -> None: + """_link_quantum should be None when link() has not been called.""" -def test_link_sets_quantum (patch_midi: None) -> None: + comp = _make_composition(patch_midi) + assert comp._link_quantum is None - """link() stores the quantum and returns self for method chaining.""" - comp = _make_composition(patch_midi) +def test_link_sets_quantum(patch_midi: None) -> None: + """link() stores the quantum and returns self for method chaining.""" - # link() eagerly requires aalink; mock its presence so this test exercises - # quantum storage regardless of whether the optional extra is installed. - fake_aalink = types.ModuleType("aalink") - with unittest.mock.patch.dict(sys.modules, {"aalink": fake_aalink}): - result = comp.link(quantum=4.0) + comp = _make_composition(patch_midi) - assert comp._link_quantum == 4.0 - assert result is comp + # link() eagerly requires aalink; mock its presence so this test exercises + # quantum storage regardless of whether the optional extra is installed. + fake_aalink = types.ModuleType("aalink") + with unittest.mock.patch.dict(sys.modules, {"aalink": fake_aalink}): + result = comp.link(quantum=4.0) + assert comp._link_quantum == 4.0 + assert result is comp -def test_link_default_quantum (patch_midi: None) -> None: - """link() defaults to quantum=4.0 (one bar in 4/4).""" +def test_link_default_quantum(patch_midi: None) -> None: + """link() defaults to quantum=4.0 (one bar in 4/4).""" - comp = _make_composition(patch_midi) + comp = _make_composition(patch_midi) - # link() eagerly requires aalink; mock its presence (see test_link_sets_quantum). - fake_aalink = types.ModuleType("aalink") - with unittest.mock.patch.dict(sys.modules, {"aalink": fake_aalink}): - comp.link() + # link() eagerly requires aalink; mock its presence (see test_link_sets_quantum). + fake_aalink = types.ModuleType("aalink") + with unittest.mock.patch.dict(sys.modules, {"aalink": fake_aalink}): + comp.link() - assert comp._link_quantum == 4.0 + assert comp._link_quantum == 4.0 -def test_link_raises_when_aalink_not_installed (patch_midi: None) -> None: +def test_link_raises_when_aalink_not_installed(patch_midi: None) -> None: + """link() raises RuntimeError with install instructions when aalink is missing.""" - """link() raises RuntimeError with install instructions when aalink is missing.""" + comp = _make_composition(patch_midi) - comp = _make_composition(patch_midi) + with unittest.mock.patch.dict(sys.modules, {"aalink": None}): + with pytest.raises(RuntimeError) as exc_info: + comp.link() - with unittest.mock.patch.dict(sys.modules, {"aalink": None}): - with pytest.raises(RuntimeError) as exc_info: - comp.link() - - assert "pip install subsequence[link]" in str(exc_info.value) + assert "pip install subsequence[link]" in str(exc_info.value) # --------------------------------------------------------------------------- # Sequencer._link_clock default and set_bpm() delegation # --------------------------------------------------------------------------- -def test_sequencer_link_clock_default_is_none (patch_midi: None) -> None: - - """_link_clock should be None on a freshly constructed Sequencer.""" - - comp = _make_composition(patch_midi) - assert comp._sequencer._link_clock is None - -def test_set_bpm_delegates_to_link_when_active (patch_midi: None) -> None: +def test_sequencer_link_clock_default_is_none(patch_midi: None) -> None: + """_link_clock should be None on a freshly constructed Sequencer.""" - """set_bpm() on the Sequencer proposes tempo to Link and returns early when running with Link.""" + comp = _make_composition(patch_midi) + assert comp._sequencer._link_clock is None - comp = _make_composition(patch_midi) - seq = comp._sequencer - mock_link_clock = unittest.mock.Mock() - seq._link_clock = mock_link_clock - seq.running = True # simulate active playback +def test_set_bpm_delegates_to_link_when_active(patch_midi: None) -> None: + """set_bpm() on the Sequencer proposes tempo to Link and returns early when running with Link.""" - seq.set_bpm(140.0) + comp = _make_composition(patch_midi) + seq = comp._sequencer - mock_link_clock.request_tempo.assert_called_once_with(140.0) - # Local BPM should NOT be updated — Link is authoritative - assert seq.current_bpm == 120.0 # unchanged from construction + mock_link_clock = unittest.mock.Mock() + seq._link_clock = mock_link_clock + seq.running = True # simulate active playback + seq.set_bpm(140.0) -def test_set_bpm_does_not_delegate_when_not_running (patch_midi: None) -> None: + mock_link_clock.request_tempo.assert_called_once_with(140.0) + # Local BPM should NOT be updated — Link is authoritative + assert seq.current_bpm == 120.0 # unchanged from construction - """set_bpm() should update locally (not delegate to Link) when the sequencer is not running.""" - comp = _make_composition(patch_midi) - seq = comp._sequencer +def test_set_bpm_does_not_delegate_when_not_running(patch_midi: None) -> None: + """set_bpm() should update locally (not delegate to Link) when the sequencer is not running.""" - mock_link_clock = unittest.mock.Mock() - seq._link_clock = mock_link_clock - # seq.running is False by default + comp = _make_composition(patch_midi) + seq = comp._sequencer - seq.set_bpm(140.0) + mock_link_clock = unittest.mock.Mock() + seq._link_clock = mock_link_clock + # seq.running is False by default - mock_link_clock.request_tempo.assert_not_called() - assert seq.current_bpm == 140.0 # updated locally + seq.set_bpm(140.0) + mock_link_clock.request_tempo.assert_not_called() + assert seq.current_bpm == 140.0 # updated locally -def test_set_target_bpm_ignored_under_link (patch_midi: None) -> None: - """A tempo ramp is a no-op under Ableton Link — the network tempo is authoritative.""" +def test_set_target_bpm_ignored_under_link(patch_midi: None) -> None: + """A tempo ramp is a no-op under Ableton Link — the network tempo is authoritative.""" - comp = _make_composition(patch_midi) - seq = comp._sequencer + comp = _make_composition(patch_midi) + seq = comp._sequencer - mock_link_clock = unittest.mock.Mock() - seq._link_clock = mock_link_clock - seq.running = True # simulate active playback under Link + mock_link_clock = unittest.mock.Mock() + seq._link_clock = mock_link_clock + seq.running = True # simulate active playback under Link - seq.set_target_bpm(160.0, bars=8) + seq.set_target_bpm(160.0, bars=8) - # No local ramp is created, and Link is not asked to ramp. - assert seq._bpm_transition is None - mock_link_clock.request_tempo.assert_not_called() + # No local ramp is created, and Link is not asked to ramp. + assert seq._bpm_transition is None + mock_link_clock.request_tempo.assert_not_called() diff --git a/tests/test_live_reloader.py b/tests/test_live_reloader.py index 4d634d2..dd2d2d5 100644 --- a/tests/test_live_reloader.py +++ b/tests/test_live_reloader.py @@ -23,455 +23,444 @@ # ── Initial load ──────────────────────────────────────────────────────────── -def test_initial_load_runs_file_synchronously (tmp_path: pathlib.Path) -> None: +def test_initial_load_runs_file_synchronously(tmp_path: pathlib.Path) -> None: + """watch() exec's the file at call time — patterns appear in _pending_patterns.""" - """watch() exec's the file at call time — patterns appear in _pending_patterns.""" + live_file = tmp_path / "patterns.py" + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\ndef drums (p): pass\n" + ) - live_file = tmp_path / "patterns.py" - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p): pass\n" - ) + composition = subsequence.Composition(bpm=120) + composition.watch(live_file) - composition = subsequence.Composition(bpm=120) - composition.watch(live_file) + # Initial decorators ran but _running_patterns is still empty (it + # becomes populated in _run()). The pending list has the pattern. + pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] + assert "drums" in pending_names - # Initial decorators ran but _running_patterns is still empty (it - # becomes populated in _run()). The pending list has the pattern. - pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] - assert "drums" in pending_names + composition._live_reloader.stop() - composition._live_reloader.stop() +def test_initial_load_syntax_error_raises(tmp_path: pathlib.Path) -> None: + """watch() raises SyntaxError if the file is malformed at start.""" -def test_initial_load_syntax_error_raises (tmp_path: pathlib.Path) -> None: + live_file = tmp_path / "broken.py" + live_file.write_text("def drums(p): :::") # syntactically invalid - """watch() raises SyntaxError if the file is malformed at start.""" + composition = subsequence.Composition(bpm=120) - live_file = tmp_path / "broken.py" - live_file.write_text("def drums(p): :::") # syntactically invalid + with pytest.raises(SyntaxError): + composition.watch(live_file) - composition = subsequence.Composition(bpm=120) - with pytest.raises(SyntaxError): - composition.watch(live_file) +def test_initial_load_missing_file_raises(tmp_path: pathlib.Path) -> None: + """watch() raises FileNotFoundError when the path doesn't exist.""" + composition = subsequence.Composition(bpm=120) -def test_initial_load_missing_file_raises (tmp_path: pathlib.Path) -> None: + with pytest.raises(FileNotFoundError): + composition.watch(tmp_path / "does_not_exist.py") - """watch() raises FileNotFoundError when the path doesn't exist.""" - composition = subsequence.Composition(bpm=120) +def test_watch_sets_live_mode(tmp_path: pathlib.Path) -> None: + """watch() flips _is_live=True so the decorator hot-swap path fires.""" - with pytest.raises(FileNotFoundError): - composition.watch(tmp_path / "does_not_exist.py") + live_file = tmp_path / "patterns.py" + live_file.write_text("# empty for now\n") + composition = subsequence.Composition(bpm=120) + assert composition._is_live is False -def test_watch_sets_live_mode (tmp_path: pathlib.Path) -> None: + composition.watch(live_file) + assert composition._is_live is True - """watch() flips _is_live=True so the decorator hot-swap path fires.""" + composition._live_reloader.stop() - live_file = tmp_path / "patterns.py" - live_file.write_text("# empty for now\n") - composition = subsequence.Composition(bpm=120) - assert composition._is_live is False +def test_watch_starts_reloader_thread(tmp_path: pathlib.Path) -> None: + """watch() spawns a daemon thread that is alive after the call.""" - composition.watch(live_file) - assert composition._is_live is True + live_file = tmp_path / "patterns.py" + live_file.write_text("# empty\n") - composition._live_reloader.stop() + composition = subsequence.Composition(bpm=120) + composition.watch(live_file) + thread = composition._live_reloader._thread + assert thread is not None + assert thread.is_alive() + assert thread.daemon -def test_watch_starts_reloader_thread (tmp_path: pathlib.Path) -> None: + composition._live_reloader.stop() - """watch() spawns a daemon thread that is alive after the call.""" - live_file = tmp_path / "patterns.py" - live_file.write_text("# empty\n") +def test_stop_terminates_watcher_thread(tmp_path: pathlib.Path) -> None: + """LiveReloader.stop() ends the watcher thread within poll_interval × 2.""" - composition = subsequence.Composition(bpm=120) - composition.watch(live_file) + live_file = tmp_path / "patterns.py" + live_file.write_text("# empty\n") - thread = composition._live_reloader._thread - assert thread is not None - assert thread.is_alive() - assert thread.daemon + composition = subsequence.Composition(bpm=120) + composition.watch(live_file, poll_interval=0.05) - composition._live_reloader.stop() + reloader = composition._live_reloader + reloader.stop() - -def test_stop_terminates_watcher_thread (tmp_path: pathlib.Path) -> None: - - """LiveReloader.stop() ends the watcher thread within poll_interval × 2.""" - - live_file = tmp_path / "patterns.py" - live_file.write_text("# empty\n") - - composition = subsequence.Composition(bpm=120) - composition.watch(live_file, poll_interval=0.05) - - reloader = composition._live_reloader - reloader.stop() - - # After stop(), the thread should have joined. - assert reloader._thread is None + # After stop(), the thread should have joined. + assert reloader._thread is None # ── Reload behaviour (drive _reload_async directly for determinism) ──────── @pytest.mark.asyncio -async def test_reload_swaps_builder_function (patch_midi: None, tmp_path: pathlib.Path) -> None: +async def test_reload_swaps_builder_function( + patch_midi: None, tmp_path: pathlib.Path +) -> None: + """On reload, an existing pattern's _builder_fn is replaced in place.""" - """On reload, an existing pattern's _builder_fn is replaced in place.""" + live_file = tmp_path / "patterns.py" + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\ndef drums (p):\n\treturn 'v1'\n" + ) - live_file = tmp_path / "patterns.py" - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p):\n" - "\treturn 'v1'\n" - ) + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition._sequencer._event_loop = asyncio.get_event_loop() + composition.watch(live_file) - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._sequencer._event_loop = asyncio.get_event_loop() - composition.watch(live_file) + # Simulate _run() processing pending patterns so we have a running entry. + await composition._activate_new_pending_patterns() - # Simulate _run() processing pending patterns so we have a running entry. - await composition._activate_new_pending_patterns() + assert "drums" in composition._running_patterns + first_builder = composition._running_patterns["drums"]._builder_fn - assert "drums" in composition._running_patterns - first_builder = composition._running_patterns["drums"]._builder_fn + # Rewrite the file with a different body and reload. + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\ndef drums (p):\n\tp._tag = 'v2'\n" + ) + await composition._live_reloader._reload_async() - # Rewrite the file with a different body and reload. - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p):\n" - "\tp._tag = 'v2'\n" - ) - await composition._live_reloader._reload_async() + # Same running pattern instance, new builder function. + assert "drums" in composition._running_patterns + new_builder = composition._running_patterns["drums"]._builder_fn + assert new_builder is not first_builder - # Same running pattern instance, new builder function. - assert "drums" in composition._running_patterns - new_builder = composition._running_patterns["drums"]._builder_fn - assert new_builder is not first_builder + # Run the new builder against a stub to confirm it's actually the v2 body. + class _Stub: ... - # Run the new builder against a stub to confirm it's actually the v2 body. - class _Stub: ... - stub = _Stub() - new_builder(stub) - assert stub._tag == "v2" + stub = _Stub() + new_builder(stub) + assert stub._tag == "v2" - composition._live_reloader.stop() + composition._live_reloader.stop() @pytest.mark.asyncio -async def test_reload_skipped_on_syntax_error (patch_midi: None, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture) -> None: - - """SyntaxError during reload leaves running state unchanged.""" +async def test_reload_skipped_on_syntax_error( + patch_midi: None, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture +) -> None: + """SyntaxError during reload leaves running state unchanged.""" - live_file = tmp_path / "patterns.py" - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p): pass\n" - ) + live_file = tmp_path / "patterns.py" + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\ndef drums (p): pass\n" + ) - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._sequencer._event_loop = asyncio.get_event_loop() - composition.watch(live_file) - await composition._activate_new_pending_patterns() + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition._sequencer._event_loop = asyncio.get_event_loop() + composition.watch(live_file) + await composition._activate_new_pending_patterns() - good_builder = composition._running_patterns["drums"]._builder_fn + good_builder = composition._running_patterns["drums"]._builder_fn - # Corrupt the file and reload. - live_file.write_text("def drums(p): :::") # SyntaxError + # Corrupt the file and reload. + live_file.write_text("def drums(p): :::") # SyntaxError - with caplog.at_level(logging.WARNING): - await composition._live_reloader._reload_async() + with caplog.at_level(logging.WARNING): + await composition._live_reloader._reload_async() - # Pattern still runs with the previous builder. - assert "drums" in composition._running_patterns - assert composition._running_patterns["drums"]._builder_fn is good_builder - assert any("SyntaxError" in r.message for r in caplog.records) + # Pattern still runs with the previous builder. + assert "drums" in composition._running_patterns + assert composition._running_patterns["drums"]._builder_fn is good_builder + assert any("SyntaxError" in r.message for r in caplog.records) - composition._live_reloader.stop() + composition._live_reloader.stop() @pytest.mark.asyncio -async def test_reload_unregisters_removed_patterns (patch_midi: None, tmp_path: pathlib.Path) -> None: +async def test_reload_unregisters_removed_patterns( + patch_midi: None, tmp_path: pathlib.Path +) -> None: + """A pattern deleted from the file is unregistered on the next reload.""" - """A pattern deleted from the file is unregistered on the next reload.""" + live_file = tmp_path / "patterns.py" + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\n" + "def kick (p): pass\n" + "@composition.pattern(channel=2, beats=4)\n" + "def snare (p): pass\n" + ) - live_file = tmp_path / "patterns.py" - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def kick (p): pass\n" - "@composition.pattern(channel=2, beats=4)\n" - "def snare (p): pass\n" - ) + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition._sequencer._event_loop = asyncio.get_event_loop() + composition.watch(live_file) + await composition._activate_new_pending_patterns() - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._sequencer._event_loop = asyncio.get_event_loop() - composition.watch(live_file) - await composition._activate_new_pending_patterns() + assert "kick" in composition._running_patterns + assert "snare" in composition._running_patterns - assert "kick" in composition._running_patterns - assert "snare" in composition._running_patterns + # Rewrite: snare deleted. + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\ndef kick (p): pass\n" + ) + await composition._live_reloader._reload_async() - # Rewrite: snare deleted. - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def kick (p): pass\n" - ) - await composition._live_reloader._reload_async() + assert "kick" in composition._running_patterns + assert "snare" not in composition._running_patterns - assert "kick" in composition._running_patterns - assert "snare" not in composition._running_patterns - - composition._live_reloader.stop() + composition._live_reloader.stop() @pytest.mark.asyncio -async def test_reload_activates_new_patterns (patch_midi: None, tmp_path: pathlib.Path) -> None: - - """A pattern added in the file appears in _running_patterns after reload.""" +async def test_reload_activates_new_patterns( + patch_midi: None, tmp_path: pathlib.Path +) -> None: + """A pattern added in the file appears in _running_patterns after reload.""" - live_file = tmp_path / "patterns.py" - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def kick (p): pass\n" - ) + live_file = tmp_path / "patterns.py" + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\ndef kick (p): pass\n" + ) - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._sequencer._event_loop = asyncio.get_event_loop() - composition.watch(live_file) - await composition._activate_new_pending_patterns() + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition._sequencer._event_loop = asyncio.get_event_loop() + composition.watch(live_file) + await composition._activate_new_pending_patterns() - # Add a new pattern. - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def kick (p): pass\n" - "@composition.pattern(channel=2, beats=4)\n" - "def bass (p): pass\n" - ) - await composition._live_reloader._reload_async() + # Add a new pattern. + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\n" + "def kick (p): pass\n" + "@composition.pattern(channel=2, beats=4)\n" + "def bass (p): pass\n" + ) + await composition._live_reloader._reload_async() - assert "kick" in composition._running_patterns - assert "bass" in composition._running_patterns + assert "kick" in composition._running_patterns + assert "bass" in composition._running_patterns - composition._live_reloader.stop() + composition._live_reloader.stop() @pytest.mark.asyncio -async def test_reload_preserves_state_on_runtime_error (patch_midi: None, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture) -> None: - - """Runtime error mid-exec: the entire reload is skipped, previous state preserved. - - The naive "partial reload" approach would have left hot-swapped patterns - in their new state and torn down patterns the broken file didn't reach. - Both are bad UX — skip the reload entirely and let the user retry. - """ - - live_file = tmp_path / "patterns.py" - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def kick (p):\n" - "\tp._tag = 'v1'\n" - "@composition.pattern(channel=2, beats=4)\n" - "def snare (p): pass\n" - ) - - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._sequencer._event_loop = asyncio.get_event_loop() - composition.watch(live_file) - await composition._activate_new_pending_patterns() - - original_kick_builder = composition._running_patterns["kick"]._builder_fn - - # Rewrite: kick "would" hot-swap, but a NameError appears before snare. - live_file.write_text( - "@composition.pattern(channel=1, beats=4)\n" - "def kick (p):\n" - "\tp._tag = 'v2'\n" - "undefined_variable_xyz\n" # NameError - "@composition.pattern(channel=2, beats=4)\n" - "def snare (p): pass\n" - ) - - with caplog.at_level(logging.WARNING): - await composition._live_reloader._reload_async() - - # Both patterns still present. - assert "kick" in composition._running_patterns - assert "snare" in composition._running_patterns - - # kick's builder DID hot-swap before the error fired (decorator side-effect - # we can't undo), but snare is still around because we didn't run the - # diff-and-unregister phase. That's the safer compromise. - assert any("skipping reload" in r.message for r in caplog.records) - - composition._live_reloader.stop() +async def test_reload_preserves_state_on_runtime_error( + patch_midi: None, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture +) -> None: + """Runtime error mid-exec: the entire reload is skipped, previous state preserved. + + The naive "partial reload" approach would have left hot-swapped patterns + in their new state and torn down patterns the broken file didn't reach. + Both are bad UX — skip the reload entirely and let the user retry. + """ + + live_file = tmp_path / "patterns.py" + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\n" + "def kick (p):\n" + "\tp._tag = 'v1'\n" + "@composition.pattern(channel=2, beats=4)\n" + "def snare (p): pass\n" + ) + + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition._sequencer._event_loop = asyncio.get_event_loop() + composition.watch(live_file) + await composition._activate_new_pending_patterns() + + original_kick_builder = composition._running_patterns["kick"]._builder_fn + + # Rewrite: kick "would" hot-swap, but a NameError appears before snare. + live_file.write_text( + "@composition.pattern(channel=1, beats=4)\n" + "def kick (p):\n" + "\tp._tag = 'v2'\n" + "undefined_variable_xyz\n" # NameError + "@composition.pattern(channel=2, beats=4)\n" + "def snare (p): pass\n" + ) + + with caplog.at_level(logging.WARNING): + await composition._live_reloader._reload_async() + + # Both patterns still present. + assert "kick" in composition._running_patterns + assert "snare" in composition._running_patterns + + # kick's builder DID hot-swap before the error fired (decorator side-effect + # we can't undo), but snare is still around because we didn't run the + # diff-and-unregister phase. That's the safer compromise. + assert any("skipping reload" in r.message for r in caplog.records) + + composition._live_reloader.stop() @pytest.mark.asyncio -async def test_reload_uses_fresh_namespace_each_call (patch_midi: None, tmp_path: pathlib.Path) -> None: - - """Module-level bindings in v1 don't leak into v2's namespace. - - The discriminating assertion: v2 reads ``_my_state`` BEFORE its pattern - decorator runs. With a fresh namespace the exec dies with NameError on - that line, so drums' builder is never hot-swapped; if v1's namespace - leaked, v2 would exec cleanly and the decorator would swap the builder. - """ - - live_file = tmp_path / "patterns.py" - live_file.write_text( - "_my_state = 'v1'\n" - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p): pass\n" - ) - - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._sequencer._event_loop = asyncio.get_event_loop() - composition.watch(live_file) - await composition._activate_new_pending_patterns() - - builder_before = composition._running_patterns["drums"]._builder_fn - - # v2 references _my_state without defining it — should NameError. - live_file.write_text( - "# _my_state intentionally not declared\n" - "x = _my_state # would succeed if v1's namespace leaked\n" - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p): pass\n" - ) - # The reload exec raises NameError (logged as a warning); the reloader survives. - await composition._live_reloader._reload_async() - - # drums still in running set (it was already there before this reload). - assert "drums" in composition._running_patterns - - # And its builder was NOT swapped: the NameError fired before v2's - # decorator line, which is only possible with a fresh namespace. - assert composition._running_patterns["drums"]._builder_fn is builder_before - - composition._live_reloader.stop() +async def test_reload_uses_fresh_namespace_each_call( + patch_midi: None, tmp_path: pathlib.Path +) -> None: + """Module-level bindings in v1 don't leak into v2's namespace. + + The discriminating assertion: v2 reads ``_my_state`` BEFORE its pattern + decorator runs. With a fresh namespace the exec dies with NameError on + that line, so drums' builder is never hot-swapped; if v1's namespace + leaked, v2 would exec cleanly and the decorator would swap the builder. + """ + + live_file = tmp_path / "patterns.py" + live_file.write_text( + "_my_state = 'v1'\n" + "@composition.pattern(channel=1, beats=4)\n" + "def drums (p): pass\n" + ) + + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition._sequencer._event_loop = asyncio.get_event_loop() + composition.watch(live_file) + await composition._activate_new_pending_patterns() + + builder_before = composition._running_patterns["drums"]._builder_fn + + # v2 references _my_state without defining it — should NameError. + live_file.write_text( + "# _my_state intentionally not declared\n" + "x = _my_state # would succeed if v1's namespace leaked\n" + "@composition.pattern(channel=1, beats=4)\n" + "def drums (p): pass\n" + ) + # The reload exec raises NameError (logged as a warning); the reloader survives. + await composition._live_reloader._reload_async() + + # drums still in running set (it was already there before this reload). + assert "drums" in composition._running_patterns + + # And its builder was NOT swapped: the NameError fired before v2's + # decorator line, which is only possible with a fresh namespace. + assert composition._running_patterns["drums"]._builder_fn is builder_before + + composition._live_reloader.stop() # ── Single-file workflow: __name__ / __file__ injection ───────────────────── -def test_initial_load_injects_live_reload_dunders (tmp_path: pathlib.Path) -> None: - - """The reloader exposes ``__name__='__live_reload__'`` and ``__file__=path`` in the watched namespace. - - Together with the user writing ``if __name__ == "__main__":`` blocks for - one-time setup, this powers the single-file workflow: setup runs when the - file is executed directly, while the watcher's re-exec sees a different - ``__name__`` and skips those blocks. - """ - - live_file = tmp_path / "patterns.py" - live_file.write_text( - "composition.data['name'] = __name__\n" - "composition.data['file'] = __file__\n" - ) - - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition.watch(live_file) - - assert composition.data["name"] == "__live_reload__" - assert composition.data["file"] == str(live_file) +def test_initial_load_injects_live_reload_dunders(tmp_path: pathlib.Path) -> None: + """The reloader exposes ``__name__='__live_reload__'`` and ``__file__=path`` in the watched namespace. + Together with the user writing ``if __name__ == "__main__":`` blocks for + one-time setup, this powers the single-file workflow: setup runs when the + file is executed directly, while the watcher's re-exec sees a different + ``__name__`` and skips those blocks. + """ -def test_initial_load_skips_main_guard (tmp_path: pathlib.Path) -> None: + live_file = tmp_path / "patterns.py" + live_file.write_text( + "composition.data['name'] = __name__\ncomposition.data['file'] = __file__\n" + ) - """``if __name__ == "__main__":`` blocks in the watched file are skipped. + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition.watch(live_file) - This is the core invariant for the single-file workflow — one-time - setup gated by the guard must not re-run inside the watcher's exec. - """ + assert composition.data["name"] == "__live_reload__" + assert composition.data["file"] == str(live_file) - live_file = tmp_path / "patterns.py" - live_file.write_text( - "composition.data['always'] = True\n" - "if __name__ == '__main__':\n" - "\tcomposition.data['main_guard'] = True\n" - ) - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition.watch(live_file) +def test_initial_load_skips_main_guard(tmp_path: pathlib.Path) -> None: + """``if __name__ == "__main__":`` blocks in the watched file are skipped. - assert composition.data["always"] is True - assert "main_guard" not in composition.data + This is the core invariant for the single-file workflow — one-time + setup gated by the guard must not re-run inside the watcher's exec. + """ + live_file = tmp_path / "patterns.py" + live_file.write_text( + "composition.data['always'] = True\n" + "if __name__ == '__main__':\n" + "\tcomposition.data['main_guard'] = True\n" + ) -def test_self_watch_skips_initial_exec (tmp_path: pathlib.Path) -> None: + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition.watch(live_file) - """Self-watch (watch(__file__) from the file being watched) skips _load_initial's exec. + assert composition.data["always"] is True + assert "main_guard" not in composition.data - Otherwise the outer Python script execution AND the watcher's initial - exec both register the same pattern decorators, double-counting every - pattern. Detection compares the watch path to the caller frame's - module-level ``__file__``. - """ - live_file = tmp_path / "single.py" - live_file.write_text( - "composition.data['exec_count'] = composition.data.get('exec_count', 0) + 1\n" - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p): pass\n" - ) +def test_self_watch_skips_initial_exec(tmp_path: pathlib.Path) -> None: + """Self-watch (watch(__file__) from the file being watched) skips _load_initial's exec. - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + Otherwise the outer Python script execution AND the watcher's initial + exec both register the same pattern decorators, double-counting every + pattern. Detection compares the watch path to the caller frame's + module-level ``__file__``. + """ - # Simulate ``python single.py`` by exec'ing the file in a namespace - # whose ``__file__`` matches the path being watched — that's the signal - # Composition.watch() uses to detect self-watch. - preamble = ( - "composition.watch(__file__)\n" - + live_file.read_text() - ) - ns = {"__name__": "__main__", "__file__": str(live_file), "composition": composition} - exec(compile(preamble, str(live_file), "exec"), ns) + live_file = tmp_path / "single.py" + live_file.write_text( + "composition.data['exec_count'] = composition.data.get('exec_count', 0) + 1\n" + "@composition.pattern(channel=1, beats=4)\n" + "def drums (p): pass\n" + ) - # Outer exec ran the body once; _load_initial should have been skipped. - assert composition.data["exec_count"] == 1 - pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] - assert pending_names == ["drums"] # exactly one, not two + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._live_reloader.stop() + # Simulate ``python single.py`` by exec'ing the file in a namespace + # whose ``__file__`` matches the path being watched — that's the signal + # Composition.watch() uses to detect self-watch. + preamble = "composition.watch(__file__)\n" + live_file.read_text() + ns = { + "__name__": "__main__", + "__file__": str(live_file), + "composition": composition, + } + exec(compile(preamble, str(live_file), "exec"), ns) + # Outer exec ran the body once; _load_initial should have been skipped. + assert composition.data["exec_count"] == 1 + pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] + assert pending_names == ["drums"] # exactly one, not two -def test_two_file_watch_runs_initial_exec (tmp_path: pathlib.Path) -> None: + composition._live_reloader.stop() - """The classic two-file flow still runs _load_initial: caller's __file__ != watched path.""" - wrapper_file = tmp_path / "wrapper.py" - live_file = tmp_path / "patterns.py" +def test_two_file_watch_runs_initial_exec(tmp_path: pathlib.Path) -> None: + """The classic two-file flow still runs _load_initial: caller's __file__ != watched path.""" - live_file.write_text( - "composition.data['exec_count'] = composition.data.get('exec_count', 0) + 1\n" - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p): pass\n" - ) + wrapper_file = tmp_path / "wrapper.py" + live_file = tmp_path / "patterns.py" - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + live_file.write_text( + "composition.data['exec_count'] = composition.data.get('exec_count', 0) + 1\n" + "@composition.pattern(channel=1, beats=4)\n" + "def drums (p): pass\n" + ) - # Simulate ``python wrapper.py`` calling watch(live_file). Caller's - # __file__ is wrapper_file; watched path is live_file → different → - # normal _load_initial path runs and execs the live file. - ns = { - "__name__": "__main__", - "__file__": str(wrapper_file), - "composition": composition, - "live_path": str(live_file), - } - exec("composition.watch(live_path)", ns) + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - # _load_initial ran the live file exactly once. - assert composition.data["exec_count"] == 1 - pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] - assert pending_names == ["drums"] + # Simulate ``python wrapper.py`` calling watch(live_file). Caller's + # __file__ is wrapper_file; watched path is live_file → different → + # normal _load_initial path runs and execs the live file. + ns = { + "__name__": "__main__", + "__file__": str(wrapper_file), + "composition": composition, + "live_path": str(live_file), + } + exec("composition.watch(live_path)", ns) + + # _load_initial ran the live file exactly once. + assert composition.data["exec_count"] == 1 + pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] + assert pending_names == ["drums"] - composition._live_reloader.stop() + composition._live_reloader.stop() diff --git a/tests/test_live_server.py b/tests/test_live_server.py index 4f6383e..8af54a6 100644 --- a/tests/test_live_server.py +++ b/tests/test_live_server.py @@ -14,534 +14,522 @@ SENTINEL = b"\x04" -async def _send_recv (reader: asyncio.StreamReader, writer: asyncio.StreamWriter, code: str) -> str: +async def _send_recv( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter, code: str +) -> str: + """Send code to the live server and return the response string.""" - """Send code to the live server and return the response string.""" + writer.write(code.encode("utf-8") + SENTINEL) + await writer.drain() - writer.write(code.encode("utf-8") + SENTINEL) - await writer.drain() + chunks: list[bytes] = [] - chunks: list[bytes] = [] + while True: + chunk = await asyncio.wait_for(reader.read(4096), timeout=5.0) - while True: - chunk = await asyncio.wait_for(reader.read(4096), timeout=5.0) + if SENTINEL in chunk: + before, _, _ = chunk.partition(SENTINEL) + chunks.append(before) + break - if SENTINEL in chunk: - before, _, _ = chunk.partition(SENTINEL) - chunks.append(before) - break + chunks.append(chunk) - chunks.append(chunk) - - return b"".join(chunks).decode("utf-8") + return b"".join(chunks).decode("utf-8") @pytest.fixture -def composition (patch_midi: None) -> subsequence.Composition: - - """Create a composition with a simple pattern for testing.""" +def composition(patch_midi: None) -> subsequence.Composition: + """Create a composition with a simple pattern for testing.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - return comp + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + return comp # --- Server eval tests --- @pytest.mark.asyncio -async def test_eval_expression (composition: subsequence.Composition) -> None: +async def test_eval_expression(composition: subsequence.Composition) -> None: + """Sending an expression should return its repr.""" - """Sending an expression should return its repr.""" + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) - reader, writer = await asyncio.open_connection("127.0.0.1", port) + result = await _send_recv(reader, writer, "1 + 1") - result = await _send_recv(reader, writer, "1 + 1") + assert result == "2" - assert result == "2" - - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() @pytest.mark.asyncio -async def test_exec_statement (composition: subsequence.Composition) -> None: - - """Sending a statement should return OK, and its side effects should persist.""" +async def test_exec_statement(composition: subsequence.Composition) -> None: + """Sending a statement should return OK, and its side effects should persist.""" - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - reader, writer = await asyncio.open_connection("127.0.0.1", port) + reader, writer = await asyncio.open_connection("127.0.0.1", port) - result = await _send_recv(reader, writer, "x = 42") - assert result == "OK" + result = await _send_recv(reader, writer, "x = 42") + assert result == "OK" - result = await _send_recv(reader, writer, "x") - assert result == "42" + result = await _send_recv(reader, writer, "x") + assert result == "42" - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() @pytest.mark.asyncio -async def test_syntax_error_rejected (composition: subsequence.Composition) -> None: +async def test_syntax_error_rejected(composition: subsequence.Composition) -> None: + """Sending invalid Python should return a SyntaxError traceback without executing anything.""" - """Sending invalid Python should return a SyntaxError traceback without executing anything.""" + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) - reader, writer = await asyncio.open_connection("127.0.0.1", port) + result = await _send_recv(reader, writer, "def foo(:") - result = await _send_recv(reader, writer, "def foo(:") + assert "SyntaxError" in result - assert "SyntaxError" in result - - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() @pytest.mark.asyncio -async def test_runtime_error (composition: subsequence.Composition) -> None: - - """Sending valid syntax that raises at runtime should return a traceback.""" +async def test_runtime_error(composition: subsequence.Composition) -> None: + """Sending valid syntax that raises at runtime should return a traceback.""" - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - reader, writer = await asyncio.open_connection("127.0.0.1", port) + reader, writer = await asyncio.open_connection("127.0.0.1", port) - result = await _send_recv(reader, writer, "1 / 0") + result = await _send_recv(reader, writer, "1 / 0") - assert "ZeroDivisionError" in result + assert "ZeroDivisionError" in result - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() @pytest.mark.asyncio -async def test_namespace_has_composition (composition: subsequence.Composition) -> None: +async def test_namespace_has_composition(composition: subsequence.Composition) -> None: + """The eval namespace should include the composition object.""" - """The eval namespace should include the composition object.""" + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) - reader, writer = await asyncio.open_connection("127.0.0.1", port) + result = await _send_recv(reader, writer, "composition.bpm") - result = await _send_recv(reader, writer, "composition.bpm") + assert result == "120" - assert result == "120" - - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() @pytest.mark.asyncio -async def test_namespace_has_subsequence (composition: subsequence.Composition) -> None: - - """The eval namespace should include the subsequence package.""" +async def test_namespace_has_subsequence(composition: subsequence.Composition) -> None: + """The eval namespace should include the subsequence package.""" - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - reader, writer = await asyncio.open_connection("127.0.0.1", port) + reader, writer = await asyncio.open_connection("127.0.0.1", port) - result = await _send_recv(reader, writer, "subsequence.__name__") + result = await _send_recv(reader, writer, "subsequence.__name__") - assert result == "'subsequence'" + assert result == "'subsequence'" - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() # --- Composition live methods --- -def test_live_creates_server (patch_midi: None) -> None: +def test_live_creates_server(patch_midi: None) -> None: + """Calling live() should create a LiveServer and set _is_live.""" - """Calling live() should create a LiveServer and set _is_live.""" + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + comp.live(port=5556) - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - comp.live(port=5556) + assert comp._live_server is not None + assert comp._is_live is True - assert comp._live_server is not None - assert comp._is_live is True +def test_set_bpm(patch_midi: None) -> None: + """set_bpm() should update both the sequencer and the composition.""" -def test_set_bpm (patch_midi: None) -> None: + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + comp.set_bpm(140) - """set_bpm() should update both the sequencer and the composition.""" - - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - comp.set_bpm(140) - - assert comp.bpm == 140 - assert comp._sequencer.current_bpm == 140 + assert comp.bpm == 140 + assert comp._sequencer.current_bpm == 140 @pytest.mark.asyncio -async def test_set_bpm_via_server (composition: subsequence.Composition) -> None: - - """Changing BPM through the live server should update composition.bpm.""" - - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] +async def test_set_bpm_via_server(composition: subsequence.Composition) -> None: + """Changing BPM through the live server should update composition.bpm.""" - reader, writer = await asyncio.open_connection("127.0.0.1", port) + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - result = await _send_recv(reader, writer, "composition.set_bpm(140)") + reader, writer = await asyncio.open_connection("127.0.0.1", port) - assert result == "OK" - assert composition.bpm == 140 - assert composition._sequencer.current_bpm == 140 + result = await _send_recv(reader, writer, "composition.set_bpm(140)") - writer.close() - await writer.wait_closed() - await server.stop() + assert result == "OK" + assert composition.bpm == 140 + assert composition._sequencer.current_bpm == 140 + writer.close() + await writer.wait_closed() + await server.stop() -def test_live_info (patch_midi: None) -> None: - """live_info() should return a dict with the expected keys.""" +def test_live_info(patch_midi: None) -> None: + """live_info() should return a dict with the expected keys.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=125, key="E") - info = comp.live_info() + info = comp.live_info() - assert info["bpm"] == 125 - assert info["key"] == "E" - assert info["bar"] == 0 - assert info["section"] is None - assert info["chord"] is None - assert info["patterns"] == [] - assert info["data"] == {} + assert info["bpm"] == 125 + assert info["key"] == "E" + assert info["bar"] == 0 + assert info["section"] is None + assert info["chord"] is None + assert info["patterns"] == [] + assert info["data"] == {} -def test_live_info_with_data (patch_midi: None) -> None: +def test_live_info_with_data(patch_midi: None) -> None: + """live_info() should include the composition.data dict.""" - """live_info() should include the composition.data dict.""" + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + comp.data["intensity"] = 0.8 - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - comp.data["intensity"] = 0.8 + info = comp.live_info() - info = comp.live_info() - - assert info["data"]["intensity"] == 0.8 + assert info["data"]["intensity"] == 0.8 # --- Mute / Unmute --- -def test_mute_unmute (patch_midi: None) -> None: - - """Muting a pattern should produce empty steps; unmuting should restore them.""" - - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0, velocity=100) +def test_mute_unmute(patch_midi: None) -> None: + """Muting a pattern should produce empty steps; unmuting should restore them.""" - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - pattern = comp._build_pattern_from_pending(pending) + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0, velocity=100) - # Register it as a running pattern. - comp._running_patterns["my_builder"] = pattern + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - assert len(pattern.steps) > 0 + pattern = comp._build_pattern_from_pending(pending) - # Mute - next rebuild should produce empty steps. - comp.mute("my_builder") + # Register it as a running pattern. + comp._running_patterns["my_builder"] = pattern - assert pattern._muted is True + assert len(pattern.steps) > 0 - pattern.on_reschedule() + # Mute - next rebuild should produce empty steps. + comp.mute("my_builder") - assert len(pattern.steps) == 0 + assert pattern._muted is True - # Unmute - next rebuild should restore notes. - comp.unmute("my_builder") + pattern.on_reschedule() - assert pattern._muted is False + assert len(pattern.steps) == 0 - pattern.on_reschedule() + # Unmute - next rebuild should restore notes. + comp.unmute("my_builder") - assert len(pattern.steps) > 0 + assert pattern._muted is False + pattern.on_reschedule() -def test_mute_unknown_pattern_raises (patch_midi: None) -> None: + assert len(pattern.steps) > 0 - """Muting a non-existent pattern should raise ValueError.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") +def test_mute_unknown_pattern_raises(patch_midi: None) -> None: + """Muting a non-existent pattern should raise ValueError.""" - with pytest.raises(ValueError, match="not found"): - comp.mute("nonexistent") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + with pytest.raises(ValueError, match="not found"): + comp.mute("nonexistent") -def test_unmute_unknown_pattern_raises (patch_midi: None) -> None: - """Unmuting a non-existent pattern should raise ValueError.""" +def test_unmute_unknown_pattern_raises(patch_midi: None) -> None: + """Unmuting a non-existent pattern should raise ValueError.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - with pytest.raises(ValueError, match="not found"): - comp.unmute("nonexistent") + with pytest.raises(ValueError, match="not found"): + comp.unmute("nonexistent") -def test_mute_preserves_cycle_count (patch_midi: None) -> None: +def test_mute_preserves_cycle_count(patch_midi: None) -> None: + """Muting should keep advancing the cycle count even though no notes are produced.""" - """Muting should keep advancing the cycle count even though no notes are produced.""" + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + def my_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0, velocity=100) - def my_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0, velocity=100) + pending = subsequence.composition._PendingPattern( + builder_fn=my_builder, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pending = subsequence.composition._PendingPattern( - builder_fn = my_builder, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pattern = comp._build_pattern_from_pending(pending) + comp._running_patterns["my_builder"] = pattern - pattern = comp._build_pattern_from_pending(pending) - comp._running_patterns["my_builder"] = pattern + # cycle_count starts at 1 (incremented in first _rebuild). + initial_cycle = pattern._cycle_count - # cycle_count starts at 1 (incremented in first _rebuild). - initial_cycle = pattern._cycle_count + comp.mute("my_builder") + pattern.on_reschedule() # muted rebuild + pattern.on_reschedule() # muted rebuild - comp.mute("my_builder") - pattern.on_reschedule() # muted rebuild - pattern.on_reschedule() # muted rebuild - - # Cycle count should have advanced by 2. - assert pattern._cycle_count == initial_cycle + 2 + # Cycle count should have advanced by 2. + assert pattern._cycle_count == initial_cycle + 2 # --- Hot-swap --- -def test_pattern_hot_swap (patch_midi: None) -> None: - - """During a live session, re-decorating a pattern should replace its builder function.""" +def test_pattern_hot_swap(patch_midi: None) -> None: + """During a live session, re-decorating a pattern should replace its builder function.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0, velocity=100) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0, velocity=100) - pending = subsequence.composition._PendingPattern( - builder_fn = my_pattern, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pending = subsequence.composition._PendingPattern( + builder_fn=my_pattern, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pattern = comp._build_pattern_from_pending(pending) - comp._running_patterns["my_pattern"] = pattern - comp._is_live = True + pattern = comp._build_pattern_from_pending(pending) + comp._running_patterns["my_pattern"] = pattern + comp._is_live = True - # Original builder places a note at beat 0. - assert 0 in pattern.steps + # Original builder places a note at beat 0. + assert 0 in pattern.steps - # Hot-swap with a new builder that places a note at beat 2. - @comp.pattern(channel=1, beats=4) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(72, beat=2, velocity=80) + # Hot-swap with a new builder that places a note at beat 2. + @comp.pattern(channel=1, beats=4) + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(72, beat=2, velocity=80) - # Trigger rebuild. - pattern.on_reschedule() + # Trigger rebuild. + pattern.on_reschedule() - # New builder should have placed a note at beat 2 (pulse 48). - pulse_2 = int(2.0 * subsequence.constants.MIDI_QUARTER_NOTE) + # New builder should have placed a note at beat 2 (pulse 48). + pulse_2 = int(2.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert pulse_2 in pattern.steps - assert pattern.steps[pulse_2].notes[0].pitch == 72 + assert pulse_2 in pattern.steps + assert pattern.steps[pulse_2].notes[0].pitch == 72 -def test_hot_swap_updates_wants_chord (patch_midi: None) -> None: +def test_hot_swap_updates_wants_chord(patch_midi: None) -> None: + """Hot-swapping should update _wants_chord when the new builder's signature differs.""" - """Hot-swapping should update _wants_chord when the new builder's signature differs.""" + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + def my_pattern(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0, velocity=100) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0, velocity=100) + pending = subsequence.composition._PendingPattern( + builder_fn=my_pattern, + channel=1, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pending = subsequence.composition._PendingPattern( - builder_fn = my_pattern, - channel = 1, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pattern = comp._build_pattern_from_pending(pending) + comp._running_patterns["my_pattern"] = pattern + comp._is_live = True - pattern = comp._build_pattern_from_pending(pending) - comp._running_patterns["my_pattern"] = pattern - comp._is_live = True + assert pattern._wants_chord is False - assert pattern._wants_chord is False + # Hot-swap with a builder that wants chord. + @comp.pattern(channel=1, beats=4) + def my_pattern( + p: "subsequence.pattern_builder.PatternBuilder", + chord: "subsequence.chords.Chord", + ) -> None: + pass - # Hot-swap with a builder that wants chord. - @comp.pattern(channel=1, beats=4) - def my_pattern (p: "subsequence.pattern_builder.PatternBuilder", chord: "subsequence.chords.Chord") -> None: - pass - - assert pattern._wants_chord is True + assert pattern._wants_chord is True # --- Live info with running patterns --- -def test_live_info_includes_patterns (patch_midi: None) -> None: - - """live_info() should list running patterns with their metadata.""" +def test_live_info_includes_patterns(patch_midi: None) -> None: + """live_info() should list running patterns with their metadata.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - def drums (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(36, beat=0, velocity=127) + def drums(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(36, beat=0, velocity=127) - pending = subsequence.composition._PendingPattern( - builder_fn = drums, - channel = 9, - length = 4, - drum_note_map = None, - reschedule_lookahead = 1, - default_grid = 16 - ) + pending = subsequence.composition._PendingPattern( + builder_fn=drums, + channel=9, + length=4, + drum_note_map=None, + reschedule_lookahead=1, + default_grid=16, + ) - pattern = comp._build_pattern_from_pending(pending) - comp._running_patterns["drums"] = pattern + pattern = comp._build_pattern_from_pending(pending) + comp._running_patterns["drums"] = pattern - info = comp.live_info() + info = comp.live_info() - assert len(info["patterns"]) == 1 - assert info["patterns"][0]["name"] == "drums" - assert info["patterns"][0]["channel"] == 10 # 9 + 1, 1-indexed display - assert info["patterns"][0]["length"] == 4 - assert info["patterns"][0]["muted"] is False + assert len(info["patterns"]) == 1 + assert info["patterns"][0]["name"] == "drums" + assert info["patterns"][0]["channel"] == 10 # 9 + 1, 1-indexed display + assert info["patterns"][0]["length"] == 4 + assert info["patterns"][0]["muted"] is False # --- Safety --- @pytest.mark.asyncio -async def test_blocked_builtins (composition: subsequence.Composition) -> None: +async def test_blocked_builtins(composition: subsequence.Composition) -> None: + """Blocked builtins should return RuntimeError, not freeze or kill the process.""" - """Blocked builtins should return RuntimeError, not freeze or kill the process.""" + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) - reader, writer = await asyncio.open_connection("127.0.0.1", port) + for name in ("help()", "input()", "exit()", "quit()", "breakpoint()"): + result = await _send_recv(reader, writer, name) + assert "RuntimeError" in result, ( + f"{name} should raise RuntimeError, got: {result}" + ) + assert "not available in live mode" in result - for name in ("help()", "input()", "exit()", "quit()", "breakpoint()"): - result = await _send_recv(reader, writer, name) - assert "RuntimeError" in result, f"{name} should raise RuntimeError, got: {result}" - assert "not available in live mode" in result - - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() @pytest.mark.asyncio -async def test_sys_exit_caught (composition: subsequence.Composition) -> None: - - """sys.exit() should be caught and return an error, not kill the server.""" +async def test_sys_exit_caught(composition: subsequence.Composition) -> None: + """sys.exit() should be caught and return an error, not kill the server.""" - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] - reader, writer = await asyncio.open_connection("127.0.0.1", port) + reader, writer = await asyncio.open_connection("127.0.0.1", port) - result = await _send_recv(reader, writer, "import sys; sys.exit()") + result = await _send_recv(reader, writer, "import sys; sys.exit()") - assert "SystemExit" in result + assert "SystemExit" in result - # Server should still be alive - send another command. - result = await _send_recv(reader, writer, "1 + 1") - assert result == "2" + # Server should still be alive - send another command. + result = await _send_recv(reader, writer, "1 + 1") + assert result == "2" - writer.close() - await writer.wait_closed() - await server.stop() + writer.close() + await writer.wait_closed() + await server.stop() @pytest.mark.asyncio -async def test_eval_does_not_block_event_loop (composition: subsequence.Composition) -> None: - - """Eval should run in a thread, keeping the event loop responsive.""" - - server = subsequence.live_server.LiveServer(composition, port=0) - await server.start() - port = server._server.sockets[0].getsockname()[1] - - # Open two connections - one sends a slow eval, the other pings. - slow_reader, slow_writer = await asyncio.open_connection("127.0.0.1", port) - fast_reader, fast_writer = await asyncio.open_connection("127.0.0.1", port) - - # Send a slow eval (0.5s sleep) on the first connection. - slow_writer.write(b"__import__('time').sleep(0.5)\x04") - await slow_writer.drain() - - # Give the slow eval a moment to start, then send a fast eval on the second connection. - await asyncio.sleep(0.05) - - start = time.perf_counter() - fast_result = await _send_recv(fast_reader, fast_writer, "1 + 1") - fast_duration = time.perf_counter() - start - - assert fast_result == "2" - - # The fast eval should complete well before the 0.5s sleep finishes. - assert fast_duration < 0.3, f"Fast eval took {fast_duration:.2f}s - event loop was blocked" - - # Wait for the slow eval to finish. - slow_chunks: list[bytes] = [] - while True: - chunk = await asyncio.wait_for(slow_reader.read(4096), timeout=5.0) - if SENTINEL in chunk: - before, _, _ = chunk.partition(SENTINEL) - slow_chunks.append(before) - break - slow_chunks.append(chunk) - - slow_writer.close() - await slow_writer.wait_closed() - fast_writer.close() - await fast_writer.wait_closed() - await server.stop() +async def test_eval_does_not_block_event_loop( + composition: subsequence.Composition, +) -> None: + """Eval should run in a thread, keeping the event loop responsive.""" + + server = subsequence.live_server.LiveServer(composition, port=0) + await server.start() + port = server._server.sockets[0].getsockname()[1] + + # Open two connections - one sends a slow eval, the other pings. + slow_reader, slow_writer = await asyncio.open_connection("127.0.0.1", port) + fast_reader, fast_writer = await asyncio.open_connection("127.0.0.1", port) + + # Send a slow eval (0.5s sleep) on the first connection. + slow_writer.write(b"__import__('time').sleep(0.5)\x04") + await slow_writer.drain() + + # Give the slow eval a moment to start, then send a fast eval on the second connection. + await asyncio.sleep(0.05) + + start = time.perf_counter() + fast_result = await _send_recv(fast_reader, fast_writer, "1 + 1") + fast_duration = time.perf_counter() - start + + assert fast_result == "2" + + # The fast eval should complete well before the 0.5s sleep finishes. + assert fast_duration < 0.3, ( + f"Fast eval took {fast_duration:.2f}s - event loop was blocked" + ) + + # Wait for the slow eval to finish. + slow_chunks: list[bytes] = [] + while True: + chunk = await asyncio.wait_for(slow_reader.read(4096), timeout=5.0) + if SENTINEL in chunk: + before, _, _ = chunk.partition(SENTINEL) + slow_chunks.append(before) + break + slow_chunks.append(chunk) + + slow_writer.close() + await slow_writer.wait_closed() + fast_writer.close() + await fast_writer.wait_closed() + await server.stop() diff --git a/tests/test_load_patterns.py b/tests/test_load_patterns.py index 3ed01ec..1f4b55b 100644 --- a/tests/test_load_patterns.py +++ b/tests/test_load_patterns.py @@ -21,137 +21,125 @@ # ── Pre-play (no event loop) ──────────────────────────────────────────────── -def test_load_patterns_registers_new_pattern_pre_play (patch_midi: None) -> None: +def test_load_patterns_registers_new_pattern_pre_play(patch_midi: None) -> None: + """Before play(), load_patterns exec's on the caller thread; pattern lands in _pending_patterns.""" - """Before play(), load_patterns exec's on the caller thread; pattern lands in _pending_patterns.""" + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + source = "@composition.pattern(channel=1, beats=4)\ndef drums (p):\n\tpass\n" + composition.load_patterns(source) - source = ( - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p):\n" - "\tpass\n" - ) - composition.load_patterns(source) + # Pre-play, decorators populate _pending_patterns; play() graduates them. + pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] + assert "drums" in pending_names - # Pre-play, decorators populate _pending_patterns; play() graduates them. - pending_names = [p.builder_fn.__name__ for p in composition._pending_patterns] - assert "drums" in pending_names +def test_load_patterns_raises_syntax_error(patch_midi: None) -> None: + """Bad Python source raises SyntaxError synchronously; no state mutated.""" -def test_load_patterns_raises_syntax_error (patch_midi: None) -> None: + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - """Bad Python source raises SyntaxError synchronously; no state mutated.""" + with pytest.raises(SyntaxError): + composition.load_patterns("def drums(p): :::") - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + assert composition._pending_patterns == [] + assert composition._running_patterns == {} - with pytest.raises(SyntaxError): - composition.load_patterns("def drums(p): :::") - assert composition._pending_patterns == [] - assert composition._running_patterns == {} +def test_load_patterns_raises_runtime_error(patch_midi: None) -> None: + """Exception during exec propagates; no decorator-side state from after the failure.""" + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") -def test_load_patterns_raises_runtime_error (patch_midi: None) -> None: + source = ( + "@composition.pattern(channel=1, beats=4)\n" + "def first (p): pass\n" + "raise ValueError('boom')\n" + ) - """Exception during exec propagates; no decorator-side state from after the failure.""" + with pytest.raises(ValueError, match="boom"): + composition.load_patterns(source) - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - source = ( - "@composition.pattern(channel=1, beats=4)\n" - "def first (p): pass\n" - "raise ValueError('boom')\n" - ) +def test_load_patterns_uses_source_label_in_compile_error(patch_midi: None) -> None: + """SyntaxError's filename attribute carries the user-supplied source_label.""" - with pytest.raises(ValueError, match="boom"): - composition.load_patterns(source) + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + with pytest.raises(SyntaxError) as exc_info: + composition.load_patterns( + "def drums(p): :::", source_label="uploaded-session-abc" + ) -def test_load_patterns_uses_source_label_in_compile_error (patch_midi: None) -> None: + assert exc_info.value.filename == "uploaded-session-abc" - """SyntaxError's filename attribute carries the user-supplied source_label.""" - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") +def test_load_patterns_default_source_label(patch_midi: None) -> None: + """Default source_label is ````.""" - with pytest.raises(SyntaxError) as exc_info: - composition.load_patterns("def drums(p): :::", source_label="uploaded-session-abc") + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - assert exc_info.value.filename == "uploaded-session-abc" + with pytest.raises(SyntaxError) as exc_info: + composition.load_patterns("def drums(p): :::") + assert exc_info.value.filename == "" -def test_load_patterns_default_source_label (patch_midi: None) -> None: - """Default source_label is ````.""" +def test_load_patterns_safe_builtins(patch_midi: None) -> None: + """Blocked builtins (input/exit/etc.) raise RuntimeError when called in source.""" - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - with pytest.raises(SyntaxError) as exc_info: - composition.load_patterns("def drums(p): :::") + for blocked in ("help", "input", "breakpoint", "exit", "quit"): + with pytest.raises(RuntimeError, match="not available in live mode"): + composition.load_patterns(f"{blocked}()") - assert exc_info.value.filename == "" +def test_load_patterns_sets_live_mode(patch_midi: None) -> None: + """load_patterns flips _is_live=True so subsequent re-decorations hot-swap.""" -def test_load_patterns_safe_builtins (patch_midi: None) -> None: + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + assert composition._is_live is False - """Blocked builtins (input/exit/etc.) raise RuntimeError when called in source.""" + composition.load_patterns("# empty") - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + assert composition._is_live is True - for blocked in ("help", "input", "breakpoint", "exit", "quit"): - with pytest.raises(RuntimeError, match="not available in live mode"): - composition.load_patterns(f"{blocked}()") +def test_load_patterns_injects_live_reload_dunders(patch_midi: None) -> None: + """The exec namespace exposes __name__='__live_reload__' and __file__=source_label. -def test_load_patterns_sets_live_mode (patch_midi: None) -> None: + Powers the single-file workflow: a user can write + ``if __name__ == "__main__": composition = subsequence.Composition(...)`` + at the top of a self-watching file and have it skipped during reload. + """ - """load_patterns flips _is_live=True so subsequent re-decorations hot-swap.""" + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - assert composition._is_live is False + # Source records the dunder values it saw at exec time on composition.data + # so the test can read them back. + composition.load_patterns( + "composition.data['name'] = __name__\ncomposition.data['file'] = __file__\n", + source_label="uploaded-session-xyz", + ) - composition.load_patterns("# empty") + assert composition.data["name"] == "__live_reload__" + assert composition.data["file"] == "uploaded-session-xyz" - assert composition._is_live is True +def test_load_patterns_skips_main_guard(patch_midi: None) -> None: + """``if __name__ == "__main__":`` blocks in the source are skipped at reload-time.""" -def test_load_patterns_injects_live_reload_dunders (patch_midi: None) -> None: + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - """The exec namespace exposes __name__='__live_reload__' and __file__=source_label. + composition.load_patterns( + "composition.data['outside_guard'] = True\n" + "if __name__ == '__main__':\n" + "\tcomposition.data['inside_guard'] = True\n" + ) - Powers the single-file workflow: a user can write - ``if __name__ == "__main__": composition = subsequence.Composition(...)`` - at the top of a self-watching file and have it skipped during reload. - """ - - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - - # Source records the dunder values it saw at exec time on composition.data - # so the test can read them back. - composition.load_patterns( - "composition.data['name'] = __name__\n" - "composition.data['file'] = __file__\n", - source_label = "uploaded-session-xyz", - ) - - assert composition.data["name"] == "__live_reload__" - assert composition.data["file"] == "uploaded-session-xyz" - - -def test_load_patterns_skips_main_guard (patch_midi: None) -> None: - - """``if __name__ == "__main__":`` blocks in the source are skipped at reload-time.""" - - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - - composition.load_patterns( - "composition.data['outside_guard'] = True\n" - "if __name__ == '__main__':\n" - "\tcomposition.data['inside_guard'] = True\n" - ) - - assert composition.data.get("outside_guard") is True - assert "inside_guard" not in composition.data + assert composition.data.get("outside_guard") is True + assert "inside_guard" not in composition.data # ── Post-play (event loop running) ────────────────────────────────────────── @@ -165,112 +153,105 @@ def test_load_patterns_skips_main_guard (patch_midi: None) -> None: @pytest.mark.asyncio -async def test_load_patterns_swaps_pattern_post_play (patch_midi: None) -> None: +async def test_load_patterns_swaps_pattern_post_play(patch_midi: None) -> None: + """With the composition playing, load_patterns swaps a running pattern's builder in place.""" - """With the composition playing, load_patterns swaps a running pattern's builder in place.""" + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + # Initial setup uses the pre-play path (no event loop attached yet). + composition.load_patterns( + "@composition.pattern(channel=1, beats=4)\ndef drums (p):\n\tp._tag = 'v1'\n" + ) - # Initial setup uses the pre-play path (no event loop attached yet). - composition.load_patterns( - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p):\n" - "\tp._tag = 'v1'\n" - ) + # Simulate play(): attach the loop reference so subsequent calls take + # the schedule-on-loop branch, and graduate the pre-play pending pattern. + composition._sequencer._event_loop = asyncio.get_event_loop() + await composition._activate_new_pending_patterns() - # Simulate play(): attach the loop reference so subsequent calls take - # the schedule-on-loop branch, and graduate the pre-play pending pattern. - composition._sequencer._event_loop = asyncio.get_event_loop() - await composition._activate_new_pending_patterns() + first_builder = composition._running_patterns["drums"]._builder_fn - first_builder = composition._running_patterns["drums"]._builder_fn + # A post-play load must originate from a thread OTHER than the loop — + # realistic call-site is a worker thread (web handler, queue consumer). + # Calling it from the loop thread itself would deadlock the future wait. + await asyncio.to_thread( + composition.load_patterns, + "@composition.pattern(channel=1, beats=4)\ndef drums (p):\n\tp._tag = 'v2'\n", + ) - # A post-play load must originate from a thread OTHER than the loop — - # realistic call-site is a worker thread (web handler, queue consumer). - # Calling it from the loop thread itself would deadlock the future wait. - await asyncio.to_thread( - composition.load_patterns, - "@composition.pattern(channel=1, beats=4)\n" - "def drums (p):\n" - "\tp._tag = 'v2'\n", - ) + new_builder = composition._running_patterns["drums"]._builder_fn + assert new_builder is not first_builder - new_builder = composition._running_patterns["drums"]._builder_fn - assert new_builder is not first_builder + class _Stub: ... - class _Stub: ... - stub = _Stub() - new_builder(stub) - assert stub._tag == "v2" + stub = _Stub() + new_builder(stub) + assert stub._tag == "v2" @pytest.mark.asyncio -async def test_load_patterns_unregisters_removed_post_play (patch_midi: None) -> None: +async def test_load_patterns_unregisters_removed_post_play(patch_midi: None) -> None: + """Patterns absent from the new source are unregistered.""" - """Patterns absent from the new source are unregistered.""" + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition.load_patterns( + "@composition.pattern(channel=1, beats=4)\n" + "def a (p): pass\n" + "@composition.pattern(channel=2, beats=4)\n" + "def b (p): pass\n" + ) - composition.load_patterns( - "@composition.pattern(channel=1, beats=4)\n" - "def a (p): pass\n" - "@composition.pattern(channel=2, beats=4)\n" - "def b (p): pass\n" - ) + composition._sequencer._event_loop = asyncio.get_event_loop() + await composition._activate_new_pending_patterns() + assert set(composition._running_patterns) == {"a", "b"} - composition._sequencer._event_loop = asyncio.get_event_loop() - await composition._activate_new_pending_patterns() - assert set(composition._running_patterns) == {"a", "b"} + # Source v2 declares only ``a``; ``b`` should be torn down. + await asyncio.to_thread( + composition.load_patterns, + "@composition.pattern(channel=1, beats=4)\ndef a (p): pass\n", + ) - # Source v2 declares only ``a``; ``b`` should be torn down. - await asyncio.to_thread( - composition.load_patterns, - "@composition.pattern(channel=1, beats=4)\n" - "def a (p): pass\n", - ) - - assert set(composition._running_patterns) == {"a"} + assert set(composition._running_patterns) == {"a"} @pytest.mark.asyncio -async def test_load_patterns_post_play_runtime_error_propagates (patch_midi: None) -> None: - - """Runtime error during exec post-play surfaces back to the caller as a real exception.""" +async def test_load_patterns_post_play_runtime_error_propagates( + patch_midi: None, +) -> None: + """Runtime error during exec post-play surfaces back to the caller as a real exception.""" - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition.load_patterns( - "@composition.pattern(channel=1, beats=4)\n" - "def keeper (p): pass\n" - ) + composition.load_patterns( + "@composition.pattern(channel=1, beats=4)\ndef keeper (p): pass\n" + ) - composition._sequencer._event_loop = asyncio.get_event_loop() - await composition._activate_new_pending_patterns() - assert "keeper" in composition._running_patterns + composition._sequencer._event_loop = asyncio.get_event_loop() + await composition._activate_new_pending_patterns() + assert "keeper" in composition._running_patterns - # Broken upload — exec raises mid-source. The exception should - # propagate from the worker thread through future.result() back to - # the test caller. Previous state should be preserved (the diff / - # unregister phase inside _apply_source_async is skipped on exec - # failure, so 'keeper' is not torn down). - with pytest.raises(ValueError, match="upload-failure"): - await asyncio.to_thread( - composition.load_patterns, - "raise ValueError('upload-failure')\n", - ) + # Broken upload — exec raises mid-source. The exception should + # propagate from the worker thread through future.result() back to + # the test caller. Previous state should be preserved (the diff / + # unregister phase inside _apply_source_async is skipped on exec + # failure, so 'keeper' is not torn down). + with pytest.raises(ValueError, match="upload-failure"): + await asyncio.to_thread( + composition.load_patterns, + "raise ValueError('upload-failure')\n", + ) - assert "keeper" in composition._running_patterns + assert "keeper" in composition._running_patterns @pytest.mark.asyncio -async def test_load_patterns_refuses_on_loop_thread (patch_midi: None) -> None: - - """Calling from inside the composition's own loop raises rather than deadlocking.""" +async def test_load_patterns_refuses_on_loop_thread(patch_midi: None) -> None: + """Calling from inside the composition's own loop raises rather than deadlocking.""" - composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") - composition._sequencer._event_loop = asyncio.get_event_loop() + composition = subsequence.Composition(bpm=120, output_device="Dummy MIDI") + composition._sequencer._event_loop = asyncio.get_event_loop() - # This test is itself running on the loop, so a direct call is the - # deadlock scenario the safety check exists to prevent. - with pytest.raises(RuntimeError, match="event loop thread"): - composition.load_patterns("# empty") + # This test is itself running on the loop, so a direct call is the + # deadlock scenario the safety check exists to prevent. + with pytest.raises(RuntimeError, match="event loop thread"): + composition.load_patterns("# empty") diff --git a/tests/test_low_fixes_2026_07.py b/tests/test_low_fixes_2026_07.py index e318bd5..6575710 100644 --- a/tests/test_low_fixes_2026_07.py +++ b/tests/test_low_fixes_2026_07.py @@ -23,395 +23,372 @@ import subsequence.sequence_utils -def make_builder (cycle: int = 0, length: float = 4.0) -> typing.Tuple[subsequence.pattern_builder.PatternBuilder, subsequence.pattern.Pattern]: +def make_builder( + cycle: int = 0, length: float = 4.0 +) -> typing.Tuple[ + subsequence.pattern_builder.PatternBuilder, subsequence.pattern.Pattern +]: + """Build a standalone PatternBuilder over a fresh pattern for testing.""" - """Build a standalone PatternBuilder over a fresh pattern for testing.""" + pat = subsequence.pattern.Pattern(channel=0, length=length) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=cycle) - pat = subsequence.pattern.Pattern(channel=0, length=length) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pat, cycle=cycle) - - return builder, pat + return builder, pat # ── Batch 1: opaque crashes → musical errors ──────────────────────────────── -def test_motif_euclidean_zero_steps_zero_pulses_is_empty_motif () -> None: - - """Nothing asked for on no grid is a clean empty motif, not a ZeroDivisionError.""" - - m = subsequence.motifs.Motif.euclidean(pulses=0, steps=0, pitch=36) - - assert m.events == () - assert m.length == 4.0 - - -def test_motif_euclidean_pulses_on_zero_steps_still_raises () -> None: - - """Asking for pulses on a zero-step grid keeps the kernel's clear error.""" - - with pytest.raises(ValueError, match="cannot be greater than steps"): - subsequence.motifs.Motif.euclidean(pulses=3, steps=0, pitch=36) - +def test_motif_euclidean_zero_steps_zero_pulses_is_empty_motif() -> None: + """Nothing asked for on no grid is a clean empty motif, not a ZeroDivisionError.""" -def test_motif_notes_rejects_bool_pitch () -> None: + m = subsequence.motifs.Motif.euclidean(pulses=0, steps=0, pitch=36) - """True/False are not MIDI notes even though bool subclasses int.""" + assert m.events == () + assert m.length == 4.0 - with pytest.raises(TypeError, match="got bool"): - subsequence.motifs.Motif.notes([True, 60]) +def test_motif_euclidean_pulses_on_zero_steps_still_raises() -> None: + """Asking for pulses on a zero-step grid keeps the kernel's clear error.""" -def test_motif_euclidean_rejects_bool_pitch () -> None: + with pytest.raises(ValueError, match="cannot be greater than steps"): + subsequence.motifs.Motif.euclidean(pulses=3, steps=0, pitch=36) - """A bool pitch raises a clear TypeError instead of a KeyError deep in sorting.""" - with pytest.raises(TypeError, match="MIDI int or drum name"): - subsequence.motifs.Motif.euclidean(pulses=3, steps=16, pitch=True) +def test_motif_notes_rejects_bool_pitch() -> None: + """True/False are not MIDI notes even though bool subclasses int.""" + with pytest.raises(TypeError, match="got bool"): + subsequence.motifs.Motif.notes([True, 60]) -def test_markov_zero_spacing_raises () -> None: - """markov() names the spacing parameter instead of dividing by zero.""" +def test_motif_euclidean_rejects_bool_pitch() -> None: + """A bool pitch raises a clear TypeError instead of a KeyError deep in sorting.""" - builder, _ = make_builder() + with pytest.raises(TypeError, match="MIDI int or drum name"): + subsequence.motifs.Motif.euclidean(pulses=3, steps=16, pitch=True) - with pytest.raises(ValueError, match="spacing"): - builder.markov( - transitions={"a": [("a", 1)]}, - pitch_map={"a": 60}, - spacing=0, - ) +def test_markov_zero_spacing_raises() -> None: + """markov() names the spacing parameter instead of dividing by zero.""" -def test_melody_zero_spacing_raises () -> None: + builder, _ = make_builder() - """melody() names the spacing parameter instead of dividing by zero.""" + with pytest.raises(ValueError, match="spacing"): + builder.markov( + transitions={"a": [("a", 1)]}, + pitch_map={"a": 60}, + spacing=0, + ) - builder, _ = make_builder() - state = subsequence.melodic_state.MelodicState() - with pytest.raises(ValueError, match="spacing"): - builder.melody(state, spacing=0) +def test_melody_zero_spacing_raises() -> None: + """melody() names the spacing parameter instead of dividing by zero.""" + builder, _ = make_builder() + state = subsequence.melodic_state.MelodicState() -def test_lsystem_zero_spacing_raises () -> None: + with pytest.raises(ValueError, match="spacing"): + builder.melody(state, spacing=0) - """lsystem() names the spacing parameter instead of dividing by zero.""" - builder, _ = make_builder() +def test_lsystem_zero_spacing_raises() -> None: + """lsystem() names the spacing parameter instead of dividing by zero.""" - with pytest.raises(ValueError, match="spacing"): - builder.lsystem( - pitch_map={"A": 60}, - axiom="A", - rules={"A": "AB", "B": "A"}, - spacing=0, - ) + builder, _ = make_builder() + with pytest.raises(ValueError, match="spacing"): + builder.lsystem( + pitch_map={"A": 60}, + axiom="A", + rules={"A": "AB", "B": "A"}, + spacing=0, + ) -def test_de_bruijn_zero_spacing_raises () -> None: - """de_bruijn() names the spacing parameter instead of dividing by zero.""" +def test_de_bruijn_zero_spacing_raises() -> None: + """de_bruijn() names the spacing parameter instead of dividing by zero.""" - builder, _ = make_builder() + builder, _ = make_builder() - with pytest.raises(ValueError, match="spacing"): - builder.de_bruijn([60, 62], spacing=0) + with pytest.raises(ValueError, match="spacing"): + builder.de_bruijn([60, 62], spacing=0) -def test_lorenz_zero_spacing_raises () -> None: +def test_lorenz_zero_spacing_raises() -> None: + """lorenz() names the spacing parameter instead of dividing by zero.""" - """lorenz() names the spacing parameter instead of dividing by zero.""" + builder, _ = make_builder() - builder, _ = make_builder() + with pytest.raises(ValueError, match="spacing"): + builder.lorenz([60, 62, 64], spacing=0) - with pytest.raises(ValueError, match="spacing"): - builder.lorenz([60, 62, 64], spacing=0) +def test_self_avoiding_walk_negative_spacing_raises() -> None: + """self_avoiding_walk() rejects negative spacing with a clear error.""" -def test_self_avoiding_walk_negative_spacing_raises () -> None: + builder, _ = make_builder() - """self_avoiding_walk() rejects negative spacing with a clear error.""" + with pytest.raises(ValueError, match="spacing"): + builder.self_avoiding_walk([60, 62, 64], spacing=-0.25) - builder, _ = make_builder() - with pytest.raises(ValueError, match="spacing"): - builder.self_avoiding_walk([60, 62, 64], spacing=-0.25) +def test_every_zero_cycle_length_raises() -> None: + """every(0, ...) explains the cycle length instead of a modulo-by-zero crash.""" + builder, _ = make_builder() -def test_every_zero_cycle_length_raises () -> None: + with pytest.raises(ValueError, match="cycle length must be at least 1"): + builder.every(0, lambda p: p.reverse()) - """every(0, ...) explains the cycle length instead of a modulo-by-zero crash.""" - builder, _ = make_builder() +def test_bar_cycle_zero_length_raises() -> None: + """bar_cycle(0) explains the cycle length instead of a modulo-by-zero crash.""" - with pytest.raises(ValueError, match="cycle length must be at least 1"): - builder.every(0, lambda p: p.reverse()) + builder, _ = make_builder() + with pytest.raises(ValueError, match="cycle length must be at least 1"): + builder.bar_cycle(0) -def test_bar_cycle_zero_length_raises () -> None: - """bar_cycle(0) explains the cycle length instead of a modulo-by-zero crash.""" +def test_cellular_automaton_2d_degenerate_grid_is_empty() -> None: + """Zero rows or columns yield the degenerate empty grid, matching the 1D no-op.""" - builder, _ = make_builder() + assert ( + subsequence.sequence_utils.generate_cellular_automaton_2d(rows=0, cols=16) == [] + ) + assert subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=3, cols=0 + ) == [[], [], []] - with pytest.raises(ValueError, match="cycle length must be at least 1"): - builder.bar_cycle(0) +def test_pink_noise_zero_sources_raises() -> None: + """pink_noise() asks for at least one source instead of an IndexError.""" -def test_cellular_automaton_2d_degenerate_grid_is_empty () -> None: + with pytest.raises(ValueError, match="at least one random source"): + subsequence.sequence_utils.pink_noise(steps=8, sources=0) - """Zero rows or columns yield the degenerate empty grid, matching the 1D no-op.""" - assert subsequence.sequence_utils.generate_cellular_automaton_2d(rows=0, cols=16) == [] - assert subsequence.sequence_utils.generate_cellular_automaton_2d(rows=3, cols=0) == [[], [], []] +def test_diatonic_extension_intervals_out_of_range_degree_raises() -> None: + """An out-of-range bare degree matches RomanChord.resolve's error style.""" + chord = subsequence.progressions.RomanChord(degree=8) -def test_pink_noise_zero_sources_raises () -> None: - - """pink_noise() asks for at least one source instead of an IndexError.""" - - with pytest.raises(ValueError, match="at least one random source"): - subsequence.sequence_utils.pink_noise(steps=8, sources=0) - - -def test_diatonic_extension_intervals_out_of_range_degree_raises () -> None: - - """An out-of-range bare degree matches RomanChord.resolve's error style.""" - - chord = subsequence.progressions.RomanChord(degree=8) - - with pytest.raises(ValueError, match="scale degree 8 is out of range"): - chord.diatonic_extension_intervals(0, "ionian", (7,)) + with pytest.raises(ValueError, match="scale degree 8 is out of range"): + chord.diatonic_extension_intervals(0, "ionian", (7,)) # ── Batch 3 behaviour changes ─────────────────────────────────────────────── -def test_chord_weight_validates_documented_range () -> None: - - """chord_weight is documented 0.0–1.0 and now validates like its sibling dials.""" +def test_chord_weight_validates_documented_range() -> None: + """chord_weight is documented 0.0–1.0 and now validates like its sibling dials.""" - with pytest.raises(ValueError, match="between 0 and 1"): - subsequence.melodic_state.MelodicState(chord_weight=1.5) + with pytest.raises(ValueError, match="between 0 and 1"): + subsequence.melodic_state.MelodicState(chord_weight=1.5) - with pytest.raises(ValueError, match="between 0 and 1"): - subsequence.melodic_state.MelodicState(chord_weight=-0.1) + with pytest.raises(ValueError, match="between 0 and 1"): + subsequence.melodic_state.MelodicState(chord_weight=-0.1) - # The documented endpoints stay valid. - subsequence.melodic_state.MelodicState(chord_weight=0.0) - subsequence.melodic_state.MelodicState(chord_weight=1.0) + # The documented endpoints stay valid. + subsequence.melodic_state.MelodicState(chord_weight=0.0) + subsequence.melodic_state.MelodicState(chord_weight=1.0) -def test_slide_rejects_notes_and_steps_together () -> None: +def test_slide_rejects_notes_and_steps_together() -> None: + """notes= and steps= are documented mutually exclusive — passing both raises.""" - """notes= and steps= are documented mutually exclusive — passing both raises.""" + builder, _ = make_builder() + builder.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) - builder, _ = make_builder() - builder.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) + with pytest.raises(ValueError, match="not both"): + builder.slide(notes=[1], steps=[4]) - with pytest.raises(ValueError, match="not both"): - builder.slide(notes=[1], steps=[4]) +def test_slide_out_of_range_note_index_raises_musically() -> None: + """An out-of-range note index names the pattern's note count, not an IndexError.""" -def test_slide_out_of_range_note_index_raises_musically () -> None: + builder, _ = make_builder() + builder.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) - """An out-of-range note index names the pattern's note count, not an IndexError.""" + with pytest.raises( + ValueError, match="note index 5 is outside this pattern's 4 notes" + ): + builder.slide(notes=[5]) - builder, _ = make_builder() - builder.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43]) - with pytest.raises(ValueError, match="note index 5 is outside this pattern's 4 notes"): - builder.slide(notes=[5]) +def test_slide_negative_note_index_still_works() -> None: + """Documented negative indexing survives the new bounds check.""" + builder, pat = make_builder() + builder.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 41]) + builder.legato(0.95) + builder.slide(notes=[-1], time=0.2) -def test_slide_negative_note_index_still_works () -> None: - - """Documented negative indexing survives the new bounds check.""" - - builder, pat = make_builder() - builder.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 41]) - builder.legato(0.95) - builder.slide(notes=[-1], time=0.2) - - assert any(e.message_type == "pitchwheel" for e in pat.cc_events) + assert any(e.message_type == "pitchwheel" for e in pat.cc_events) # ── Batch 4: consolidation refactors keep behaviour ───────────────────────── -def test_thue_morse_two_pitch_placement_unchanged () -> None: - - """Two-pitch mode still places pitch at 0-positions and pitch_b at 1-positions.""" - - builder, pat = make_builder() - builder.thue_morse(36, pitch_b=38, velocity=100) +def test_thue_morse_two_pitch_placement_unchanged() -> None: + """Two-pitch mode still places pitch at 0-positions and pitch_b at 1-positions.""" - sequence = subsequence.sequence_utils.thue_morse(16) - step_pulses = 96 / 16 + builder, pat = make_builder() + builder.thue_morse(36, pitch_b=38, velocity=100) - for i, val in enumerate(sequence): - notes = pat.steps[int(i * step_pulses)].notes - expected = 36 if val == 0 else 38 + sequence = subsequence.sequence_utils.thue_morse(16) + step_pulses = 96 / 16 - assert [n.pitch for n in notes] == [expected] + for i, val in enumerate(sequence): + notes = pat.steps[int(i * step_pulses)].notes + expected = 36 if val == 0 else 38 + assert [n.pitch for n in notes] == [expected] -def test_bresenham_poly_voices_never_overlap () -> None: - """Interlocking placement survives the shared-kernel refactor.""" +def test_bresenham_poly_voices_never_overlap() -> None: + """Interlocking placement survives the shared-kernel refactor.""" - builder, pat = make_builder() - builder.bresenham_poly(parts={36: 0.5, 38: 0.25}, velocity={36: 100, 38: 70}) + builder, pat = make_builder() + builder.bresenham_poly(parts={36: 0.5, 38: 0.25}, velocity={36: 100, 38: 70}) - for step in pat.steps.values(): - assert len(step.notes) == 1 + for step in pat.steps.values(): + assert len(step.notes) == 1 - velocities = {n.pitch: n.velocity for s in pat.steps.values() for n in s.notes} + velocities = {n.pitch: n.velocity for s in pat.steps.values() for n in s.notes} - assert velocities == {36: 100, 38: 70} + assert velocities == {36: 100, 38: 70} -def test_bend_emits_endpoint_when_resolution_skips_it () -> None: +def test_bend_emits_endpoint_when_resolution_skips_it() -> None: + """The endpoint rule survives delegating _generate_bend_events to the ramp kernel.""" - """The endpoint rule survives delegating _generate_bend_events to the ramp kernel.""" + builder, pat = make_builder() + builder.note(pitch=60, beat=0.0, duration=1.0) + builder.note(pitch=62, beat=2.0, duration=1.0) - builder, pat = make_builder() - builder.note(pitch=60, beat=0.0, duration=1.0) - builder.note(pitch=62, beat=2.0, duration=1.0) + # Duration 24 pulses, ramp over pulses 0..24 with resolution 5: 24 % 5 != 0, + # so the target value must still be emitted at the ramp's final pulse. + builder.bend(note=0, amount=1.0, resolution=5) - # Duration 24 pulses, ramp over pulses 0..24 with resolution 5: 24 % 5 != 0, - # so the target value must still be emitted at the ramp's final pulse. - builder.bend(note=0, amount=1.0, resolution=5) + wheel = [e for e in pat.cc_events if e.message_type == "pitchwheel"] + final = [e for e in wheel if e.pulse == 24] - wheel = [e for e in pat.cc_events if e.message_type == "pitchwheel"] - final = [e for e in wheel if e.pulse == 24] + assert final and final[-1].value == 8191 - assert final and final[-1].value == 8191 +def test_euclidean_seeded_thinning_unchanged() -> None: + """Seeded probability thinning is stable through _place_gated_sequence.""" -def test_euclidean_seeded_thinning_unchanged () -> None: + builder_a, pat_a = make_builder() + builder_a.euclidean(36, pulses=7, probability=0.6, seed=5) - """Seeded probability thinning is stable through _place_gated_sequence.""" + builder_b, pat_b = make_builder() + builder_b.euclidean(36, pulses=7, probability=0.6, seed=5) - builder_a, pat_a = make_builder() - builder_a.euclidean(36, pulses=7, probability=0.6, seed=5) - - builder_b, pat_b = make_builder() - builder_b.euclidean(36, pulses=7, probability=0.6, seed=5) - - assert sorted(pat_a.steps) == sorted(pat_b.steps) - assert 0 < len(pat_a.steps) <= 7 + assert sorted(pat_a.steps) == sorted(pat_b.steps) + assert 0 < len(pat_a.steps) <= 7 # ── scale_velocities: last-half-step wrap ─────────────────────────────────── -def test_scale_velocities_scales_note_in_last_half_step () -> None: - - """A note in the pattern's last half-step wraps to step 0's factor instead of escaping.""" - - builder, pat = make_builder() +def test_scale_velocities_scales_note_in_last_half_step() -> None: + """A note in the pattern's last half-step wraps to step 0's factor instead of escaping.""" - # 4 beats × 24 PPQ = 96 pulses; grid 16 → 6 pulses per step. Beat 3.95 - # lands on pulse 94, which rounds to step 16 — the wrap to step 0. - builder.note(pitch=60, beat=3.95, velocity=100, duration=0.1) - builder.scale_velocities([0.5] + [1.0] * 15) + builder, pat = make_builder() - note = next(iter(pat.steps.values())).notes[0] + # 4 beats × 24 PPQ = 96 pulses; grid 16 → 6 pulses per step. Beat 3.95 + # lands on pulse 94, which rounds to step 16 — the wrap to step 0. + builder.note(pitch=60, beat=3.95, velocity=100, duration=0.1) + builder.scale_velocities([0.5] + [1.0] * 15) - assert note.velocity == 50 + note = next(iter(pat.steps.values())).notes[0] + assert note.velocity == 50 -def test_scale_velocities_on_grid_steps_unchanged () -> None: - """Notes on ordinary grid steps still scale by their own step's factor.""" +def test_scale_velocities_on_grid_steps_unchanged() -> None: + """Notes on ordinary grid steps still scale by their own step's factor.""" - builder, pat = make_builder() - builder.note(pitch=60, beat=0.0, velocity=100, duration=0.1) - builder.note(pitch=60, beat=1.0, velocity=100, duration=0.1) + builder, pat = make_builder() + builder.note(pitch=60, beat=0.0, velocity=100, duration=0.1) + builder.note(pitch=60, beat=1.0, velocity=100, duration=0.1) - factors = [1.0] * 16 - factors[4] = 0.25 - builder.scale_velocities(factors) + factors = [1.0] * 16 + factors[4] = 0.25 + builder.scale_velocities(factors) - assert pat.steps[0].notes[0].velocity == 100 - assert pat.steps[24].notes[0].velocity == 25 + assert pat.steps[0].notes[0].velocity == 100 + assert pat.steps[24].notes[0].velocity == 25 # --- final mop-up: misleading behaviours ----------------------------------- -def test_quantize_midpoints_all_snap_later () -> None: - - """Onsets exactly midway between grid lines all snap the same way (later). - - Python's round() is half-to-even, which made exact midpoints snap in - ALTERNATING directions (0.125 -> 0.0 but 0.375 -> 0.5) — a zigzag no - musician expects from quantisation. - """ - - m = subsequence.motif([1, 2]) - events = tuple( - dataclasses.replace(e, beat=b) - for e, b in zip(m.events, (0.125, 0.375)) - ) - m = dataclasses.replace(m, events=events) - - q = m.quantize(0.25) - - assert [e.beat for e in q.events] == [0.25, 0.5] - - -def test_add_sequence_empty_velocity_list_raises_musically () -> None: - - """An explicit empty velocity list with hits to place raises a clear error. - Previously a bare ZeroDivisionError from the velocity modulo. - """ +def test_quantize_midpoints_all_snap_later() -> None: + """Onsets exactly midway between grid lines all snap the same way (later). - pat = subsequence.pattern.Pattern(channel=0) + Python's round() is half-to-even, which made exact midpoints snap in + ALTERNATING directions (0.125 -> 0.0 but 0.375 -> 0.5) — a zigzag no + musician expects from quantisation. + """ - with pytest.raises(ValueError) as exc: - pat.add_sequence([1, 0, 1], spacing_pulses=6, pitch=60, velocity=[]) + m = subsequence.motif([1, 2]) + events = tuple( + dataclasses.replace(e, beat=b) for e, b in zip(m.events, (0.125, 0.375)) + ) + m = dataclasses.replace(m, events=events) - assert "velocity list" in str(exc.value) + q = m.quantize(0.25) - # All-rests with an empty list stays a quiet no-op (nothing to voice). - pat.add_sequence([0, 0, 0], spacing_pulses=6, pitch=60, velocity=[]) - assert pat.steps == {} + assert [e.beat for e in q.events] == [0.25, 0.5] -def test_wing_discover_returns_none_on_unreachable_network (monkeypatch: pytest.MonkeyPatch) -> None: +def test_add_sequence_empty_velocity_list_raises_musically() -> None: + """An explicit empty velocity list with hits to place raises a clear error. - """discover() keeps its no-raise promise on a machine with no broadcast route. + Previously a bare ZeroDivisionError from the velocity modulo. + """ - sendto() raising OSError (e.g. ENETUNREACH on an offline laptop) counts - as "nothing replied" — the documented None return, not a traceback. - """ + pat = subsequence.pattern.Pattern(channel=0) - class _OfflineSocket: + with pytest.raises(ValueError) as exc: + pat.add_sequence([1, 0, 1], spacing_pulses=6, pitch=60, velocity=[]) - def __init__ (self, *args: typing.Any, **kwargs: typing.Any) -> None: + assert "velocity list" in str(exc.value) - pass + # All-rests with an empty list stays a quiet no-op (nothing to voice). + pat.add_sequence([0, 0, 0], spacing_pulses=6, pitch=60, velocity=[]) + assert pat.steps == {} - def setsockopt (self, *args: typing.Any) -> None: - pass +def test_wing_discover_returns_none_on_unreachable_network( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """discover() keeps its no-raise promise on a machine with no broadcast route. - def settimeout (self, timeout: float) -> None: + sendto() raising OSError (e.g. ENETUNREACH on an offline laptop) counts + as "nothing replied" — the documented None return, not a traceback. + """ - pass + class _OfflineSocket: + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + pass - def sendto (self, data: bytes, address: typing.Tuple[str, int]) -> None: + def setsockopt(self, *args: typing.Any) -> None: + pass - raise OSError(101, "Network is unreachable") + def settimeout(self, timeout: float) -> None: + pass - def close (self) -> None: + def sendto(self, data: bytes, address: typing.Tuple[str, int]) -> None: + raise OSError(101, "Network is unreachable") - pass + def close(self) -> None: + pass - monkeypatch.setattr(subsequence.helpers.wing.socket, "socket", _OfflineSocket) + monkeypatch.setattr(subsequence.helpers.wing.socket, "socket", _OfflineSocket) - assert subsequence.helpers.wing.discover(timeout=0.05) is None + assert subsequence.helpers.wing.discover(timeout=0.05) is None diff --git a/tests/test_melodic_state.py b/tests/test_melodic_state.py index 5814198..c8c65e8 100644 --- a/tests/test_melodic_state.py +++ b/tests/test_melodic_state.py @@ -22,396 +22,410 @@ # Helpers # --------------------------------------------------------------------------- -def _state ( - key: str = "C", - mode: str = "ionian", - low: int = 60, - high: int = 72, - nir_strength: float = 1.0, - chord_weight: float = 0.0, - rest_probability: float = 0.0, - pitch_diversity: float = 1.0, -) -> subsequence.melodic_state.MelodicState: - """Create a MelodicState with sensible test defaults.""" +def _state( + key: str = "C", + mode: str = "ionian", + low: int = 60, + high: int = 72, + nir_strength: float = 1.0, + chord_weight: float = 0.0, + rest_probability: float = 0.0, + pitch_diversity: float = 1.0, +) -> subsequence.melodic_state.MelodicState: + """Create a MelodicState with sensible test defaults.""" - return subsequence.melodic_state.MelodicState( - key=key, - mode=mode, - low=low, - high=high, - nir_strength=nir_strength, - chord_weight=chord_weight, - rest_probability=rest_probability, - pitch_diversity=pitch_diversity, - ) + return subsequence.melodic_state.MelodicState( + key=key, + mode=mode, + low=low, + high=high, + nir_strength=nir_strength, + chord_weight=chord_weight, + rest_probability=rest_probability, + pitch_diversity=pitch_diversity, + ) # --------------------------------------------------------------------------- # Construction # --------------------------------------------------------------------------- -class TestMelodicStateInit: - def test_pitch_pool_in_scale (self) -> None: - """Pitch pool should contain only scale tones within [low, high].""" - ms = _state(key="C", mode="ionian", low=60, high=72) +class TestMelodicStateInit: + def test_pitch_pool_in_scale(self) -> None: + """Pitch pool should contain only scale tones within [low, high].""" + ms = _state(key="C", mode="ionian", low=60, high=72) - # C major pitch classes: 0, 2, 4, 5, 7, 9, 11 - c_major_pcs = {0, 2, 4, 5, 7, 9, 11} + # C major pitch classes: 0, 2, 4, 5, 7, 9, 11 + c_major_pcs = {0, 2, 4, 5, 7, 9, 11} - for p in ms._pitch_pool: - assert 60 <= p <= 72 - assert p % 12 in c_major_pcs + for p in ms._pitch_pool: + assert 60 <= p <= 72 + assert p % 12 in c_major_pcs - def test_pitch_pool_respects_bounds (self) -> None: - """No pitch in the pool should fall outside [low, high].""" - ms = _state(low=48, high=60) + def test_pitch_pool_respects_bounds(self) -> None: + """No pitch in the pool should fall outside [low, high].""" + ms = _state(low=48, high=60) - for p in ms._pitch_pool: - assert 48 <= p <= 60 + for p in ms._pitch_pool: + assert 48 <= p <= 60 - def test_history_starts_empty (self) -> None: - """History should be empty on construction.""" - ms = _state() + def test_history_starts_empty(self) -> None: + """History should be empty on construction.""" + ms = _state() - assert ms.history == [] + assert ms.history == [] - def test_invalid_key_raises (self) -> None: - """Unknown key name should raise ValueError.""" - with pytest.raises(ValueError): - subsequence.melodic_state.MelodicState(key="X") + def test_invalid_key_raises(self) -> None: + """Unknown key name should raise ValueError.""" + with pytest.raises(ValueError): + subsequence.melodic_state.MelodicState(key="X") - def test_invalid_mode_raises (self) -> None: - """Unknown mode name should raise ValueError.""" - with pytest.raises(ValueError): - subsequence.melodic_state.MelodicState(mode="not_a_mode") + def test_invalid_mode_raises(self) -> None: + """Unknown mode name should raise ValueError.""" + with pytest.raises(ValueError): + subsequence.melodic_state.MelodicState(mode="not_a_mode") - def test_nir_strength_out_of_range_raises (self) -> None: - """nir_strength outside 0-1 should raise ValueError.""" - with pytest.raises(ValueError, match="NIR strength"): - _state(nir_strength=-0.1) + def test_nir_strength_out_of_range_raises(self) -> None: + """nir_strength outside 0-1 should raise ValueError.""" + with pytest.raises(ValueError, match="NIR strength"): + _state(nir_strength=-0.1) - with pytest.raises(ValueError, match="NIR strength"): - _state(nir_strength=1.1) + with pytest.raises(ValueError, match="NIR strength"): + _state(nir_strength=1.1) - def test_rest_probability_out_of_range_raises (self) -> None: - """rest_probability outside 0-1 should raise ValueError.""" - with pytest.raises(ValueError, match="Rest probability"): - _state(rest_probability=-0.1) + def test_rest_probability_out_of_range_raises(self) -> None: + """rest_probability outside 0-1 should raise ValueError.""" + with pytest.raises(ValueError, match="Rest probability"): + _state(rest_probability=-0.1) - with pytest.raises(ValueError, match="Rest probability"): - _state(rest_probability=1.1) + with pytest.raises(ValueError, match="Rest probability"): + _state(rest_probability=1.1) - def test_pitch_diversity_out_of_range_raises (self) -> None: - """pitch_diversity outside 0-1 should raise ValueError.""" - with pytest.raises(ValueError, match="Pitch diversity"): - _state(pitch_diversity=-0.1) + def test_pitch_diversity_out_of_range_raises(self) -> None: + """pitch_diversity outside 0-1 should raise ValueError.""" + with pytest.raises(ValueError, match="Pitch diversity"): + _state(pitch_diversity=-0.1) - with pytest.raises(ValueError, match="Pitch diversity"): - _state(pitch_diversity=1.1) + with pytest.raises(ValueError, match="Pitch diversity"): + _state(pitch_diversity=1.1) - def test_negative_chord_weight_raises (self) -> None: - """Negative chord_weight should raise ValueError.""" - with pytest.raises(ValueError, match="Chord weight"): - _state(chord_weight=-0.1) + def test_negative_chord_weight_raises(self) -> None: + """Negative chord_weight should raise ValueError.""" + with pytest.raises(ValueError, match="Chord weight"): + _state(chord_weight=-0.1) - def test_low_at_or_above_high_raises (self) -> None: - """A pitch range with low >= high should raise ValueError.""" - with pytest.raises(ValueError, match="low must be below high"): - _state(low=72, high=60) + def test_low_at_or_above_high_raises(self) -> None: + """A pitch range with low >= high should raise ValueError.""" + with pytest.raises(ValueError, match="low must be below high"): + _state(low=72, high=60) - with pytest.raises(ValueError, match="low must be below high"): - _state(low=60, high=60) + with pytest.raises(ValueError, match="low must be below high"): + _state(low=60, high=60) - def test_in_range_construction_succeeds (self) -> None: - """Boundary-valid parameters construct without error.""" - ms = subsequence.melodic_state.MelodicState( - key="C", - mode="ionian", - low=48, - high=72, - nir_strength=1.0, - chord_weight=0.0, - rest_probability=0.0, - pitch_diversity=1.0, - ) + def test_in_range_construction_succeeds(self) -> None: + """Boundary-valid parameters construct without error.""" + ms = subsequence.melodic_state.MelodicState( + key="C", + mode="ionian", + low=48, + high=72, + nir_strength=1.0, + chord_weight=0.0, + rest_probability=0.0, + pitch_diversity=1.0, + ) - assert ms.history == [] + assert ms.history == [] # --------------------------------------------------------------------------- # choose_next — basic behaviour # --------------------------------------------------------------------------- + class TestChooseNext: + def test_returns_pitch_in_pool(self) -> None: + """Chosen pitch must be a member of the pitch pool.""" + ms = _state() + rng = random.Random(42) - def test_returns_pitch_in_pool (self) -> None: - """Chosen pitch must be a member of the pitch pool.""" - ms = _state() - rng = random.Random(42) + for _ in range(20): + pitch = ms.choose_next(chord_tones=None, rng=rng) + assert pitch in ms._pitch_pool - for _ in range(20): - pitch = ms.choose_next(chord_tones=None, rng=rng) - assert pitch in ms._pitch_pool + def test_pitch_in_low_high_range(self) -> None: + """Chosen pitch must be within [low, high].""" + ms = _state(low=60, high=72) + rng = random.Random(99) - def test_pitch_in_low_high_range (self) -> None: - """Chosen pitch must be within [low, high].""" - ms = _state(low=60, high=72) - rng = random.Random(99) + for _ in range(20): + pitch = ms.choose_next(chord_tones=None, rng=rng) + assert 60 <= pitch <= 72 - for _ in range(20): - pitch = ms.choose_next(chord_tones=None, rng=rng) - assert 60 <= pitch <= 72 + def test_rest_probability_zero_never_rests(self) -> None: + """With rest_probability=0.0, choose_next should never return None.""" + ms = _state(rest_probability=0.0) + rng = random.Random(0) - def test_rest_probability_zero_never_rests (self) -> None: - """With rest_probability=0.0, choose_next should never return None.""" - ms = _state(rest_probability=0.0) - rng = random.Random(0) + results = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(50)] + assert None not in results - results = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(50)] - assert None not in results + def test_rest_probability_one_always_rests(self) -> None: + """With rest_probability=1.0, choose_next should always return None.""" + ms = _state(rest_probability=1.0) + rng = random.Random(0) - def test_rest_probability_one_always_rests (self) -> None: - """With rest_probability=1.0, choose_next should always return None.""" - ms = _state(rest_probability=1.0) - rng = random.Random(0) + results = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(20)] + assert all(r is None for r in results) - results = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(20)] - assert all(r is None for r in results) + def test_rest_probability_partial(self) -> None: + """With rest_probability=0.5, some but not all calls should return None.""" + ms = _state(rest_probability=0.5) + rng = random.Random(7) - def test_rest_probability_partial (self) -> None: - """With rest_probability=0.5, some but not all calls should return None.""" - ms = _state(rest_probability=0.5) - rng = random.Random(7) + results = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(100)] + rests = sum(1 for r in results if r is None) + notes = sum(1 for r in results if r is not None) - results = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(100)] - rests = sum(1 for r in results if r is None) - notes = sum(1 for r in results if r is not None) + assert rests > 0 + assert notes > 0 - assert rests > 0 - assert notes > 0 + def test_deterministic_with_same_seed(self) -> None: + """Same seed should produce the same sequence.""" - def test_deterministic_with_same_seed (self) -> None: - """Same seed should produce the same sequence.""" - def _run (seed: int) -> list: - ms = _state() - rng = random.Random(seed) - return [ms.choose_next(chord_tones=None, rng=rng) for _ in range(10)] + def _run(seed: int) -> list: + ms = _state() + rng = random.Random(seed) + return [ms.choose_next(chord_tones=None, rng=rng) for _ in range(10)] - assert _run(42) == _run(42) - assert _run(42) != _run(99) + assert _run(42) == _run(42) + assert _run(42) != _run(99) - def test_history_grows_and_caps_at_four (self) -> None: - """History should grow up to 4 entries then stay at 4.""" - ms = _state(rest_probability=0.0) - rng = random.Random(0) + def test_history_grows_and_caps_at_four(self) -> None: + """History should grow up to 4 entries then stay at 4.""" + ms = _state(rest_probability=0.0) + rng = random.Random(0) - for i in range(1, 7): - ms.choose_next(chord_tones=None, rng=rng) - assert len(ms.history) == min(i, 4) + for i in range(1, 7): + ms.choose_next(chord_tones=None, rng=rng) + assert len(ms.history) == min(i, 4) - def test_history_updates_with_chosen_pitch (self) -> None: - """History entries should equal the pitches returned by choose_next.""" - ms = _state(rest_probability=0.0) - rng = random.Random(0) + def test_history_updates_with_chosen_pitch(self) -> None: + """History entries should equal the pitches returned by choose_next.""" + ms = _state(rest_probability=0.0) + rng = random.Random(0) - pitches = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(4)] - assert ms.history == pitches + pitches = [ms.choose_next(chord_tones=None, rng=rng) for _ in range(4)] + assert ms.history == pitches - def test_rests_do_not_update_history (self) -> None: - """A rest (None) must not be appended to the history.""" - ms = _state(rest_probability=1.0) - rng = random.Random(0) + def test_rests_do_not_update_history(self) -> None: + """A rest (None) must not be appended to the history.""" + ms = _state(rest_probability=1.0) + rng = random.Random(0) - for _ in range(5): - ms.choose_next(chord_tones=None, rng=rng) + for _ in range(5): + ms.choose_next(chord_tones=None, rng=rng) - assert ms.history == [] + assert ms.history == [] # --------------------------------------------------------------------------- # _score_candidate — NIR rules # --------------------------------------------------------------------------- -class TestNIRScoring: - def test_rule_a_reversal_after_large_leap (self) -> None: - """After a large upward leap (>4 st), a downward step should score higher than upward continuation.""" - ms = _state(nir_strength=1.0) +class TestNIRScoring: + def test_rule_a_reversal_after_large_leap(self) -> None: + """After a large upward leap (>4 st), a downward step should score higher than upward continuation.""" + ms = _state(nir_strength=1.0) - # Implication: C4 (60) -> A4 (69) — leap of +9 semitones upward - ms.history = [60, 69] + # Implication: C4 (60) -> A4 (69) — leap of +9 semitones upward + ms.history = [60, 69] - # Reversal candidate: steps back down to G4 (67) — direction reversal, small interval - reversal = 67 # 69 -> 67: -2, reversed direction, small + # Reversal candidate: steps back down to G4 (67) — direction reversal, small interval + reversal = 67 # 69 -> 67: -2, reversed direction, small - # Continuation candidate: B4 (71) — same upward direction - continuation = 71 # 69 -> 71: +2, same direction + # Continuation candidate: B4 (71) — same upward direction + continuation = 71 # 69 -> 71: +2, same direction - score_reversal = ms._score_candidate(reversal, set()) - score_continuation = ms._score_candidate(continuation, set()) + score_reversal = ms._score_candidate(reversal, set()) + score_continuation = ms._score_candidate(continuation, set()) - assert score_reversal > score_continuation + assert score_reversal > score_continuation - def test_rule_b_process_after_small_step (self) -> None: - """After a small step, continuation in the same direction should score higher than reversal.""" - ms = _state(nir_strength=1.0) + def test_rule_b_process_after_small_step(self) -> None: + """After a small step, continuation in the same direction should score higher than reversal.""" + ms = _state(nir_strength=1.0) - # Implication: C4 (60) -> D4 (62) — step of +2 semitones upward - ms.history = [60, 62] + # Implication: C4 (60) -> D4 (62) — step of +2 semitones upward + ms.history = [60, 62] - # Continuation: E4 (64) — same upward direction, similar size - continuation = 64 # 62 -> 64: +2 + # Continuation: E4 (64) — same upward direction, similar size + continuation = 64 # 62 -> 64: +2 - # Reversal: Bb3 (58) — large downward jump - reversal = 58 # 62 -> 58: -4 + # Reversal: Bb3 (58) — large downward jump + reversal = 58 # 62 -> 58: -4 - score_continuation = ms._score_candidate(continuation, set()) - score_reversal = ms._score_candidate(reversal, set()) + score_continuation = ms._score_candidate(continuation, set()) + score_reversal = ms._score_candidate(reversal, set()) - assert score_continuation > score_reversal + assert score_continuation > score_reversal - def test_rule_c_tonic_closure_boost (self) -> None: - """The tonic pitch class should receive a closure boost over a non-tonic at same interval.""" - # Use a wide range [48, 84] so range gravity does not dominate: centre is 66. - # C4 (60) is 6 from centre; E4 (64) is 2 from centre — but C4 gets +0.2 tonic boost - # which outweighs the small extra range penalty at nir_strength=1.0. - ms = _state(key="C", mode="ionian", nir_strength=1.0, low=48, high=84) + def test_rule_c_tonic_closure_boost(self) -> None: + """The tonic pitch class should receive a closure boost over a non-tonic at same interval.""" + # Use a wide range [48, 84] so range gravity does not dominate: centre is 66. + # C4 (60) is 6 from centre; E4 (64) is 2 from centre — but C4 gets +0.2 tonic boost + # which outweighs the small extra range penalty at nir_strength=1.0. + ms = _state(key="C", mode="ionian", nir_strength=1.0, low=48, high=84) - # One note in history to trigger Rule C (no Rules A/B with one item) - ms.history = [62] # D4 + # One note in history to trigger Rule C (no Rules A/B with one item) + ms.history = [62] # D4 - # C4 (60) is the tonic — should get closure boost - tonic_c = 60 # D -> C: -2, tonic + # C4 (60) is the tonic — should get closure boost + tonic_c = 60 # D -> C: -2, tonic - # E4 (64) is non-tonic at same interval - non_tonic_e = 64 # D -> E: +2, non-tonic + # E4 (64) is non-tonic at same interval + non_tonic_e = 64 # D -> E: +2, non-tonic - score_tonic = ms._score_candidate(tonic_c, set()) - score_non_tonic = ms._score_candidate(non_tonic_e, set()) + score_tonic = ms._score_candidate(tonic_c, set()) + score_non_tonic = ms._score_candidate(non_tonic_e, set()) - assert score_tonic > score_non_tonic + assert score_tonic > score_non_tonic - def test_rule_d_proximity_boost (self) -> None: - """A close interval (<=3 st) should score higher than a leap from the same last note.""" - ms = _state(nir_strength=1.0) + def test_rule_d_proximity_boost(self) -> None: + """A close interval (<=3 st) should score higher than a leap from the same last note.""" + ms = _state(nir_strength=1.0) - # One note in history - ms.history = [60] # C4 + # One note in history + ms.history = [60] # C4 - # D4 (62) — interval 2, within proximity range - close = 62 + # D4 (62) — interval 2, within proximity range + close = 62 - # A4 (69) — interval 9, outside proximity range - far = 69 + # A4 (69) — interval 9, outside proximity range + far = 69 - score_close = ms._score_candidate(close, set()) - score_far = ms._score_candidate(far, set()) + score_close = ms._score_candidate(close, set()) + score_far = ms._score_candidate(far, set()) - assert score_close > score_far + assert score_close > score_far - def test_nir_strength_zero_no_boost (self) -> None: - """With nir_strength=0.0, a candidate at the range centre should score exactly 1.0.""" - # Range [60, 84]: centre = 72.0 exactly, so range_factor = 1.0 for C5 (72). - # History [60, 69] creates a large leap that would normally trigger Rule A, - # but nir_strength=0 cancels all NIR boosts. Diversity is also 1.0 (no history match). - ms = _state(key="C", mode="ionian", nir_strength=0.0, low=60, high=84, pitch_diversity=1.0) - ms.history = [60, 69] + def test_nir_strength_zero_no_boost(self) -> None: + """With nir_strength=0.0, a candidate at the range centre should score exactly 1.0.""" + # Range [60, 84]: centre = 72.0 exactly, so range_factor = 1.0 for C5 (72). + # History [60, 69] creates a large leap that would normally trigger Rule A, + # but nir_strength=0 cancels all NIR boosts. Diversity is also 1.0 (no history match). + ms = _state( + key="C", + mode="ionian", + nir_strength=0.0, + low=60, + high=84, + pitch_diversity=1.0, + ) + ms.history = [60, 69] - # C5 (72) is the range centre and the tonic — any NIR or closure boost would be cancelled. - score = ms._score_candidate(72, set()) + # C5 (72) is the range centre and the tonic — any NIR or closure boost would be cancelled. + score = ms._score_candidate(72, set()) - assert score == pytest.approx(1.0) + assert score == pytest.approx(1.0) - def test_no_history_returns_valid_score (self) -> None: - """With empty history, all candidates should receive a valid positive score.""" - ms = _state() + def test_no_history_returns_valid_score(self) -> None: + """With empty history, all candidates should receive a valid positive score.""" + ms = _state() - for p in ms._pitch_pool: - score = ms._score_candidate(p, set()) - assert score >= 0.0 + for p in ms._pitch_pool: + score = ms._score_candidate(p, set()) + assert score >= 0.0 # --------------------------------------------------------------------------- # _score_candidate — chord tone boost and other factors # --------------------------------------------------------------------------- -class TestChordToneBoost: - def test_chord_tone_scored_higher (self) -> None: - """A chord tone should score higher than a non-chord-tone at equal NIR distance.""" - ms = _state(key="C", mode="ionian", chord_weight=0.5, nir_strength=0.0) +class TestChordToneBoost: + def test_chord_tone_scored_higher(self) -> None: + """A chord tone should score higher than a non-chord-tone at equal NIR distance.""" + ms = _state(key="C", mode="ionian", chord_weight=0.5, nir_strength=0.0) - # D4 (62) is a chord tone (in C major this is the 2nd — but we treat it as an arbitrary chord tone) - chord_tone = 62 - non_chord = 64 # E4 + # D4 (62) is a chord tone (in C major this is the 2nd — but we treat it as an arbitrary chord tone) + chord_tone = 62 + non_chord = 64 # E4 - # Both are in C major scale; D is chord tone, E is not in this call - score_chord = ms._score_candidate(chord_tone, {62 % 12}) - score_non_chord = ms._score_candidate(non_chord, {62 % 12}) + # Both are in C major scale; D is chord tone, E is not in this call + score_chord = ms._score_candidate(chord_tone, {62 % 12}) + score_non_chord = ms._score_candidate(non_chord, {62 % 12}) - assert score_chord > score_non_chord + assert score_chord > score_non_chord - def test_chord_weight_zero_no_boost (self) -> None: - """With chord_weight=0.0, chord tones should not receive any bonus.""" - ms = _state(chord_weight=0.0, nir_strength=0.0) + def test_chord_weight_zero_no_boost(self) -> None: + """With chord_weight=0.0, chord tones should not receive any bonus.""" + ms = _state(chord_weight=0.0, nir_strength=0.0) - p = ms._pitch_pool[0] + p = ms._pitch_pool[0] - score_with = ms._score_candidate(p, {p % 12}) - score_without = ms._score_candidate(p, set()) + score_with = ms._score_candidate(p, {p % 12}) + score_without = ms._score_candidate(p, set()) - assert score_with == pytest.approx(score_without) + assert score_with == pytest.approx(score_without) class TestPitchDiversity: + def test_repeated_pitch_penalised(self) -> None: + """A pitch that appears in history should score lower than one that does not.""" + ms = _state(pitch_diversity=0.3, nir_strength=0.0, chord_weight=0.0) - def test_repeated_pitch_penalised (self) -> None: - """A pitch that appears in history should score lower than one that does not.""" - ms = _state(pitch_diversity=0.3, nir_strength=0.0, chord_weight=0.0) - - # Load history with the first pool pitch - repeated = ms._pitch_pool[0] - fresh = ms._pitch_pool[-1] - ms.history = [repeated] + # Load history with the first pool pitch + repeated = ms._pitch_pool[0] + fresh = ms._pitch_pool[-1] + ms.history = [repeated] - score_repeated = ms._score_candidate(repeated, set()) - score_fresh = ms._score_candidate(fresh, set()) + score_repeated = ms._score_candidate(repeated, set()) + score_fresh = ms._score_candidate(fresh, set()) - assert score_repeated < score_fresh + assert score_repeated < score_fresh - def test_diversity_one_no_penalty (self) -> None: - """With pitch_diversity=1.0, no penalty is applied for repeated pitches.""" - ms = _state(pitch_diversity=1.0, nir_strength=0.0, chord_weight=0.0) + def test_diversity_one_no_penalty(self) -> None: + """With pitch_diversity=1.0, no penalty is applied for repeated pitches.""" + ms = _state(pitch_diversity=1.0, nir_strength=0.0, chord_weight=0.0) - p = ms._pitch_pool[0] - ms.history = [p, p, p, p] + p = ms._pitch_pool[0] + ms.history = [p, p, p, p] - score_with_history = ms._score_candidate(p, set()) + score_with_history = ms._score_candidate(p, set()) - # Fresh state — no history - ms2 = _state(pitch_diversity=1.0, nir_strength=0.0, chord_weight=0.0) - score_no_history = ms2._score_candidate(p, set()) + # Fresh state — no history + ms2 = _state(pitch_diversity=1.0, nir_strength=0.0, chord_weight=0.0) + score_no_history = ms2._score_candidate(p, set()) - assert score_with_history == pytest.approx(score_no_history, rel=0.01) + assert score_with_history == pytest.approx(score_no_history, rel=0.01) class TestRangeGravity: - - def test_edge_pitch_penalised_vs_centre (self) -> None: - """A pitch at the edge of the range should score lower than one near the centre.""" - # C ionian over [60, 84]: centre = 72 = C5, which is in the scale. - # C4 (60) is at the low edge (distance 12 from centre, half_range=12 → penalty 0.3). - ms = _state(key="C", mode="ionian", low=60, high=84, nir_strength=0.0, chord_weight=0.0, pitch_diversity=1.0) - - # C5 (72) is the exact centre — no range penalty. - centre_pitch = 72 - - # C4 (60) is the bottom edge — maximum range penalty. - edge_pitch = 60 - - score_centre = ms._score_candidate(centre_pitch, set()) - score_edge = ms._score_candidate(edge_pitch, set()) - - assert score_centre > score_edge + def test_edge_pitch_penalised_vs_centre(self) -> None: + """A pitch at the edge of the range should score lower than one near the centre.""" + # C ionian over [60, 84]: centre = 72 = C5, which is in the scale. + # C4 (60) is at the low edge (distance 12 from centre, half_range=12 → penalty 0.3). + ms = _state( + key="C", + mode="ionian", + low=60, + high=84, + nir_strength=0.0, + chord_weight=0.0, + pitch_diversity=1.0, + ) + + # C5 (72) is the exact centre — no range penalty. + centre_pitch = 72 + + # C4 (60) is the bottom edge — maximum range penalty. + edge_pitch = 60 + + score_centre = ms._score_candidate(centre_pitch, set()) + score_edge = ms._score_candidate(edge_pitch, set()) + + assert score_centre > score_edge diff --git a/tests/test_melody_v2.py b/tests/test_melody_v2.py index d2688e0..1ffe341 100644 --- a/tests/test_melody_v2.py +++ b/tests/test_melody_v2.py @@ -32,428 +32,477 @@ # CSEG / CSIM kernels # --------------------------------------------------------------------------- -def test_cseg_ranks_the_shape () -> None: - """CSEG abstracts the contour from exact pitch; equal pitches share ranks.""" +def test_cseg_ranks_the_shape() -> None: + """CSEG abstracts the contour from exact pitch; equal pitches share ranks.""" - assert subsequence.sequence_utils.cseg([60, 67, 64]) == [0, 2, 1] - assert subsequence.sequence_utils.cseg([50, 59, 55]) == [0, 2, 1] - assert subsequence.sequence_utils.cseg([5, 5, 3]) == [1, 1, 0] - assert subsequence.sequence_utils.cseg([]) == [] + assert subsequence.sequence_utils.cseg([60, 67, 64]) == [0, 2, 1] + assert subsequence.sequence_utils.cseg([50, 59, 55]) == [0, 2, 1] + assert subsequence.sequence_utils.cseg([5, 5, 3]) == [1, 1, 0] + assert subsequence.sequence_utils.cseg([]) == [] -def test_csim_measures_shared_order_relations () -> None: +def test_csim_measures_shared_order_relations() -> None: + """1.0 for identical shapes; lower as pairwise relations disagree.""" - """1.0 for identical shapes; lower as pairwise relations disagree.""" + assert subsequence.sequence_utils.csim([60, 67, 64], [50, 59, 55]) == 1.0 + assert subsequence.sequence_utils.csim([1, 2, 3], [3, 2, 1]) == 0.0 + assert 0.0 < subsequence.sequence_utils.csim([1, 2, 3], [1, 3, 2]) < 1.0 - assert subsequence.sequence_utils.csim([60, 67, 64], [50, 59, 55]) == 1.0 - assert subsequence.sequence_utils.csim([1, 2, 3], [3, 2, 1]) == 0.0 - assert 0.0 < subsequence.sequence_utils.csim([1, 2, 3], [1, 3, 2]) < 1.0 - - with pytest.raises(ValueError, match="equal-length"): - subsequence.sequence_utils.csim([1, 2], [1, 2, 3]) + with pytest.raises(ValueError, match="equal-length"): + subsequence.sequence_utils.csim([1, 2], [1, 2, 3]) # --------------------------------------------------------------------------- # The factor list (the CHORAL split) # --------------------------------------------------------------------------- -def test_factors_are_pluggable () -> None: - - """Replacing the factor list reshapes the generator's taste.""" - state = subsequence.MelodicState(key="C", mode="ionian", low=60, high=72) +def test_factors_are_pluggable() -> None: + """Replacing the factor list reshapes the generator's taste.""" - # An iron fist: only pitch 64 scores. - state.factors = [lambda s, ctx: 1.0 if ctx.candidate == 64 else 0.0] + state = subsequence.MelodicState(key="C", mode="ionian", low=60, high=72) - for _ in range(10): - assert state.choose_next(None, random.Random(1)) == 64 + # An iron fist: only pitch 64 scores. + state.factors = [lambda s, ctx: 1.0 if ctx.candidate == 64 else 0.0] + for _ in range(10): + assert state.choose_next(None, random.Random(1)) == 64 -def test_contour_factor_pulls_toward_the_target () -> None: - """With a contour target threaded, candidates near it dominate.""" +def test_contour_factor_pulls_toward_the_target() -> None: + """With a contour target threaded, candidates near it dominate.""" - state = subsequence.MelodicState(key="C", mode="ionian", low=48, high=84, nir_strength=0.0) + state = subsequence.MelodicState( + key="C", mode="ionian", low=48, high=84, nir_strength=0.0 + ) - highs = [state.choose_next(None, random.Random(seed), contour_target=1.0) for seed in range(40)] - state.history.clear() - lows = [state.choose_next(None, random.Random(seed), contour_target=0.0) for seed in range(40)] + highs = [ + state.choose_next(None, random.Random(seed), contour_target=1.0) + for seed in range(40) + ] + state.history.clear() + lows = [ + state.choose_next(None, random.Random(seed), contour_target=0.0) + for seed in range(40) + ] - average = lambda values: sum(v for v in values if v) / len(values) - assert average(highs) - average(lows) > 12 # the envelope moves the register + average = lambda values: sum(v for v in values if v) / len(values) + assert average(highs) - average(lows) > 12 # the envelope moves the register -def test_tessitura_regression_pulls_home () -> None: +def test_tessitura_regression_pulls_home() -> None: + """After straying high, candidates moving back toward the centre are boosted.""" - """After straying high, candidates moving back toward the centre are boosted.""" + state = subsequence.MelodicState( + key="C", + mode="ionian", + low=48, + high=84, + tessitura_strength=1.0, + nir_strength=0.0, + ) + state.history = [83] # far above centre (66) - state = subsequence.MelodicState(key="C", mode="ionian", low=48, high=84, tessitura_strength=1.0, nir_strength=0.0) - state.history = [83] # far above centre (66) + ctx_home = subsequence.melodic_state.ScoringContext( + candidate=72, + history=(83,), + chord_tone_pcs=frozenset(), + tonic_pc=0, + low=48, + high=84, + ) + ctx_away = subsequence.melodic_state.ScoringContext( + candidate=84, + history=(83,), + chord_tone_pcs=frozenset(), + tonic_pc=0, + low=48, + high=84, + ) - ctx_home = subsequence.melodic_state.ScoringContext( - candidate=72, history=(83,), chord_tone_pcs=frozenset(), tonic_pc=0, low=48, high=84, - ) - ctx_away = subsequence.melodic_state.ScoringContext( - candidate=84, history=(83,), chord_tone_pcs=frozenset(), tonic_pc=0, low=48, high=84, - ) - - assert subsequence.melodic_state.tessitura_factor(state, ctx_home) > 1.0 - assert subsequence.melodic_state.tessitura_factor(state, ctx_away) == 1.0 + assert subsequence.melodic_state.tessitura_factor(state, ctx_home) > 1.0 + assert subsequence.melodic_state.tessitura_factor(state, ctx_away) == 1.0 # --------------------------------------------------------------------------- # MelodicState: clone, set_pool, deferred defaults # --------------------------------------------------------------------------- -def test_clone_is_independent () -> None: - - """A clone walks alone — the original's history never moves.""" - state = subsequence.MelodicState(key="A", mode="aeolian", low=57, high=81) - state.choose_next(None, random.Random(1)) +def test_clone_is_independent() -> None: + """A clone walks alone — the original's history never moves.""" - duplicate = state.clone() - duplicate.choose_next(None, random.Random(2)) - duplicate.choose_next(None, random.Random(3)) + state = subsequence.MelodicState(key="A", mode="aeolian", low=57, high=81) + state.choose_next(None, random.Random(1)) - assert len(state.history) == 1 - assert len(duplicate.history) == 3 - assert duplicate.history[0] == state.history[0] + duplicate = state.clone() + duplicate.choose_next(None, random.Random(2)) + duplicate.choose_next(None, random.Random(3)) + assert len(state.history) == 1 + assert len(duplicate.history) == 3 + assert duplicate.history[0] == state.history[0] -def test_set_pool_is_the_experimental_seam () -> None: - """An explicit pool replaces the scale entirely (sieves, hand-picked sets).""" +def test_set_pool_is_the_experimental_seam() -> None: + """An explicit pool replaces the scale entirely (sieves, hand-picked sets).""" - state = subsequence.MelodicState(key="C", mode="ionian") - state.set_pool([60, 61, 66]) + state = subsequence.MelodicState(key="C", mode="ionian") + state.set_pool([60, 61, 66]) - for seed in range(10): - assert state.choose_next(None, random.Random(seed)) in (60, 61, 66) + for seed in range(10): + assert state.choose_next(None, random.Random(seed)) in (60, 61, 66) - with pytest.raises(ValueError): - state.set_pool([]) + with pytest.raises(ValueError): + state.set_pool([]) -def test_melody_adopts_composition_key_and_scale () -> None: +def test_melody_adopts_composition_key_and_scale() -> None: + """A state built bare adopts p.key/p.scale on first use; explicit args win.""" - """A state built bare adopts p.key/p.scale on first use; explicit args win.""" + def build(state: subsequence.MelodicState) -> None: + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + key="E", + scale="phrygian", + rng=random.Random(1), + ) + builder.melody(state, spacing=1.0, seed=1) - def build (state: subsequence.MelodicState) -> None: - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, cycle=0, key="E", scale="phrygian", rng=random.Random(1), - ) - builder.melody(state, spacing=1.0, seed=1) + adopted = subsequence.MelodicState() + build(adopted) + assert adopted.key == "E" and adopted.mode == "phrygian" - adopted = subsequence.MelodicState() - build(adopted) - assert adopted.key == "E" and adopted.mode == "phrygian" - - explicit = subsequence.MelodicState(key="C", mode="ionian") - build(explicit) - assert explicit.key == "C" and explicit.mode == "ionian" + explicit = subsequence.MelodicState(key="C", mode="ionian") + build(explicit) + assert explicit.key == "C" and explicit.mode == "ionian" # --------------------------------------------------------------------------- # Motif.generate # --------------------------------------------------------------------------- -def test_generate_is_rhythm_first () -> None: - - """The onsets ARE the rhythm; length defaults to the next whole bar.""" - - hook = M.generate(rhythm=[0, 1, 1.5, 1.75, 2.5], seed=7) - - assert [event.beat for event in hook.events] == [0, 1, 1.5, 1.75, 2.5] - assert hook.length == 4.0 - assert all(isinstance(event.pitch, Degree) for event in hook.events) - assert hook.fit == 0.7 # generated motifs want to play against the changes +def test_generate_is_rhythm_first() -> None: + """The onsets ARE the rhythm; length defaults to the next whole bar.""" -def test_generate_borrows_a_motifs_rhythm () -> None: + hook = M.generate(rhythm=[0, 1, 1.5, 1.75, 2.5], seed=7) - """rhythm= takes another motif — cross-pattern rhythm reuse is shared values.""" + assert [event.beat for event in hook.events] == [0, 1, 1.5, 1.75, 2.5] + assert hook.length == 4.0 + assert all(isinstance(event.pitch, Degree) for event in hook.events) + assert hook.fit == 0.7 # generated motifs want to play against the changes - kick = M.hits("kick", beats=[0, 1.5, 3], length=4) - line = M.generate(rhythm=kick, seed=3) - assert [event.beat for event in line.events] == [0, 1.5, 3] +def test_generate_borrows_a_motifs_rhythm() -> None: + """rhythm= takes another motif — cross-pattern rhythm reuse is shared values.""" + kick = M.hits("kick", beats=[0, 1.5, 3], length=4) + line = M.generate(rhythm=kick, seed=3) -def test_generate_is_deterministic_and_warns_without_seed () -> None: + assert [event.beat for event in line.events] == [0, 1.5, 3] - """Same seed, same line; the generator seed policy applies.""" - assert M.generate(rhythm=[0, 1, 2, 3], seed=5) == M.generate(rhythm=[0, 1, 2, 3], seed=5) +def test_generate_is_deterministic_and_warns_without_seed() -> None: + """Same seed, same line; the generator seed policy applies.""" - with pytest.warns(UserWarning, match="seed"): - M.generate(rhythm=[0, 1]) + assert M.generate(rhythm=[0, 1, 2, 3], seed=5) == M.generate( + rhythm=[0, 1, 2, 3], seed=5 + ) + with pytest.warns(UserWarning, match="seed"): + M.generate(rhythm=[0, 1]) -def test_generate_end_on_and_pins () -> None: - """end_on pins the last note; pins fix any 1-based position (-1 = last).""" +def test_generate_end_on_and_pins() -> None: + """end_on pins the last note; pins fix any 1-based position (-1 = last).""" - line = M.generate(rhythm=[0, 1, 2, 3], end_on=1, pins={1: 5}, seed=9) + line = M.generate(rhythm=[0, 1, 2, 3], end_on=1, pins={1: 5}, seed=9) - first, last = line.events[0].pitch, line.events[-1].pitch - assert isinstance(first, Degree) and first.step == 5 - assert isinstance(last, Degree) and last.step == 1 and last.octave == 0 + first, last = line.events[0].pitch, line.events[-1].pitch + assert isinstance(first, Degree) and first.step == 5 + assert isinstance(last, Degree) and last.step == 1 and last.octave == 0 - with pytest.raises(ValueError, match="same position"): - M.generate(rhythm=[0, 1], end_on=1, pins={-1: 5}, seed=1) - with pytest.raises(ValueError, match="outside"): - M.generate(rhythm=[0, 1], pins={9: 1}, seed=1) + with pytest.raises(ValueError, match="same position"): + M.generate(rhythm=[0, 1], end_on=1, pins={-1: 5}, seed=1) + with pytest.raises(ValueError, match="outside"): + M.generate(rhythm=[0, 1], pins={9: 1}, seed=1) -def test_generate_contour_shapes_the_line () -> None: +def test_generate_contour_shapes_the_line() -> None: + """An ascending contour ends higher than it starts (statistically certain).""" - """An ascending contour ends higher than it starts (statistically certain).""" + def height(event: typing.Any) -> float: + return event.pitch.octave * 7 + event.pitch.step - def height (event: typing.Any) -> float: - return event.pitch.octave * 7 + event.pitch.step + rises = 0 + for seed in range(20): + line = M.generate(rhythm=list(range(8)), contour="ascending", seed=seed) + if height(line.events[-1]) > height(line.events[0]): + rises += 1 - rises = 0 - for seed in range(20): - line = M.generate(rhythm=list(range(8)), contour="ascending", seed=seed) - if height(line.events[-1]) > height(line.events[0]): - rises += 1 + assert rises >= 16 - assert rises >= 16 + with pytest.raises(ValueError, match="contour"): + M.generate(rhythm=[0, 1], contour="zigzag", seed=1) - with pytest.raises(ValueError, match="contour"): - M.generate(rhythm=[0, 1], contour="zigzag", seed=1) +def test_generate_scale_constrains_candidates() -> None: + """scale="minor_pentatonic" admits only pentatonic degrees (minor-family spelling).""" -def test_generate_scale_constrains_candidates () -> None: + line = M.generate( + rhythm=list(range(16)), length=16, scale="minor_pentatonic", seed=4 + ) - """scale="minor_pentatonic" admits only pentatonic degrees (minor-family spelling).""" + minor = subsequence.intervals.scale_pitch_classes(0, "minor") + pentatonic = set(subsequence.intervals.scale_pitch_classes(0, "minor_pentatonic")) - line = M.generate(rhythm=list(range(16)), length=16, scale="minor_pentatonic", seed=4) + for event in line.events: + offset = (minor[(event.pitch.step - 1) % 7] + event.pitch.chroma) % 12 + assert offset in pentatonic - minor = subsequence.intervals.scale_pitch_classes(0, "minor") - pentatonic = set(subsequence.intervals.scale_pitch_classes(0, "minor_pentatonic")) - for event in line.events: - offset = (minor[(event.pitch.step - 1) % 7] + event.pitch.chroma) % 12 - assert offset in pentatonic +def test_generate_max_pitches_caps_the_pool() -> None: + """A tight pool is a hook: at most N distinct pitches.""" + line = M.generate(rhythm=list(range(16)), length=16, max_pitches=3, seed=2) -def test_generate_max_pitches_caps_the_pool () -> None: + assert ( + len({(e.pitch.step, e.pitch.octave, e.pitch.chroma) for e in line.events}) <= 3 + ) - """A tight pool is a hook: at most N distinct pitches.""" - line = M.generate(rhythm=list(range(16)), length=16, max_pitches=3, seed=2) +def test_generate_midi_pool_is_absolute() -> None: + """An explicit MIDI pool switches to absolute output — the sieve path.""" - assert len({(e.pitch.step, e.pitch.octave, e.pitch.chroma) for e in line.events}) <= 3 + line = M.generate(rhythm=[0, 1, 2, 3], scale=[60, 61, 66, 70], seed=8) + assert all(isinstance(event.pitch, int) for event in line.events) + assert all(event.pitch in (60, 61, 66, 70) for event in line.events) -def test_generate_midi_pool_is_absolute () -> None: - """An explicit MIDI pool switches to absolute output — the sieve path.""" +def test_generate_copies_the_state() -> None: + """A module-level MelodicState is never mutated by building a value.""" - line = M.generate(rhythm=[0, 1, 2, 3], scale=[60, 61, 66, 70], seed=8) + state = subsequence.MelodicState(key="A", mode="aeolian", low=57, high=81) + state.history = [69, 72] - assert all(isinstance(event.pitch, int) for event in line.events) - assert all(event.pitch in (60, 61, 66, 70) for event in line.events) + before = list(state.history) + M.generate(rhythm=[0, 1, 2, 3], state=state, seed=6) + assert state.history == before -def test_generate_copies_the_state () -> None: - """A module-level MelodicState is never mutated by building a value.""" +def test_generate_ignores_state_rest_probability() -> None: + """generate is rhythm-first: a state's rest_probability never stalls the walk. - state = subsequence.MelodicState(key="A", mode="aeolian", low=57, high=81) - state.history = [69, 72] + Regression: a supplied MelodicState with rest_probability > 0 made + choose_next return None, and generate fell back to a fixed pool note — + producing a stuck, repeated degree instead of a walked line. + """ - before = list(state.history) - M.generate(rhythm=[0, 1, 2, 3], state=state, seed=6) + state = subsequence.MelodicState( + key="C", mode="minor", low=48, high=72, rest_probability=0.9 + ) - assert state.history == before + value = M.generate(rhythm=[0, 1, 2, 3, 4, 5, 6, 7], state=state, seed=2) - -def test_generate_ignores_state_rest_probability () -> None: - - """generate is rhythm-first: a state's rest_probability never stalls the walk. - - Regression: a supplied MelodicState with rest_probability > 0 made - choose_next return None, and generate fell back to a fixed pool note — - producing a stuck, repeated degree instead of a walked line. - """ - - state = subsequence.MelodicState(key="C", mode="minor", low=48, high=72, rest_probability=0.9) - - value = M.generate(rhythm=[0, 1, 2, 3, 4, 5, 6, 7], state=state, seed=2) - - distinct = {(e.pitch.step, e.pitch.octave) for e in value.events} - assert len(distinct) > 1 # a real line, not a single stuck note - assert state.rest_probability == 0.9 # the caller's state is left untouched + distinct = {(e.pitch.step, e.pitch.octave) for e in value.events} + assert len(distinct) > 1 # a real line, not a single stuck note + assert state.rest_probability == 0.9 # the caller's state is left untouched # --------------------------------------------------------------------------- # vary(keep_contour=True) # --------------------------------------------------------------------------- -def test_keep_contour_preserves_the_cseg () -> None: - - """Varied lines keep their shape — the motif-identity guard.""" - line = subsequence.motif([1, 5, 3, 7, 2, 6]) - ranks = lambda m: subsequence.sequence_utils.cseg([e.pitch.step for e in m.events]) +def test_keep_contour_preserves_the_cseg() -> None: + """Varied lines keep their shape — the motif-identity guard.""" - for seed in range(20): - varied = line.vary(notes=3, position="anywhere", seed=seed, keep_contour=True) - assert ranks(varied) == ranks(line) + line = subsequence.motif([1, 5, 3, 7, 2, 6]) + ranks = lambda m: subsequence.sequence_utils.cseg([e.pitch.step for e in m.events]) + for seed in range(20): + varied = line.vary(notes=3, position="anywhere", seed=seed, keep_contour=True) + assert ranks(varied) == ranks(line) -def test_keep_contour_yields_where_shape_forbids_motion () -> None: - """When no nudge preserves the contour, the note stays — shape wins.""" +def test_keep_contour_yields_where_shape_forbids_motion() -> None: + """When no nudge preserves the contour, the note stays — shape wins.""" - # Adjacent steps everywhere: most nudges break the order relations. - tight = subsequence.motif([1, 2, 3]) - varied = tight.vary(notes=3, position="anywhere", seed=3, keep_contour=True) + # Adjacent steps everywhere: most nudges break the order relations. + tight = subsequence.motif([1, 2, 3]) + varied = tight.vary(notes=3, position="anywhere", seed=3, keep_contour=True) - ranks = lambda m: subsequence.sequence_utils.cseg([e.pitch.step for e in m.events]) - assert ranks(varied) == ranks(tight) + ranks = lambda m: subsequence.sequence_utils.cseg([e.pitch.step for e in m.events]) + assert ranks(varied) == ranks(tight) # --------------------------------------------------------------------------- # Approach resolution + the fit dial (placement) # --------------------------------------------------------------------------- -class _StubHarmony: - - """A HarmonyView stand-in with a chord change at a known beat.""" - - def __init__ (self, current: typing.Any, following: typing.Any, change_at: float = 4.0) -> None: - self._current = current - self._following = following - self._change_at = change_at - - def chord_at (self, beat: float) -> typing.Any: - return self._current if beat < self._change_at else self._following - def next_chord_at (self, beat: float) -> typing.Any: - return self._following if beat < self._change_at else None - - -def _builder (harmony: typing.Any = None, length: float = 8.0, seed: int = 1) -> subsequence.pattern_builder.PatternBuilder: - - """A standalone builder over a fresh pattern.""" +class _StubHarmony: + """A HarmonyView stand-in with a chord change at a known beat.""" - pattern = subsequence.pattern.Pattern(channel=0, length=length) + def __init__( + self, current: typing.Any, following: typing.Any, change_at: float = 4.0 + ) -> None: + self._current = current + self._following = following + self._change_at = change_at - return subsequence.pattern_builder.PatternBuilder( - pattern=pattern, cycle=0, key="A", scale="minor", rng=random.Random(seed), harmony=harmony, - ) + def chord_at(self, beat: float) -> typing.Any: + return self._current if beat < self._change_at else self._following + def next_chord_at(self, beat: float) -> typing.Any: + return self._following if beat < self._change_at else None -def _placed (p: subsequence.pattern_builder.PatternBuilder) -> typing.List[typing.Tuple[float, int]]: - out = [] - for pulse in sorted(p._pattern.steps): - for note in p._pattern.steps[pulse].notes: - out.append((pulse / subsequence.constants.MIDI_QUARTER_NOTE, note.pitch)) - return out +def _builder( + harmony: typing.Any = None, length: float = 8.0, seed: int = 1 +) -> subsequence.pattern_builder.PatternBuilder: + """A standalone builder over a fresh pattern.""" + pattern = subsequence.pattern.Pattern(channel=0, length=length) -def test_approach_lands_a_semitone_below_the_next_chords_tone () -> None: + return subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + key="A", + scale="minor", + rng=random.Random(seed), + harmony=harmony, + ) - """Approach(ChordTone) reads the chord AFTER the event — the landing harmony.""" - a_minor = subsequence.chords.parse_chord("Am") - f_major = subsequence.chords.parse_chord("F") - view = _StubHarmony(a_minor, f_major, change_at=4.0) +def _placed( + p: subsequence.pattern_builder.PatternBuilder, +) -> typing.List[typing.Tuple[float, int]]: + out = [] + for pulse in sorted(p._pattern.steps): + for note in p._pattern.steps[pulse].notes: + out.append((pulse / subsequence.constants.MIDI_QUARTER_NOTE, note.pitch)) + return out - p = _builder(harmony=view) - p.motif(M.from_events([ - subsequence.MotifEvent(beat=3.5, pitch=Approach(ChordTone("root"))), - ], length=8), root=60) - # F's root nearest 60 is 65; the approach is 64 — into the change. - assert _placed(p) == [(3.5, 64)] +def test_approach_lands_a_semitone_below_the_next_chords_tone() -> None: + """Approach(ChordTone) reads the chord AFTER the event — the landing harmony.""" + a_minor = subsequence.chords.parse_chord("Am") + f_major = subsequence.chords.parse_chord("F") + view = _StubHarmony(a_minor, f_major, change_at=4.0) -def test_fit_snaps_strong_beats_to_chord_tones () -> None: + p = _builder(harmony=view) + p.motif( + M.from_events( + [ + subsequence.MotifEvent(beat=3.5, pitch=Approach(ChordTone("root"))), + ], + length=8, + ), + root=60, + ) - """fit=1.0: every strong-beat degree lands on a chord tone; offbeats are free.""" + # F's root nearest 60 is 65; the approach is 64 — into the change. + assert _placed(p) == [(3.5, 64)] - a_minor = subsequence.chords.parse_chord("Am") - view = _StubHarmony(a_minor, a_minor, change_at=99.0) - chord_pcs = {9, 0, 4} - line = M.degrees([2, 2, 2, 2], beats=[0.0, 1.0, 2.0, 3.0], length=4) +def test_fit_snaps_strong_beats_to_chord_tones() -> None: + """fit=1.0: every strong-beat degree lands on a chord tone; offbeats are free.""" - snapped = _builder(harmony=view) - snapped.motif(line, root=60, fit=1.0) - for beat, pitch in _placed(snapped): - assert pitch % 12 in chord_pcs # B (degree 2) pulled to a neighbouring chord tone + a_minor = subsequence.chords.parse_chord("Am") + view = _StubHarmony(a_minor, a_minor, change_at=99.0) + chord_pcs = {9, 0, 4} - free = _builder(harmony=view) - free.motif(line, root=60, fit=0.0) - assert all(pitch % 12 == 11 for _, pitch in _placed(free)) # untouched + line = M.degrees([2, 2, 2, 2], beats=[0.0, 1.0, 2.0, 3.0], length=4) + snapped = _builder(harmony=view) + snapped.motif(line, root=60, fit=1.0) + for beat, pitch in _placed(snapped): + assert ( + pitch % 12 in chord_pcs + ) # B (degree 2) pulled to a neighbouring chord tone -def test_fit_defaults_split_by_intent () -> None: + free = _builder(harmony=view) + free.motif(line, root=60, fit=0.0) + assert all(pitch % 12 == 11 for _, pitch in _placed(free)) # untouched - """Hand-written degrees stay sacred (no snap); generated motifs carry 0.7.""" - a_minor = subsequence.chords.parse_chord("Am") - view = _StubHarmony(a_minor, a_minor, change_at=99.0) +def test_fit_defaults_split_by_intent() -> None: + """Hand-written degrees stay sacred (no snap); generated motifs carry 0.7.""" - hand = _builder(harmony=view) - hand.motif(M.degrees([2, 2, 2, 2], beats=[0.0, 1.0, 2.0, 3.0], length=4), root=60) - assert all(pitch % 12 == 11 for _, pitch in _placed(hand)) # typed degrees untouched + a_minor = subsequence.chords.parse_chord("Am") + view = _StubHarmony(a_minor, a_minor, change_at=99.0) - generated = M.generate(rhythm=[0, 1, 2, 3], seed=1) - assert generated.fit == 0.7 # the default rides the value into placement + hand = _builder(harmony=view) + hand.motif(M.degrees([2, 2, 2, 2], beats=[0.0, 1.0, 2.0, 3.0], length=4), root=60) + assert all( + pitch % 12 == 11 for _, pitch in _placed(hand) + ) # typed degrees untouched + generated = M.generate(rhythm=[0, 1, 2, 3], seed=1) + assert generated.fit == 0.7 # the default rides the value into placement -def test_fit_inactive_without_a_chord_context () -> None: - """No harmony, no snap — fit degrades gracefully.""" +def test_fit_inactive_without_a_chord_context() -> None: + """No harmony, no snap — fit degrades gracefully.""" - p = _builder(harmony=None) - p.motif(M.degrees([2, 2], beats=[0.0, 1.0], length=2), root=60, fit=1.0) + p = _builder(harmony=None) + p.motif(M.degrees([2, 2], beats=[0.0, 1.0], length=2), root=60, fit=1.0) - assert all(pitch % 12 == 11 for _, pitch in _placed(p)) + assert all(pitch % 12 == 11 for _, pitch in _placed(p)) # --------------------------------------------------------------------------- # Sketch (f)'s hook — the generate line, end to end # --------------------------------------------------------------------------- -def test_sketch_f_hook_generates_develops_and_places (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """The hook line from sketch (f): generate → develop → bind → sound.""" - - import mido - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="A", scale="minor", seed=42) - composition.harmony(progression=["Am", "F", "C", "G"]) - composition.form([("verse", 8)]) - - hook = subsequence.Motif.generate( - rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", - contour="arch", end_on=1, seed=composition.seed_for("hook"), - ) - verse_line = subsequence.Phrase.develop(hook, bars=8, plan="call_response", - seed=composition.seed_for("verse_line")) - - composition.section_motifs("verse", verse_line, part="lead") - composition.phrase_part(channel=4, root=78, part="lead") - - filename = str(tmp_path / "hook.mid") - composition.render(bars=8, filename=filename) - - mid = mido.MidiFile(filename) - note_ons = [ - msg for track in mid.tracks for msg in track - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0 - ] - # 4 call_response units x 5 hook onsets, walked bar by bar. - assert len(note_ons) >= 20 +def test_sketch_f_hook_generates_develops_and_places( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """The hook line from sketch (f): generate → develop → bind → sound.""" + + import mido + + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="A", scale="minor", seed=42 + ) + composition.harmony(progression=["Am", "F", "C", "G"]) + composition.form([("verse", 8)]) + + hook = subsequence.Motif.generate( + rhythm=[0, 1, 1.5, 1.75, 2.5], + scale="minor_pentatonic", + contour="arch", + end_on=1, + seed=composition.seed_for("hook"), + ) + verse_line = subsequence.Phrase.develop( + hook, bars=8, plan="call_response", seed=composition.seed_for("verse_line") + ) + + composition.section_motifs("verse", verse_line, part="lead") + composition.phrase_part(channel=4, root=78, part="lead") + + filename = str(tmp_path / "hook.mid") + composition.render(bars=8, filename=filename) + + mid = mido.MidiFile(filename) + note_ons = [ + msg + for track in mid.tracks + for msg in track + if not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + ] + + # 4 call_response units x 5 hook onsets, walked bar by bar. + assert len(note_ons) >= 20 diff --git a/tests/test_midi_helpers.py b/tests/test_midi_helpers.py index b1eeef7..123bd20 100644 --- a/tests/test_midi_helpers.py +++ b/tests/test_midi_helpers.py @@ -4,65 +4,65 @@ import subsequence.midi as midi -def test_cc_returns_control_change () -> None: - msg = midi.cc(74, 100) - assert msg.type == 'control_change' - assert msg.control == 74 - assert msg.value == 100 - assert msg.channel == 0 +def test_cc_returns_control_change() -> None: + msg = midi.cc(74, 100) + assert msg.type == "control_change" + assert msg.control == 74 + assert msg.value == 100 + assert msg.channel == 0 -def test_cc_custom_channel () -> None: - msg = midi.cc(1, 64, channel=3) - assert msg.channel == 3 +def test_cc_custom_channel() -> None: + msg = midi.cc(1, 64, channel=3) + assert msg.channel == 3 -def test_out_of_range_cc_value_raises () -> None: - # Values are NOT clamped: mido rejects out-of-range data bytes, and the - # factory lets that ValueError propagate. - with pytest.raises(ValueError): - midi.cc(74, 200) +def test_out_of_range_cc_value_raises() -> None: + # Values are NOT clamped: mido rejects out-of-range data bytes, and the + # factory lets that ValueError propagate. + with pytest.raises(ValueError): + midi.cc(74, 200) -def test_pitchwheel_returns_pitchwheel () -> None: - msg = midi.pitchwheel(0) - assert msg.type == 'pitchwheel' - assert msg.pitch == 0 - assert msg.channel == 0 +def test_pitchwheel_returns_pitchwheel() -> None: + msg = midi.pitchwheel(0) + assert msg.type == "pitchwheel" + assert msg.pitch == 0 + assert msg.channel == 0 -def test_pitchwheel_min () -> None: - msg = midi.pitchwheel(-8192) - assert msg.pitch == -8192 +def test_pitchwheel_min() -> None: + msg = midi.pitchwheel(-8192) + assert msg.pitch == -8192 -def test_pitchwheel_max () -> None: - msg = midi.pitchwheel(8191) - assert msg.pitch == 8191 +def test_pitchwheel_max() -> None: + msg = midi.pitchwheel(8191) + assert msg.pitch == 8191 -def test_pitchwheel_custom_channel () -> None: - msg = midi.pitchwheel(1000, channel=2) - assert msg.channel == 2 +def test_pitchwheel_custom_channel() -> None: + msg = midi.pitchwheel(1000, channel=2) + assert msg.channel == 2 -def test_program_change_returns_program_change () -> None: - msg = midi.program_change(10) - assert msg.type == 'program_change' - assert msg.program == 10 - assert msg.channel == 0 +def test_program_change_returns_program_change() -> None: + msg = midi.program_change(10) + assert msg.type == "program_change" + assert msg.program == 10 + assert msg.channel == 0 -def test_program_change_custom_channel () -> None: - msg = midi.program_change(0, channel=1) - assert msg.channel == 1 +def test_program_change_custom_channel() -> None: + msg = midi.program_change(0, channel=1) + assert msg.channel == 1 -def test_cc_usable_as_cc_forward_transform () -> None: - """Verify the factory is usable as a cc_forward lambda.""" - transform = lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch) - result = transform(127, 0) - assert result.type == 'control_change' - assert result.control == 74 - assert result.value == 100 - assert result.channel == 0 +def test_cc_usable_as_cc_forward_transform() -> None: + """Verify the factory is usable as a cc_forward lambda.""" + transform = lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch) + result = transform(127, 0) + assert result.type == "control_change" + assert result.control == 74 + assert result.value == 100 + assert result.channel == 0 diff --git a/tests/test_midi_input.py b/tests/test_midi_input.py index be24bff..6889e7b 100644 --- a/tests/test_midi_input.py +++ b/tests/test_midi_input.py @@ -13,375 +13,352 @@ # --- Sequencer input device configuration --- -def test_sequencer_accepts_input_device (patch_midi: None) -> None: +def test_sequencer_accepts_input_device(patch_midi: None) -> None: + """Sequencer should store the input device name when provided.""" - """Sequencer should store the input device name when provided.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + assert seq.input_device_name == "Dummy MIDI" + assert seq.clock_follow is True - assert seq.input_device_name == "Dummy MIDI" - assert seq.clock_follow is True +def test_clock_follow_without_input_raises(patch_midi: None) -> None: + """clock_follow=True without an input device should raise ValueError.""" -def test_clock_follow_without_input_raises (patch_midi: None) -> None: + with pytest.raises(ValueError): + subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120, clock_follow=True + ) - """clock_follow=True without an input device should raise ValueError.""" - with pytest.raises(ValueError): - subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - clock_follow=True - ) +def test_sequencer_no_input_by_default(patch_midi: None) -> None: + """Sequencer should have no MIDI input by default.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) -def test_sequencer_no_input_by_default (patch_midi: None) -> None: - - """Sequencer should have no MIDI input by default.""" - - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120 - ) - - assert seq.input_device_name is None - assert seq.clock_follow is False - assert seq.midi_in is None + assert seq.input_device_name is None + assert seq.clock_follow is False + assert seq.midi_in is None @pytest.mark.asyncio -async def test_sequencer_opens_input_port (patch_midi: None) -> None: +async def test_sequencer_opens_input_port(patch_midi: None) -> None: + """Starting a sequencer with input_device_name should open the MIDI input port.""" - """Starting a sequencer with input_device_name should open the MIDI input port.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120, input_device_name="Dummy MIDI" + ) - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI" - ) + await seq.start() - await seq.start() + assert seq.midi_in is not None + assert isinstance(seq.midi_in, conftest.FakeMidiIn) - assert seq.midi_in is not None - assert isinstance(seq.midi_in, conftest.FakeMidiIn) - - seq.running = False - await seq.task - await seq.stop() + seq.running = False + await seq.task + await seq.stop() # --- Clock follow - - pulse counting --- @pytest.mark.asyncio -async def test_clock_follow_advances_pulses (patch_midi: None) -> None: - - """Each MIDI clock tick should advance pulse_count by one.""" +async def test_clock_follow_advances_pulses(patch_midi: None) -> None: + """Each MIDI clock tick should advance pulse_count by one.""" - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - await seq.start() + await seq.start() - # Inject a start message to begin counting. - seq._midi_input_queue.put_nowait((0, mido.Message("start"))) + # Inject a start message to begin counting. + seq._midi_input_queue.put_nowait((0, mido.Message("start"))) - # Inject 24 clock ticks (= 1 beat). - for _ in range(24): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + # Inject 24 clock ticks (= 1 beat). + for _ in range(24): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - # Inject stop to end the loop. - seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) + # Inject stop to end the loop. + seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) - await seq.task + await seq.task - assert seq.pulse_count == 24 + assert seq.pulse_count == 24 - await seq.stop() + await seq.stop() @pytest.mark.asyncio -async def test_clock_follow_waits_for_start (patch_midi: None) -> None: +async def test_clock_follow_waits_for_start(patch_midi: None) -> None: + """Clock ticks before a start message should be ignored.""" - """Clock ticks before a start message should be ignored.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + await seq.start() - await seq.start() + # Send clock ticks without a start - should be ignored. + for _ in range(10): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - # Send clock ticks without a start - should be ignored. - for _ in range(10): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + # Now send start + 5 more ticks. + seq._midi_input_queue.put_nowait((0, mido.Message("start"))) - # Now send start + 5 more ticks. - seq._midi_input_queue.put_nowait((0, mido.Message("start"))) + for _ in range(5): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - for _ in range(5): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) - seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) + await seq.task - await seq.task + # Only the 5 ticks after start should have been counted. + assert seq.pulse_count == 5 - # Only the 5 ticks after start should have been counted. - assert seq.pulse_count == 5 - - await seq.stop() + await seq.stop() # --- Transport messages --- @pytest.mark.asyncio -async def test_transport_start_resets_position (patch_midi: None) -> None: - - """MIDI start should reset pulse_count to 0.""" +async def test_transport_start_resets_position(patch_midi: None) -> None: + """MIDI start should reset pulse_count to 0.""" - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - await seq.start() + await seq.start() - # Start, count some ticks, then start again (resets). - seq._midi_input_queue.put_nowait((0, mido.Message("start"))) + # Start, count some ticks, then start again (resets). + seq._midi_input_queue.put_nowait((0, mido.Message("start"))) - for _ in range(48): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + for _ in range(48): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - # Second start - resets pulse_count. - seq._midi_input_queue.put_nowait((0, mido.Message("start"))) + # Second start - resets pulse_count. + seq._midi_input_queue.put_nowait((0, mido.Message("start"))) - for _ in range(10): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + for _ in range(10): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) + seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) - await seq.task + await seq.task - # Only 10 ticks since the last start. - assert seq.pulse_count == 10 + # Only 10 ticks since the last start. + assert seq.pulse_count == 10 - await seq.stop() + await seq.stop() @pytest.mark.asyncio -async def test_transport_stop_halts_sequencer (patch_midi: None) -> None: +async def test_transport_stop_halts_sequencer(patch_midi: None) -> None: + """MIDI stop should set running to False.""" - """MIDI stop should set running to False.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + await seq.start() - await seq.start() + assert seq.running is True - assert seq.running is True + seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) - seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) + await seq.task - await seq.task + assert seq.running is False - assert seq.running is False - - await seq.stop() + await seq.stop() @pytest.mark.asyncio -async def test_transport_continue_resumes (patch_midi: None) -> None: - - """MIDI continue should resume from the current position, not restart at zero.""" +async def test_transport_continue_resumes(patch_midi: None) -> None: + """MIDI continue should resume from the current position, not restart at zero.""" - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - await seq.start() + await seq.start() - # Clocks before any start/continue are ignored (position stays 0). - for _ in range(8): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + # Clocks before any start/continue are ignored (position stays 0). + for _ in range(8): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - # Drive the sequencer to a non-zero position. - seq._midi_input_queue.put_nowait((0, mido.Message("start"))) + # Drive the sequencer to a non-zero position. + seq._midi_input_queue.put_nowait((0, mido.Message("start"))) - for _ in range(24): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + for _ in range(24): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - # Continue must preserve the position (start resets it — see - # test_transport_start_resets_position above). - seq._midi_input_queue.put_nowait((0, mido.Message("continue"))) + # Continue must preserve the position (start resets it — see + # test_transport_start_resets_position above). + seq._midi_input_queue.put_nowait((0, mido.Message("continue"))) - for _ in range(12): - seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) + for _ in range(12): + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) - seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) + seq._midi_input_queue.put_nowait((0, mido.Message("stop"))) - await seq.task + await seq.task - # 24 ticks before continue + 12 after: resumed from pulse 24, not 0. - assert seq.pulse_count == 36 + # 24 ticks before continue + 12 after: resumed from pulse 24, not 0. + assert seq.pulse_count == 36 - await seq.stop() + await seq.stop() # --- BPM estimation --- -def test_bpm_estimation (patch_midi: None) -> None: +def test_bpm_estimation(patch_midi: None) -> None: + """Feeding clock ticks at known intervals should produce a correct BPM estimate.""" - """Feeding clock ticks at known intervals should produce a correct BPM estimate.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + # Simulate 120 BPM: 24 ticks per beat, 0.5s per beat → ~0.02083s per tick. + tick_interval = 0.5 / 24 # 120 BPM - # Simulate 120 BPM: 24 ticks per beat, 0.5s per beat → ~0.02083s per tick. - tick_interval = 0.5 / 24 # 120 BPM + base_time = 100.0 - base_time = 100.0 + for i in range(48): + seq._estimate_bpm(base_time + i * tick_interval) - for i in range(48): - seq._estimate_bpm(base_time + i * tick_interval) - - # Should estimate close to 120 BPM. - assert abs(seq.current_bpm - 120) <= 1 + # Should estimate close to 120 BPM. + assert abs(seq.current_bpm - 120) <= 1 # --- set_bpm in clock_follow mode --- @pytest.mark.asyncio -async def test_set_bpm_noop_in_clock_follow (patch_midi: None) -> None: - - """set_bpm() should have no effect when clock_follow is enabled and running.""" +async def test_set_bpm_noop_in_clock_follow(patch_midi: None) -> None: + """set_bpm() should have no effect when clock_follow is enabled and running.""" - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Dummy MIDI", - clock_follow=True - ) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Dummy MIDI", + clock_follow=True, + ) - # Before start, set_bpm works (needed for initial setup). - seq.set_bpm(100) - assert seq.current_bpm == 100 + # Before start, set_bpm works (needed for initial setup). + seq.set_bpm(100) + assert seq.current_bpm == 100 - # Start the sequencer - now set_bpm should be ignored. - await seq.start() + # Start the sequencer - now set_bpm should be ignored. + await seq.start() - seq.set_bpm(200) - assert seq.current_bpm == 100 # Unchanged + seq.set_bpm(200) + assert seq.current_bpm == 100 # Unchanged - seq.running = False - await seq.task - await seq.stop() + seq.running = False + await seq.task + await seq.stop() # --- Composition.midi_input() --- -def test_composition_midi_input_method (patch_midi: None) -> None: - - """midi_input() should store the input device name and clock_follow flag.""" - - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - - comp.midi_input(device="Dummy MIDI", clock_follow=True) +def test_composition_midi_input_method(patch_midi: None) -> None: + """midi_input() should store the input device name and clock_follow flag.""" - assert comp._input_device == "Dummy MIDI" - assert comp._clock_follow is True + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + comp.midi_input(device="Dummy MIDI", clock_follow=True) -def test_composition_midi_input_default_no_clock (patch_midi: None) -> None: + assert comp._input_device == "Dummy MIDI" + assert comp._clock_follow is True - """midi_input() without clock_follow should default to False.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) +def test_composition_midi_input_default_no_clock(patch_midi: None) -> None: + """midi_input() without clock_follow should default to False.""" - comp.midi_input(device="Dummy MIDI") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - assert comp._input_device == "Dummy MIDI" - assert comp._clock_follow is False + comp.midi_input(device="Dummy MIDI") + assert comp._input_device == "Dummy MIDI" + assert comp._clock_follow is False -def test_live_info_includes_input_fields (patch_midi: None) -> None: - """live_info() should include input_device and clock_follow.""" +def test_live_info_includes_input_fields(patch_midi: None) -> None: + """live_info() should include input_device and clock_follow.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - info = comp.live_info() + info = comp.live_info() - assert info["input_device"] is None - assert info["clock_follow"] is False + assert info["input_device"] is None + assert info["clock_follow"] is False -def test_live_info_with_midi_input (patch_midi: None) -> None: +def test_live_info_with_midi_input(patch_midi: None) -> None: + """live_info() should reflect midi_input() configuration.""" - """live_info() should reflect midi_input() configuration.""" + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + comp.midi_input(device="Dummy MIDI", clock_follow=True) - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - comp.midi_input(device="Dummy MIDI", clock_follow=True) + info = comp.live_info() - info = comp.live_info() - - assert info["input_device"] == "Dummy MIDI" - assert info["clock_follow"] is True + assert info["input_device"] == "Dummy MIDI" + assert info["clock_follow"] is True # --- select_input_device --- -def test_select_input_device_none_returns_none (patch_midi: None) -> None: - - """No device name means no input — (None, None) without prompting.""" - - name, port = subsequence.midi_utils.select_input_device(None) - - assert name is None - assert port is None +def test_select_input_device_none_returns_none(patch_midi: None) -> None: + """No device name means no input — (None, None) without prompting.""" + name, port = subsequence.midi_utils.select_input_device(None) -def test_select_input_device_opens_named_device (patch_midi: None) -> None: + assert name is None + assert port is None - """A named device that exists is opened directly.""" - name, port = subsequence.midi_utils.select_input_device("Dummy MIDI") +def test_select_input_device_opens_named_device(patch_midi: None) -> None: + """A named device that exists is opened directly.""" - assert name == "Dummy MIDI" - assert isinstance(port, conftest.FakeMidiIn) + name, port = subsequence.midi_utils.select_input_device("Dummy MIDI") + assert name == "Dummy MIDI" + assert isinstance(port, conftest.FakeMidiIn) -def test_select_input_device_missing_name_raises (patch_midi: None) -> None: - """A named device that is missing raises instead of falling back to another input.""" +def test_select_input_device_missing_name_raises(patch_midi: None) -> None: + """A named device that is missing raises instead of falling back to another input.""" - with pytest.raises(ValueError, match="not found"): - subsequence.midi_utils.select_input_device("Nonexistent Device") + with pytest.raises(ValueError, match="not found"): + subsequence.midi_utils.select_input_device("Nonexistent Device") diff --git a/tests/test_midi_recording.py b/tests/test_midi_recording.py index 90db89a..9f1019f 100644 --- a/tests/test_midi_recording.py +++ b/tests/test_midi_recording.py @@ -13,176 +13,184 @@ # _record_event # --------------------------------------------------------------------------- -def test_record_event_appends_message (patch_midi: None) -> None: - """_record_event stores (pulse, message) when recording is enabled.""" +def test_record_event_appends_message(patch_midi: None) -> None: + """_record_event stores (pulse, message) when recording is enabled.""" - seq = subsequence.sequencer.Sequencer(record=True) - initial_count = len(seq.recorded_events) - msg = mido.Message('note_on', channel=0, note=60, velocity=100) - seq._record_event(48, msg) + seq = subsequence.sequencer.Sequencer(record=True) + initial_count = len(seq.recorded_events) + msg = mido.Message("note_on", channel=0, note=60, velocity=100) + seq._record_event(48, msg) - assert len(seq.recorded_events) == initial_count + 1 - assert seq.recorded_events[-1] == (48.0, msg) + assert len(seq.recorded_events) == initial_count + 1 + assert seq.recorded_events[-1] == (48.0, msg) -def test_record_event_skipped_when_not_recording (patch_midi: None) -> None: +def test_record_event_skipped_when_not_recording(patch_midi: None) -> None: + """_record_event does nothing when recording is disabled.""" - """_record_event does nothing when recording is disabled.""" + seq = subsequence.sequencer.Sequencer(record=False) + msg = mido.Message("note_on", channel=0, note=60, velocity=100) + seq._record_event(48, msg) - seq = subsequence.sequencer.Sequencer(record=False) - msg = mido.Message('note_on', channel=0, note=60, velocity=100) - seq._record_event(48, msg) - - assert len(seq.recorded_events) == 0 + assert len(seq.recorded_events) == 0 # --------------------------------------------------------------------------- # set_bpm tempo recording # --------------------------------------------------------------------------- -def test_set_bpm_records_tempo_event (patch_midi: None) -> None: - - """set_bpm appends a set_tempo MetaMessage when recording.""" - seq = subsequence.sequencer.Sequencer(record=True) - seq.recorded_events.clear() # discard the initial set_bpm event from __init__ +def test_set_bpm_records_tempo_event(patch_midi: None) -> None: + """set_bpm appends a set_tempo MetaMessage when recording.""" - seq.set_bpm(140) + seq = subsequence.sequencer.Sequencer(record=True) + seq.recorded_events.clear() # discard the initial set_bpm event from __init__ - assert len(seq.recorded_events) == 1 - _, msg = seq.recorded_events[0] - assert isinstance(msg, mido.MetaMessage) - assert msg.type == 'set_tempo' - assert msg.tempo == mido.bpm2tempo(140) + seq.set_bpm(140) + assert len(seq.recorded_events) == 1 + _, msg = seq.recorded_events[0] + assert isinstance(msg, mido.MetaMessage) + assert msg.type == "set_tempo" + assert msg.tempo == mido.bpm2tempo(140) -def test_set_bpm_does_not_record_when_not_recording (patch_midi: None) -> None: - """set_bpm does not append anything when recording is disabled.""" +def test_set_bpm_does_not_record_when_not_recording(patch_midi: None) -> None: + """set_bpm does not append anything when recording is disabled.""" - seq = subsequence.sequencer.Sequencer(record=False) - seq.set_bpm(140) + seq = subsequence.sequencer.Sequencer(record=False) + seq.set_bpm(140) - assert len(seq.recorded_events) == 0 + assert len(seq.recorded_events) == 0 -def test_initial_bpm_is_recorded_on_construction (patch_midi: None) -> None: +def test_initial_bpm_is_recorded_on_construction(patch_midi: None) -> None: + """The initial BPM is stored as the first recorded event.""" - """The initial BPM is stored as the first recorded event.""" + seq = subsequence.sequencer.Sequencer(record=True, initial_bpm=110) - seq = subsequence.sequencer.Sequencer(record=True, initial_bpm=110) - - assert len(seq.recorded_events) >= 1 - _, msg = seq.recorded_events[0] - assert isinstance(msg, mido.MetaMessage) - assert msg.type == 'set_tempo' - assert msg.tempo == mido.bpm2tempo(110) + assert len(seq.recorded_events) >= 1 + _, msg = seq.recorded_events[0] + assert isinstance(msg, mido.MetaMessage) + assert msg.type == "set_tempo" + assert msg.tempo == mido.bpm2tempo(110) # --------------------------------------------------------------------------- # save_recording # --------------------------------------------------------------------------- -def test_save_recording_creates_valid_midi_file (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """save_recording writes a Type 1 MIDI file with the recorded events.""" - filename = str(tmp_path / "test.mid") - seq = subsequence.sequencer.Sequencer(record=True, record_filename=filename) +def test_save_recording_creates_valid_midi_file( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """save_recording writes a Type 1 MIDI file with the recorded events.""" - # Add a note_on and note_off on top of the initial tempo event - seq._record_event(0, mido.Message('note_on', channel=1, note=64, velocity=90)) - seq._record_event(96, mido.Message('note_off', channel=1, note=64, velocity=0)) - seq.save_recording() + filename = str(tmp_path / "test.mid") + seq = subsequence.sequencer.Sequencer(record=True, record_filename=filename) - assert os.path.exists(filename) + # Add a note_on and note_off on top of the initial tempo event + seq._record_event(0, mido.Message("note_on", channel=1, note=64, velocity=90)) + seq._record_event(96, mido.Message("note_off", channel=1, note=64, velocity=0)) + seq.save_recording() - mid = mido.MidiFile(filename) - assert mid.type == 1 - assert mid.ticks_per_beat == 480 + assert os.path.exists(filename) - note_events = [m for m in mid.tracks[0] if not isinstance(m, mido.MetaMessage)] - tempo_events = [m for m in mid.tracks[0] if isinstance(m, mido.MetaMessage) and m.type == 'set_tempo'] + mid = mido.MidiFile(filename) + assert mid.type == 1 + assert mid.ticks_per_beat == 480 - assert len(note_events) == 2 # note_on + note_off - assert len(tempo_events) == 1 # initial set_bpm + note_events = [m for m in mid.tracks[0] if not isinstance(m, mido.MetaMessage)] + tempo_events = [ + m + for m in mid.tracks[0] + if isinstance(m, mido.MetaMessage) and m.type == "set_tempo" + ] + assert len(note_events) == 2 # note_on + note_off + assert len(tempo_events) == 1 # initial set_bpm -def test_save_recording_delta_ticks_are_correct (tmp_path: pathlib.Path, patch_midi: None) -> None: - """Events at known pulses produce the expected delta tick values (1 pulse = 20 ticks).""" +def test_save_recording_delta_ticks_are_correct( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Events at known pulses produce the expected delta tick values (1 pulse = 20 ticks).""" - filename = str(tmp_path / "ticks.mid") - seq = subsequence.sequencer.Sequencer(record=True, record_filename=filename) - seq.recorded_events.clear() # start from blank + filename = str(tmp_path / "ticks.mid") + seq = subsequence.sequencer.Sequencer(record=True, record_filename=filename) + seq.recorded_events.clear() # start from blank - # Pulse 0 and pulse 24 (= one beat at 24 PPQN = 480 ticks at 20× scale) - seq._record_event(0, mido.Message('note_on', channel=0, note=60, velocity=100)) - seq._record_event(24, mido.Message('note_off', channel=0, note=60, velocity=0)) - seq.save_recording() + # Pulse 0 and pulse 24 (= one beat at 24 PPQN = 480 ticks at 20× scale) + seq._record_event(0, mido.Message("note_on", channel=0, note=60, velocity=100)) + seq._record_event(24, mido.Message("note_off", channel=0, note=60, velocity=0)) + seq.save_recording() - mid = mido.MidiFile(filename) - events = list(mid.tracks[0]) + mid = mido.MidiFile(filename) + events = list(mid.tracks[0]) - assert events[0].time == 0 # first event: delta 0 - assert events[1].time == 480 # 24 pulses × 20 = 480 ticks + assert events[0].time == 0 # first event: delta 0 + assert events[1].time == 480 # 24 pulses × 20 = 480 ticks -def test_save_recording_skips_when_no_events (tmp_path: pathlib.Path, patch_midi: None) -> None: +def test_save_recording_skips_when_no_events( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """save_recording does nothing when recorded_events is empty.""" - """save_recording does nothing when recorded_events is empty.""" + filename = str(tmp_path / "empty.mid") + seq = subsequence.sequencer.Sequencer(record=True, record_filename=filename) + seq.recorded_events.clear() + seq.save_recording() - filename = str(tmp_path / "empty.mid") - seq = subsequence.sequencer.Sequencer(record=True, record_filename=filename) - seq.recorded_events.clear() - seq.save_recording() + assert not os.path.exists(filename) - assert not os.path.exists(filename) +def test_save_recording_skips_when_not_recording( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """save_recording does nothing when self.recording is False, even with events present.""" -def test_save_recording_skips_when_not_recording (tmp_path: pathlib.Path, patch_midi: None) -> None: + filename = str(tmp_path / "disabled.mid") + seq = subsequence.sequencer.Sequencer(record=False, record_filename=filename) - """save_recording does nothing when self.recording is False, even with events present.""" + # Bypass the _record_event guard to inject a synthetic event + seq.recorded_events.append( + (0.0, mido.Message("note_on", channel=0, note=60, velocity=100)) + ) + seq.save_recording() - filename = str(tmp_path / "disabled.mid") - seq = subsequence.sequencer.Sequencer(record=False, record_filename=filename) + assert not os.path.exists(filename) - # Bypass the _record_event guard to inject a synthetic event - seq.recorded_events.append((0.0, mido.Message('note_on', channel=0, note=60, velocity=100))) - seq.save_recording() - assert not os.path.exists(filename) +def test_save_recording_generates_timestamp_filename( + tmp_path: pathlib.Path, patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """save_recording uses a timestamped filename when record_filename is not set.""" + monkeypatch.chdir(tmp_path) # write into tmp_path so the file is cleaned up + seq = subsequence.sequencer.Sequencer(record=True) # no record_filename + seq.save_recording() -def test_save_recording_generates_timestamp_filename (tmp_path: pathlib.Path, patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - - """save_recording uses a timestamped filename when record_filename is not set.""" - - monkeypatch.chdir(tmp_path) # write into tmp_path so the file is cleaned up - seq = subsequence.sequencer.Sequencer(record=True) # no record_filename - seq.save_recording() - - mid_files = list(tmp_path.glob("session_*.mid")) - assert len(mid_files) == 1 + mid_files = list(tmp_path.glob("session_*.mid")) + assert len(mid_files) == 1 # --------------------------------------------------------------------------- # Composition integration # --------------------------------------------------------------------------- -def test_composition_record_passed_to_sequencer (patch_midi: None) -> None: - - """Composition(record=True, record_filename=...) passes both params to Sequencer.""" - composition = subsequence.Composition(record=True, record_filename="session.mid") - assert composition._sequencer.recording is True - assert composition._sequencer.record_filename == "session.mid" +def test_composition_record_passed_to_sequencer(patch_midi: None) -> None: + """Composition(record=True, record_filename=...) passes both params to Sequencer.""" + composition = subsequence.Composition(record=True, record_filename="session.mid") + assert composition._sequencer.recording is True + assert composition._sequencer.record_filename == "session.mid" -def test_composition_record_defaults_to_off (patch_midi: None) -> None: - """Composition() with no record arg creates a non-recording sequencer.""" +def test_composition_record_defaults_to_off(patch_midi: None) -> None: + """Composition() with no record arg creates a non-recording sequencer.""" - composition = subsequence.Composition() - assert composition._sequencer.recording is False + composition = subsequence.Composition() + assert composition._sequencer.recording is False diff --git a/tests/test_mini_notation.py b/tests/test_mini_notation.py index 12eb998..91425e6 100644 --- a/tests/test_mini_notation.py +++ b/tests/test_mini_notation.py @@ -1,177 +1,163 @@ - import pytest import subsequence.mini_notation -def test_basic_tokenization () -> None: - - """Test breaking string into tokens.""" - - tokens = subsequence.mini_notation._tokenize("a b c") - assert tokens == ["a", "b", "c"] - - tokens = subsequence.mini_notation._tokenize("a [b c]") - assert tokens == ["a", ["b", "c"]] - - tokens = subsequence.mini_notation._tokenize("a [b [c d]]") - assert tokens == ["a", ["b", ["c", "d"]]] - - -def test_basic_parsing () -> None: - - """Test allocating time slots.""" - - events = subsequence.mini_notation.parse("a b c d", total_duration=4.0) - - assert len(events) == 4 - assert events[0].symbol == "a" - assert events[0].time == 0.0 - assert events[0].duration == 1.0 - - assert events[1].symbol == "b" - assert events[1].time == 1.0 - - assert events[3].symbol == "d" - assert events[3].time == 3.0 +def test_basic_tokenization() -> None: + """Test breaking string into tokens.""" + + tokens = subsequence.mini_notation._tokenize("a b c") + assert tokens == ["a", "b", "c"] + + tokens = subsequence.mini_notation._tokenize("a [b c]") + assert tokens == ["a", ["b", "c"]] + + tokens = subsequence.mini_notation._tokenize("a [b [c d]]") + assert tokens == ["a", ["b", ["c", "d"]]] + + +def test_basic_parsing() -> None: + """Test allocating time slots.""" + + events = subsequence.mini_notation.parse("a b c d", total_duration=4.0) + + assert len(events) == 4 + assert events[0].symbol == "a" + assert events[0].time == 0.0 + assert events[0].duration == 1.0 + + assert events[1].symbol == "b" + assert events[1].time == 1.0 + + assert events[3].symbol == "d" + assert events[3].time == 3.0 + + +def test_subdivision() -> None: + """Test nested subdivisions.""" + + # "a [b c] d" -> a(1), [b(0.5), c(0.5)], d(1) + events = subsequence.mini_notation.parse("a [b c] d", total_duration=3.0) + assert len(events) == 4 -def test_subdivision () -> None: + # 'a' gets 1 beat + assert events[0].symbol == "a" + assert events[0].duration == 1.0 - """Test nested subdivisions.""" - - # "a [b c] d" -> a(1), [b(0.5), c(0.5)], d(1) - events = subsequence.mini_notation.parse("a [b c] d", total_duration=3.0) - - assert len(events) == 4 - - # 'a' gets 1 beat - assert events[0].symbol == "a" - assert events[0].duration == 1.0 - - # 'b' gets 0.5 beat - assert events[1].symbol == "b" - assert events[1].duration == 0.5 - assert events[1].time == 1.0 - - # 'c' gets 0.5 beat - assert events[2].symbol == "c" - assert events[2].duration == 0.5 - assert events[2].time == 1.5 - - # 'd' gets 1 beat - assert events[3].symbol == "d" - assert events[3].time == 2.0 + # 'b' gets 0.5 beat + assert events[1].symbol == "b" + assert events[1].duration == 0.5 + assert events[1].time == 1.0 + # 'c' gets 0.5 beat + assert events[2].symbol == "c" + assert events[2].duration == 0.5 + assert events[2].time == 1.5 -def test_rests () -> None: + # 'd' gets 1 beat + assert events[3].symbol == "d" + assert events[3].time == 2.0 - """Test rests (~) and (.) are skipped.""" - - events = subsequence.mini_notation.parse("a ~ b .", total_duration=4.0) - - assert len(events) == 2 - - assert events[0].symbol == "a" - assert events[0].time == 0.0 - - assert events[1].symbol == "b" - assert events[1].time == 2.0 # skips '1.0' +def test_rests() -> None: + """Test rests (~) and (.) are skipped.""" -def test_sustain () -> None: + events = subsequence.mini_notation.parse("a ~ b .", total_duration=4.0) - """Test sustain (_) extends previous note.""" - - events = subsequence.mini_notation.parse("a _ b _ _", total_duration=5.0) - - assert len(events) == 2 - - # 'a' takes 2 slots (1.0 + 1.0) - assert events[0].symbol == "a" - assert events[0].duration == 2.0 - - # 'b' takes 3 slots (1.0 + 1.0 + 1.0) - assert events[1].symbol == "b" - assert events[1].duration == 3.0 - assert events[1].time == 2.0 + assert len(events) == 2 + assert events[0].symbol == "a" + assert events[0].time == 0.0 -def test_empty_string () -> None: + assert events[1].symbol == "b" + assert events[1].time == 2.0 # skips '1.0' - """Test empty notation returns no events.""" - events = subsequence.mini_notation.parse("", total_duration=4.0) +def test_sustain() -> None: + """Test sustain (_) extends previous note.""" - assert events == [] + events = subsequence.mini_notation.parse("a _ b _ _", total_duration=5.0) + assert len(events) == 2 -def test_consecutive_sustains () -> None: + # 'a' takes 2 slots (1.0 + 1.0) + assert events[0].symbol == "a" + assert events[0].duration == 2.0 - """Test multiple consecutive sustains extend the same note.""" + # 'b' takes 3 slots (1.0 + 1.0 + 1.0) + assert events[1].symbol == "b" + assert events[1].duration == 3.0 + assert events[1].time == 2.0 - events = subsequence.mini_notation.parse("a _ _ _", total_duration=4.0) - assert len(events) == 1 - assert events[0].symbol == "a" - assert events[0].duration == 4.0 +def test_empty_string() -> None: + """Test empty notation returns no events.""" + events = subsequence.mini_notation.parse("", total_duration=4.0) -def test_sustain_at_start () -> None: + assert events == [] - """Test sustain at start with no prior note is ignored.""" - events = subsequence.mini_notation.parse("_ _ a", total_duration=3.0) +def test_consecutive_sustains() -> None: + """Test multiple consecutive sustains extend the same note.""" - assert len(events) == 1 - assert events[0].symbol == "a" - assert events[0].time == 2.0 + events = subsequence.mini_notation.parse("a _ _ _", total_duration=4.0) + assert len(events) == 1 + assert events[0].symbol == "a" + assert events[0].duration == 4.0 -def test_sustain_after_rest_stays_silent () -> None: - """A sustain after a rest is silent: in "a ~ _" the note keeps its one-slot duration and is not extended through the rest.""" +def test_sustain_at_start() -> None: + """Test sustain at start with no prior note is ignored.""" - events = subsequence.mini_notation.parse("a ~ _", total_duration=3.0) + events = subsequence.mini_notation.parse("_ _ a", total_duration=3.0) - assert len(events) == 1 - assert events[0].symbol == "a" - assert events[0].time == 0.0 - assert events[0].duration == 1.0 + assert len(events) == 1 + assert events[0].symbol == "a" + assert events[0].time == 2.0 -def test_probability_suffix_not_a_number_raises () -> None: +def test_sustain_after_rest_stays_silent() -> None: + """A sustain after a rest is silent: in "a ~ _" the note keeps its one-slot duration and is not extended through the rest.""" - """A non-numeric probability suffix like "kick?abc" raises MiniNotationError.""" + events = subsequence.mini_notation.parse("a ~ _", total_duration=3.0) - with pytest.raises(subsequence.mini_notation.MiniNotationError): - subsequence.mini_notation.parse("kick?abc", total_duration=1.0) + assert len(events) == 1 + assert events[0].symbol == "a" + assert events[0].time == 0.0 + assert events[0].duration == 1.0 -def test_probability_suffix_out_of_range_raises () -> None: +def test_probability_suffix_not_a_number_raises() -> None: + """A non-numeric probability suffix like "kick?abc" raises MiniNotationError.""" - """A probability above 1.0 like "kick?1.5" raises MiniNotationError.""" + with pytest.raises(subsequence.mini_notation.MiniNotationError): + subsequence.mini_notation.parse("kick?abc", total_duration=1.0) - with pytest.raises(subsequence.mini_notation.MiniNotationError): - subsequence.mini_notation.parse("kick?1.5", total_duration=1.0) +def test_probability_suffix_out_of_range_raises() -> None: + """A probability above 1.0 like "kick?1.5" raises MiniNotationError.""" -def test_invalid_total_duration () -> None: + with pytest.raises(subsequence.mini_notation.MiniNotationError): + subsequence.mini_notation.parse("kick?1.5", total_duration=1.0) - """Test that zero or negative total_duration raises ValueError.""" - with pytest.raises(ValueError): - subsequence.mini_notation.parse("a b", total_duration=0) +def test_invalid_total_duration() -> None: + """Test that zero or negative total_duration raises ValueError.""" - with pytest.raises(ValueError): - subsequence.mini_notation.parse("a b", total_duration=-1.0) + with pytest.raises(ValueError): + subsequence.mini_notation.parse("a b", total_duration=0) + with pytest.raises(ValueError): + subsequence.mini_notation.parse("a b", total_duration=-1.0) -def test_errors () -> None: - """Test invalid syntax.""" +def test_errors() -> None: + """Test invalid syntax.""" - with pytest.raises(subsequence.mini_notation.MiniNotationError): - subsequence.mini_notation._tokenize("a [ b") + with pytest.raises(subsequence.mini_notation.MiniNotationError): + subsequence.mini_notation._tokenize("a [ b") - with pytest.raises(subsequence.mini_notation.MiniNotationError): - subsequence.mini_notation._tokenize("a ] b") + with pytest.raises(subsequence.mini_notation.MiniNotationError): + subsequence.mini_notation._tokenize("a ] b") diff --git a/tests/test_mini_notation_integration.py b/tests/test_mini_notation_integration.py index 58fa39f..bb0106e 100644 --- a/tests/test_mini_notation_integration.py +++ b/tests/test_mini_notation_integration.py @@ -1,44 +1,42 @@ - import pytest import subsequence.pattern import subsequence.pattern_builder -def test_seq_rhythm () -> None: - - """Test p.seq() with fixed pitch (rhythm mode).""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern, cycle=0, drum_note_map={"k": 36}) - - # 4 beats: Hit at 0, 1, 2, 3 - builder.seq("x x x x", pitch="k") - - steps = pattern.steps - - # beat 0 - assert 0 in steps - assert steps[0].notes[0].pitch == 36 - - # beat 1 (24 pulses) - assert 24 in steps - assert steps[24].notes[0].pitch == 36 - - -def test_seq_melody () -> None: - - """Test p.seq() with symbols as pitches.""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4.0) - builder = subsequence.pattern_builder.PatternBuilder(pattern, cycle=0) - - # 4 beats: 60, 62, 64, 65 - builder.seq("60 62 64 65") - - steps = pattern.steps - - assert steps[0].notes[0].pitch == 60 - assert steps[24].notes[0].pitch == 62 - assert steps[48].notes[0].pitch == 64 - assert steps[72].notes[0].pitch == 65 +def test_seq_rhythm() -> None: + """Test p.seq() with fixed pitch (rhythm mode).""" + + pattern = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder( + pattern, cycle=0, drum_note_map={"k": 36} + ) + + # 4 beats: Hit at 0, 1, 2, 3 + builder.seq("x x x x", pitch="k") + + steps = pattern.steps + + # beat 0 + assert 0 in steps + assert steps[0].notes[0].pitch == 36 + + # beat 1 (24 pulses) + assert 24 in steps + assert steps[24].notes[0].pitch == 36 + + +def test_seq_melody() -> None: + """Test p.seq() with symbols as pitches.""" + + pattern = subsequence.pattern.Pattern(channel=0, length=4.0) + builder = subsequence.pattern_builder.PatternBuilder(pattern, cycle=0) + + # 4 beats: 60, 62, 64, 65 + builder.seq("60 62 64 65") + + steps = pattern.steps + assert steps[0].notes[0].pitch == 60 + assert steps[24].notes[0].pitch == 62 + assert steps[48].notes[0].pitch == 64 + assert steps[72].notes[0].pitch == 65 diff --git a/tests/test_mirror.py b/tests/test_mirror.py index e277cfe..c91aff9 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -19,786 +19,869 @@ # ── Pattern data model ────────────────────────────────────────────────────── -def test_pattern_default_mirrors_empty () -> None: +def test_pattern_default_mirrors_empty() -> None: + """A Pattern constructed without `mirrors` defaults to an empty list.""" - """A Pattern constructed without `mirrors` defaults to an empty list.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) - pattern = subsequence.pattern.Pattern(channel=0, length=4) + assert pattern.mirrors == [] - assert pattern.mirrors == [] +def test_pattern_accepts_mirrors() -> None: + """Pattern stores mirrors as a list of (device, channel) tuples.""" -def test_pattern_accepts_mirrors () -> None: + pattern = subsequence.pattern.Pattern(channel=0, length=4, mirrors=[(1, 5), (2, 9)]) - """Pattern stores mirrors as a list of (device, channel) tuples.""" + assert pattern.mirrors == [(1, 5), (2, 9)] - pattern = subsequence.pattern.Pattern(channel=0, length=4, mirrors=[(1, 5), (2, 9)]) - assert pattern.mirrors == [(1, 5), (2, 9)] +def test_pattern_mirrors_is_independent_copy() -> None: + """Mutating the input list after construction must not affect the Pattern.""" + user_list = [(1, 5)] + pattern = subsequence.pattern.Pattern(channel=0, length=4, mirrors=user_list) + user_list.append((2, 9)) -def test_pattern_mirrors_is_independent_copy () -> None: - - """Mutating the input list after construction must not affect the Pattern.""" - - user_list = [(1, 5)] - pattern = subsequence.pattern.Pattern(channel=0, length=4, mirrors=user_list) - user_list.append((2, 9)) - - assert pattern.mirrors == [(1, 5)] + assert pattern.mirrors == [(1, 5)] # ── Sequencer fan-out: notes ──────────────────────────────────────────────── @pytest.mark.asyncio -async def test_mirror_duplicates_note_events (patch_midi: None) -> None: - - """Note On / Note Off are scheduled for the primary AND each mirror.""" +async def test_mirror_duplicates_note_events(patch_midi: None) -> None: + """Note On / Note Off are scheduled for the primary AND each mirror.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5)]) - pattern.add_note(position=0, pitch=60, velocity=100, duration=12) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5)] + ) + pattern.add_note(position=0, pitch=60, velocity=100, duration=12) - await sequencer.schedule_pattern(pattern, start_pulse=0) + await sequencer.schedule_pattern(pattern, start_pulse=0) - note_ons = [e for e in sequencer.event_queue if e.message_type == 'note_on'] - note_offs = [e for e in sequencer.event_queue if e.message_type == 'note_off'] + note_ons = [e for e in sequencer.event_queue if e.message_type == "note_on"] + note_offs = [e for e in sequencer.event_queue if e.message_type == "note_off"] - # Two destinations × one note = two note_on + two note_off - assert len(note_ons) == 2 - assert len(note_offs) == 2 + # Two destinations × one note = two note_on + two note_off + assert len(note_ons) == 2 + assert len(note_offs) == 2 - # Primary on (device=0, channel=0); mirror on (device=1, channel=5) - primary_on = next(e for e in note_ons if e.device == 0) - mirror_on = next(e for e in note_ons if e.device == 1) + # Primary on (device=0, channel=0); mirror on (device=1, channel=5) + primary_on = next(e for e in note_ons if e.device == 0) + mirror_on = next(e for e in note_ons if e.device == 1) - assert (primary_on.channel, primary_on.note, primary_on.velocity) == (0, 60, 100) - assert (mirror_on.channel, mirror_on.note, mirror_on.velocity) == (5, 60, 100) + assert (primary_on.channel, primary_on.note, primary_on.velocity) == (0, 60, 100) + assert (mirror_on.channel, mirror_on.note, mirror_on.velocity) == (5, 60, 100) @pytest.mark.asyncio -async def test_mirror_with_two_destinations_fans_out_three_ways (patch_midi: None) -> None: +async def test_mirror_with_two_destinations_fans_out_three_ways( + patch_midi: None, +) -> None: + """Two mirrors → 3× events total (primary + 2 mirrors).""" - """Two mirrors → 3× events total (primary + 2 mirrors).""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5), (2, 9)] + ) + pattern.add_note(position=0, pitch=60, velocity=100, duration=12) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5), (2, 9)]) - pattern.add_note(position=0, pitch=60, velocity=100, duration=12) + await sequencer.schedule_pattern(pattern, start_pulse=0) - await sequencer.schedule_pattern(pattern, start_pulse=0) + note_ons = sorted( + [e for e in sequencer.event_queue if e.message_type == "note_on"], + key=lambda e: e.device, + ) - note_ons = sorted( - [e for e in sequencer.event_queue if e.message_type == 'note_on'], - key=lambda e: e.device, - ) - - assert [(e.device, e.channel) for e in note_ons] == [(0, 0), (1, 5), (2, 9)] + assert [(e.device, e.channel) for e in note_ons] == [(0, 0), (1, 5), (2, 9)] # ── Sequencer fan-out: CC / pitch bend / NRPN ────────────────────────────── @pytest.mark.asyncio -async def test_mirror_duplicates_cc_events (patch_midi: None) -> None: - - """Plain CC events fan out to mirror destinations.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5)]) - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=0, message_type='control_change', control=74, value=100) - ) - - await sequencer.schedule_pattern(pattern, start_pulse=0) - - ccs = [e for e in sequencer.event_queue if e.message_type == 'control_change'] - assert len(ccs) == 2 - - by_device = {e.device: e for e in ccs} - assert by_device[0].channel == 0 and by_device[0].control == 74 and by_device[0].value == 100 - assert by_device[1].channel == 5 and by_device[1].control == 74 and by_device[1].value == 100 +async def test_mirror_duplicates_cc_events(patch_midi: None) -> None: + """Plain CC events fan out to mirror destinations.""" + + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5)] + ) + pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=0, message_type="control_change", control=74, value=100 + ) + ) + + await sequencer.schedule_pattern(pattern, start_pulse=0) + + ccs = [e for e in sequencer.event_queue if e.message_type == "control_change"] + assert len(ccs) == 2 + + by_device = {e.device: e for e in ccs} + assert ( + by_device[0].channel == 0 + and by_device[0].control == 74 + and by_device[0].value == 100 + ) + assert ( + by_device[1].channel == 5 + and by_device[1].control == 74 + and by_device[1].value == 100 + ) @pytest.mark.asyncio -async def test_mirror_duplicates_pitchwheel_events (patch_midi: None) -> None: - - """Pitch bend events fan out to mirror destinations.""" +async def test_mirror_duplicates_pitchwheel_events(patch_midi: None) -> None: + """Pitch bend events fan out to mirror destinations.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5)]) - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=0, message_type='pitchwheel', value=4000) - ) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5)] + ) + pattern.cc_events.append( + subsequence.pattern.CcEvent(pulse=0, message_type="pitchwheel", value=4000) + ) - await sequencer.schedule_pattern(pattern, start_pulse=0) + await sequencer.schedule_pattern(pattern, start_pulse=0) - pbs = [e for e in sequencer.event_queue if e.message_type == 'pitchwheel'] - assert len(pbs) == 2 + pbs = [e for e in sequencer.event_queue if e.message_type == "pitchwheel"] + assert len(pbs) == 2 - by_device = {e.device: e for e in pbs} - assert by_device[0].channel == 0 and by_device[0].value == 4000 - assert by_device[1].channel == 5 and by_device[1].value == 4000 + by_device = {e.device: e for e in pbs} + assert by_device[0].channel == 0 and by_device[0].value == 4000 + assert by_device[1].channel == 5 and by_device[1].value == 4000 @pytest.mark.asyncio -async def test_mirror_preserves_nrpn_burst_ordering (patch_midi: None) -> None: +async def test_mirror_preserves_nrpn_burst_ordering(patch_midi: None) -> None: + """A 4-CC NRPN burst at the same pulse stays in 99→98→6→38 order on the mirror. - """A 4-CC NRPN burst at the same pulse stays in 99→98→6→38 order on the mirror. + This is a regression fence for the ``MidiEvent.sequence`` tie-breaker — + without it the heap would scramble same-pulse mirrored CCs. + """ - This is a regression fence for the ``MidiEvent.sequence`` tie-breaker — - without it the heap would scramble same-pulse mirrored CCs. - """ + import heapq - import heapq + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5)] + ) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5)]) + # Build an NRPN burst at pulse 0 + for cc in (99, 98, 6, 38): + pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=0, message_type="control_change", control=cc, value=cc + ) + ) - # Build an NRPN burst at pulse 0 - for cc in (99, 98, 6, 38): - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=0, message_type='control_change', control=cc, value=cc) - ) + await sequencer.schedule_pattern(pattern, start_pulse=0) - await sequencer.schedule_pattern(pattern, start_pulse=0) + # Pop everything from the heap and check ordering per device + order_by_device: typing.Dict[int, list] = {} + while sequencer.event_queue: + ev = heapq.heappop(sequencer.event_queue) + if ev.message_type == "control_change": + order_by_device.setdefault(ev.device, []).append(ev.control) - # Pop everything from the heap and check ordering per device - order_by_device: typing.Dict[int, list] = {} - while sequencer.event_queue: - ev = heapq.heappop(sequencer.event_queue) - if ev.message_type == 'control_change': - order_by_device.setdefault(ev.device, []).append(ev.control) - - assert order_by_device[0] == [99, 98, 6, 38] - assert order_by_device[1] == [99, 98, 6, 38] + assert order_by_device[0] == [99, 98, 6, 38] + assert order_by_device[1] == [99, 98, 6, 38] # ── Sequencer fan-out: drones ─────────────────────────────────────────────── @pytest.mark.asyncio -async def test_mirror_duplicates_drone_events (patch_midi: None) -> None: - - """Drone (raw_note_event) events fan out to mirror destinations.""" +async def test_mirror_duplicates_drone_events(patch_midi: None) -> None: + """Drone (raw_note_event) events fan out to mirror destinations.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5)]) - pattern.raw_note_events.append( - subsequence.pattern.RawNoteEvent(pulse=0, message_type='note_on', pitch=48, velocity=80) - ) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5)] + ) + pattern.raw_note_events.append( + subsequence.pattern.RawNoteEvent( + pulse=0, message_type="note_on", pitch=48, velocity=80 + ) + ) - await sequencer.schedule_pattern(pattern, start_pulse=0) + await sequencer.schedule_pattern(pattern, start_pulse=0) - notes = [e for e in sequencer.event_queue if e.message_type == 'note_on'] - assert len(notes) == 2 + notes = [e for e in sequencer.event_queue if e.message_type == "note_on"] + assert len(notes) == 2 - by_device = {e.device: e for e in notes} - assert by_device[0].channel == 0 and by_device[0].note == 48 - assert by_device[1].channel == 5 and by_device[1].note == 48 + by_device = {e.device: e for e in notes} + assert by_device[0].channel == 0 and by_device[0].note == 48 + assert by_device[1].channel == 5 and by_device[1].note == 48 # ── OSC events are NOT mirrored ───────────────────────────────────────────── @pytest.mark.asyncio -async def test_mirror_does_not_duplicate_osc_events (patch_midi: None) -> None: - - """OSC events are deliberately excluded from mirroring.""" +async def test_mirror_does_not_duplicate_osc_events(patch_midi: None) -> None: + """OSC events are deliberately excluded from mirroring.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5), (2, 9)]) - pattern.osc_events.append( - subsequence.pattern.OscEvent(pulse=0, address="/fader/1", args=(0.8,)) - ) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5), (2, 9)] + ) + pattern.osc_events.append( + subsequence.pattern.OscEvent(pulse=0, address="/fader/1", args=(0.8,)) + ) - await sequencer.schedule_pattern(pattern, start_pulse=0) + await sequencer.schedule_pattern(pattern, start_pulse=0) - osc = [e for e in sequencer.event_queue if e.message_type == 'osc'] - assert len(osc) == 1 + osc = [e for e in sequencer.event_queue if e.message_type == "osc"] + assert len(osc) == 1 # ── CcEvent override semantics with mirrors ───────────────────────────────── @pytest.mark.asyncio -async def test_mirror_ignores_cc_event_channel_override (patch_midi: None) -> None: +async def test_mirror_ignores_cc_event_channel_override(patch_midi: None) -> None: + """A CcEvent with explicit `channel=` overrides the primary only. - """A CcEvent with explicit `channel=` overrides the primary only. + Mirrors always use their pinned channel — by design, since the override + is meaningful for the primary's port (e.g. polyphonic-tuning rotation) + but mirrors are independent destinations. + """ - Mirrors always use their pinned channel — by design, since the override - is meaningful for the primary's port (e.g. polyphonic-tuning rotation) - but mirrors are independent destinations. - """ + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 9)] + ) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 9)]) + # CcEvent targets channel 7 explicitly (e.g. polyphonic-tuning bend). + pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=0, message_type="pitchwheel", value=2000, channel=7 + ) + ) - # CcEvent targets channel 7 explicitly (e.g. polyphonic-tuning bend). - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=0, message_type='pitchwheel', value=2000, channel=7) - ) + await sequencer.schedule_pattern(pattern, start_pulse=0) - await sequencer.schedule_pattern(pattern, start_pulse=0) + pbs = [e for e in sequencer.event_queue if e.message_type == "pitchwheel"] + assert len(pbs) == 2 - pbs = [e for e in sequencer.event_queue if e.message_type == 'pitchwheel'] - assert len(pbs) == 2 - - by_device = {e.device: e for e in pbs} - # Primary respected the explicit channel=7 - assert by_device[0].channel == 7 - # Mirror used its pinned channel (9), ignoring the override - assert by_device[1].channel == 9 + by_device = {e.device: e for e in pbs} + # Primary respected the explicit channel=7 + assert by_device[0].channel == 7 + # Mirror used its pinned channel (9), ignoring the override + assert by_device[1].channel == 9 # ── Decorator integration ────────────────────────────────────────────────── -def test_pattern_decorator_accepts_mirrors () -> None: - - """@composition.pattern(..., mirrors=[...]) carries through to _PendingPattern.""" - - composition = subsequence.Composition(bpm=120) - - @composition.pattern(channel=1, beats=4, mirrors=[(1, 10), (2, 1)]) - def p1 (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass +def test_pattern_decorator_accepts_mirrors() -> None: + """@composition.pattern(..., mirrors=[...]) carries through to _PendingPattern.""" - pending = composition._pending_patterns[0] - # Channel 1 (1-indexed) → 0; Channel 10 → 9; Channel 1 → 0 - assert pending.mirrors == [(1, 9), (2, 0)] + composition = subsequence.Composition(bpm=120) + @composition.pattern(channel=1, beats=4, mirrors=[(1, 10), (2, 1)]) + def p1(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_pattern_decorator_resolves_channels_zero_indexed () -> None: + pending = composition._pending_patterns[0] + # Channel 1 (1-indexed) → 0; Channel 10 → 9; Channel 1 → 0 + assert pending.mirrors == [(1, 9), (2, 0)] - """Mirror channels go through `_resolve_channel`, honouring zero_indexed_channels.""" - composition = subsequence.Composition(bpm=120, zero_indexed_channels=True) +def test_pattern_decorator_resolves_channels_zero_indexed() -> None: + """Mirror channels go through `_resolve_channel`, honouring zero_indexed_channels.""" - @composition.pattern(channel=0, beats=4, mirrors=[(1, 9)]) - def p2 (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition(bpm=120, zero_indexed_channels=True) - pending = composition._pending_patterns[0] - assert pending.mirrors == [(1, 9)] # 0-indexed input passed through unchanged + @composition.pattern(channel=0, beats=4, mirrors=[(1, 9)]) + def p2(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass + pending = composition._pending_patterns[0] + assert pending.mirrors == [(1, 9)] # 0-indexed input passed through unchanged -def test_pattern_decorator_validates_mirror_shape () -> None: - """Malformed mirror entries raise ValueError at decoration time.""" +def test_pattern_decorator_validates_mirror_shape() -> None: + """Malformed mirror entries raise ValueError at decoration time.""" - composition = subsequence.Composition(bpm=120) + composition = subsequence.Composition(bpm=120) - with pytest.raises(ValueError, match="2 or 3 elements"): - @composition.pattern(channel=1, beats=4, mirrors=[(1,)]) # type: ignore[list-item] - def bad1 (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + with pytest.raises(ValueError, match="2 or 3 elements"): + @composition.pattern(channel=1, beats=4, mirrors=[(1,)]) # type: ignore[list-item] + def bad1(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_pattern_decorator_accepts_list_mirrors () -> None: - """Mirror entries can be lists, not just tuples — useful for JSON-driven configs.""" +def test_pattern_decorator_accepts_list_mirrors() -> None: + """Mirror entries can be lists, not just tuples — useful for JSON-driven configs.""" - composition = subsequence.Composition(bpm=120) + composition = subsequence.Composition(bpm=120) - @composition.pattern(channel=1, beats=4, mirrors=[[1, 5], [2, 9]]) # type: ignore[list-item] - def from_config (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + @composition.pattern(channel=1, beats=4, mirrors=[[1, 5], [2, 9]]) # type: ignore[list-item] + def from_config(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - pending = composition._pending_patterns[0] - assert pending.mirrors == [(1, 4), (2, 8)] + pending = composition._pending_patterns[0] + assert pending.mirrors == [(1, 4), (2, 8)] -def test_pattern_decorator_rejects_bool_as_device () -> None: +def test_pattern_decorator_rejects_bool_as_device() -> None: + """``True`` / ``False`` are technically int subclasses but should be rejected as devices.""" - """``True`` / ``False`` are technically int subclasses but should be rejected as devices.""" + composition = subsequence.Composition(bpm=120) - composition = subsequence.Composition(bpm=120) + with pytest.raises(ValueError, match="Mirror device must be an integer"): - with pytest.raises(ValueError, match="Mirror device must be an integer"): - @composition.pattern(channel=1, beats=4, mirrors=[(True, 5)]) # type: ignore[list-item] - def bad_bool (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + @composition.pattern(channel=1, beats=4, mirrors=[(True, 5)]) # type: ignore[list-item] + def bad_bool(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_pattern_decorator_validates_device_type () -> None: +def test_pattern_decorator_validates_device_type() -> None: + """Non-integer device raises ValueError at decoration time.""" - """Non-integer device raises ValueError at decoration time.""" + composition = subsequence.Composition(bpm=120) - composition = subsequence.Composition(bpm=120) + with pytest.raises(ValueError, match="Mirror device must be an integer"): - with pytest.raises(ValueError, match="Mirror device must be an integer"): - @composition.pattern(channel=1, beats=4, mirrors=[("synth_b", 5)]) # type: ignore[list-item] - def bad2 (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + @composition.pattern(channel=1, beats=4, mirrors=[("synth_b", 5)]) # type: ignore[list-item] + def bad2(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_pattern_decorator_validates_channel () -> None: +def test_pattern_decorator_validates_channel() -> None: + """Mirror channel out of range raises via _resolve_channel.""" - """Mirror channel out of range raises via _resolve_channel.""" + composition = subsequence.Composition(bpm=120) # 1-indexed default - composition = subsequence.Composition(bpm=120) # 1-indexed default + with pytest.raises(ValueError, match="MIDI channel must be 1-16"): - with pytest.raises(ValueError, match="MIDI channel must be 1-16"): - @composition.pattern(channel=1, beats=4, mirrors=[(1, 0)]) # 0 invalid in 1-indexed mode - def bad3 (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + @composition.pattern( + channel=1, beats=4, mirrors=[(1, 0)] + ) # 0 invalid in 1-indexed mode + def bad3(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_layer_decorator_accepts_mirrors () -> None: +def test_layer_decorator_accepts_mirrors() -> None: + """composition.layer(..., mirrors=[...]) carries through.""" - """composition.layer(..., mirrors=[...]) carries through.""" + composition = subsequence.Composition(bpm=120) - composition = subsequence.Composition(bpm=120) + def part_a(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - def part_a (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + def part_b(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - def part_b (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition.layer(part_a, part_b, channel=1, beats=4, mirrors=[(1, 5)]) - composition.layer(part_a, part_b, channel=1, beats=4, mirrors=[(1, 5)]) - - pending = composition._pending_patterns[0] - assert pending.mirrors == [(1, 4)] + pending = composition._pending_patterns[0] + assert pending.mirrors == [(1, 4)] @pytest.mark.asyncio -async def test_trigger_with_mirrors_fans_out (patch_midi: None) -> None: - - """composition.trigger(mirrors=...) duplicates events onto mirror destinations. - - The full trigger path: temp Pattern → PatternBuilder → schedule_pattern. - Verifies the mirrors list survives the round trip and the sequencer - fan-outs to both destinations. - """ - - import asyncio - - composition = subsequence.Composition(bpm=120) - # trigger() needs an event loop reference to defer scheduling onto. In a - # real run that's set in Sequencer.start(); for this test we set it - # explicitly to the running loop. - composition._sequencer._event_loop = asyncio.get_event_loop() - - composition.trigger( - lambda p: p.note(60, beat=0, velocity=100, duration=0.5), - channel=1, - beats=1, - mirrors=[(1, 5)], - ) - - # trigger() spawns an asyncio task for schedule_pattern; let it complete. - await asyncio.sleep(0) - - note_ons = [e for e in composition._sequencer.event_queue if e.message_type == 'note_on'] - # Primary on (device=0); mirror on (device=1) - assert sorted({e.device for e in note_ons}) == [0, 1] - - # Both events carry the same note number on their respective channels. - # Composition is in default 1-indexed mode, so channel=1 → 0 internal, - # and the mirror's channel=5 → 4 internal. - by_device = {e.device: e for e in note_ons} - assert by_device[0].channel == 0 - assert by_device[0].note == 60 - assert by_device[1].channel == 4 - assert by_device[1].note == 60 +async def test_trigger_with_mirrors_fans_out(patch_midi: None) -> None: + """composition.trigger(mirrors=...) duplicates events onto mirror destinations. + + The full trigger path: temp Pattern → PatternBuilder → schedule_pattern. + Verifies the mirrors list survives the round trip and the sequencer + fan-outs to both destinations. + """ + + import asyncio + + composition = subsequence.Composition(bpm=120) + # trigger() needs an event loop reference to defer scheduling onto. In a + # real run that's set in Sequencer.start(); for this test we set it + # explicitly to the running loop. + composition._sequencer._event_loop = asyncio.get_event_loop() + + composition.trigger( + lambda p: p.note(60, beat=0, velocity=100, duration=0.5), + channel=1, + beats=1, + mirrors=[(1, 5)], + ) + + # trigger() spawns an asyncio task for schedule_pattern; let it complete. + await asyncio.sleep(0) + + note_ons = [ + e for e in composition._sequencer.event_queue if e.message_type == "note_on" + ] + # Primary on (device=0); mirror on (device=1) + assert sorted({e.device for e in note_ons}) == [0, 1] + + # Both events carry the same note number on their respective channels. + # Composition is in default 1-indexed mode, so channel=1 → 0 internal, + # and the mirror's channel=5 → 4 internal. + by_device = {e.device: e for e in note_ons} + assert by_device[0].channel == 0 + assert by_device[0].note == 60 + assert by_device[1].channel == 4 + assert by_device[1].note == 60 # ── Runtime API ──────────────────────────────────────────────────────────── -def _running_pattern_stub (channel: int = 0, mirrors: typing.Optional[list] = None) -> typing.Any: +def _running_pattern_stub( + channel: int = 0, mirrors: typing.Optional[list] = None +) -> typing.Any: + """Minimal stand-in for a running pattern with the fields composition.mirror() touches.""" - """Minimal stand-in for a running pattern with the fields composition.mirror() touches.""" + pat = subsequence.pattern.Pattern(channel=channel, length=4, mirrors=mirrors) + pat._muted = False + pat._tweaks = {} + pat._cycle_count = 0 + return pat - pat = subsequence.pattern.Pattern(channel=channel, length=4, mirrors=mirrors) - pat._muted = False - pat._tweaks = {} - pat._cycle_count = 0 - return pat +def test_runtime_mirror_adds_destination() -> None: + """composition.mirror() appends to the running pattern's mirrors list.""" -def test_runtime_mirror_adds_destination () -> None: + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub() - """composition.mirror() appends to the running pattern's mirrors list.""" + composition.mirror("drums", device=1, channel=10) - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub() + assert composition._running_patterns["drums"].mirrors == [(1, 9)] - composition.mirror("drums", device=1, channel=10) - assert composition._running_patterns["drums"].mirrors == [(1, 9)] +def test_runtime_mirror_is_idempotent() -> None: + """Adding the same destination twice does not double-fan.""" + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub() -def test_runtime_mirror_is_idempotent () -> None: + composition.mirror("drums", device=1, channel=10) + composition.mirror("drums", device=1, channel=10) - """Adding the same destination twice does not double-fan.""" + assert composition._running_patterns["drums"].mirrors == [(1, 9)] - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub() - composition.mirror("drums", device=1, channel=10) - composition.mirror("drums", device=1, channel=10) +def test_runtime_unmirror_removes_destination() -> None: + """composition.unmirror() removes a single destination.""" - assert composition._running_patterns["drums"].mirrors == [(1, 9)] + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub( + mirrors=[(1, 9), (2, 4)] + ) + composition.unmirror("drums", device=1, channel=10) -def test_runtime_unmirror_removes_destination () -> None: + assert composition._running_patterns["drums"].mirrors == [(2, 4)] - """composition.unmirror() removes a single destination.""" - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub(mirrors=[(1, 9), (2, 4)]) +def test_runtime_unmirror_silent_on_missing_destination() -> None: + """Removing a destination that isn't there is a no-op (idempotent).""" - composition.unmirror("drums", device=1, channel=10) + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub(mirrors=[(1, 9)]) - assert composition._running_patterns["drums"].mirrors == [(2, 4)] + composition.unmirror("drums", device=2, channel=5) # not present — should not raise + assert composition._running_patterns["drums"].mirrors == [(1, 9)] -def test_runtime_unmirror_silent_on_missing_destination () -> None: - """Removing a destination that isn't there is a no-op (idempotent).""" +def test_runtime_unmirror_all_clears_destinations() -> None: + """composition.unmirror_all() clears the mirrors list.""" - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub(mirrors=[(1, 9)]) + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub( + mirrors=[(1, 9), (2, 4)] + ) - composition.unmirror("drums", device=2, channel=5) # not present — should not raise + composition.unmirror_all("drums") - assert composition._running_patterns["drums"].mirrors == [(1, 9)] + assert composition._running_patterns["drums"].mirrors == [] -def test_runtime_unmirror_all_clears_destinations () -> None: +def test_runtime_mirror_unknown_pattern_raises() -> None: + """Operating on a non-existent pattern raises ValueError.""" - """composition.unmirror_all() clears the mirrors list.""" + composition = subsequence.Composition(bpm=120) - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub(mirrors=[(1, 9), (2, 4)]) + with pytest.raises(ValueError, match="not found"): + composition.mirror("unknown", device=1, channel=10) - composition.unmirror_all("drums") + with pytest.raises(ValueError, match="not found"): + composition.unmirror("unknown", device=1, channel=10) - assert composition._running_patterns["drums"].mirrors == [] + with pytest.raises(ValueError, match="not found"): + composition.unmirror_all("unknown") -def test_runtime_mirror_unknown_pattern_raises () -> None: +def test_runtime_mirror_validates_channel() -> None: + """composition.mirror() validates channel via _resolve_channel.""" - """Operating on a non-existent pattern raises ValueError.""" + composition = subsequence.Composition(bpm=120) # 1-indexed + composition._running_patterns["drums"] = _running_pattern_stub() - composition = subsequence.Composition(bpm=120) + with pytest.raises(ValueError, match="MIDI channel must be 1-16"): + composition.mirror("drums", device=1, channel=0) - with pytest.raises(ValueError, match="not found"): - composition.mirror("unknown", device=1, channel=10) - with pytest.raises(ValueError, match="not found"): - composition.unmirror("unknown", device=1, channel=10) +def test_runtime_mirror_warns_on_mirror_to_self( + caplog: pytest.LogCaptureFixture, +) -> None: + """Mirroring a pattern to its own (device, channel) logs a warning.""" - with pytest.raises(ValueError, match="not found"): - composition.unmirror_all("unknown") + import logging + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub( + channel=4 + ) # internal 0-indexed = 4 -def test_runtime_mirror_validates_channel () -> None: + with caplog.at_level(logging.WARNING): + composition.mirror("drums", device=0, channel=5) # 5 → 4 internal == primary - """composition.mirror() validates channel via _resolve_channel.""" + assert any("primary destination" in record.message for record in caplog.records) - composition = subsequence.Composition(bpm=120) # 1-indexed - composition._running_patterns["drums"] = _running_pattern_stub() - with pytest.raises(ValueError, match="MIDI channel must be 1-16"): - composition.mirror("drums", device=1, channel=0) +def test_decorator_warns_on_mirror_to_self(caplog: pytest.LogCaptureFixture) -> None: + """@composition.pattern(mirrors=...) where a mirror equals the primary logs a warning. + Composition default is 1-indexed channels. ``channel=1`` → internal 0; + ``channel=2`` → internal 1. A mirror tuple is ``(device, user_channel)`` + resolved the same way. + """ -def test_runtime_mirror_warns_on_mirror_to_self (caplog: pytest.LogCaptureFixture) -> None: + import logging - """Mirroring a pattern to its own (device, channel) logs a warning.""" + composition = subsequence.Composition(bpm=120) - import logging + with caplog.at_level(logging.WARNING): + # Primary (device=0, channel=0); mirror (device=0, channel=1) — different, no warn. + @composition.pattern(channel=1, beats=4, mirrors=[(0, 2)]) + def ok(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub(channel=4) # internal 0-indexed = 4 + # Primary (device=0, channel=1); mirror (device=0, channel=1) — collision. + @composition.pattern(channel=2, beats=4, mirrors=[(0, 2)]) + def bad(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - with caplog.at_level(logging.WARNING): - composition.mirror("drums", device=0, channel=5) # 5 → 4 internal == primary + # Only the second pattern's mirror collides + warnings = [r for r in caplog.records if "primary destination" in r.message] + assert len(warnings) == 1 - assert any("primary destination" in record.message for record in caplog.records) +def test_runtime_unmirror_debug_log_when_absent( + caplog: pytest.LogCaptureFixture, +) -> None: + """unmirror() on a missing destination logs a debug message (not an error).""" -def test_decorator_warns_on_mirror_to_self (caplog: pytest.LogCaptureFixture) -> None: + import logging - """@composition.pattern(mirrors=...) where a mirror equals the primary logs a warning. + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub(mirrors=[(1, 9)]) - Composition default is 1-indexed channels. ``channel=1`` → internal 0; - ``channel=2`` → internal 1. A mirror tuple is ``(device, user_channel)`` - resolved the same way. - """ + with caplog.at_level(logging.DEBUG): + composition.unmirror("drums", device=2, channel=5) - import logging - - composition = subsequence.Composition(bpm=120) - - with caplog.at_level(logging.WARNING): - # Primary (device=0, channel=0); mirror (device=0, channel=1) — different, no warn. - @composition.pattern(channel=1, beats=4, mirrors=[(0, 2)]) - def ok (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - - # Primary (device=0, channel=1); mirror (device=0, channel=1) — collision. - @composition.pattern(channel=2, beats=4, mirrors=[(0, 2)]) - def bad (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - - # Only the second pattern's mirror collides - warnings = [r for r in caplog.records if "primary destination" in r.message] - assert len(warnings) == 1 - - -def test_runtime_unmirror_debug_log_when_absent (caplog: pytest.LogCaptureFixture) -> None: - - """unmirror() on a missing destination logs a debug message (not an error).""" - - import logging - - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub(mirrors=[(1, 9)]) - - with caplog.at_level(logging.DEBUG): - composition.unmirror("drums", device=2, channel=5) - - assert any("no-op" in record.message for record in caplog.records) + assert any("no-op" in record.message for record in caplog.records) # ── Mute interaction ─────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_mirror_with_muted_pattern_produces_no_events (patch_midi: None) -> None: +async def test_mirror_with_muted_pattern_produces_no_events(patch_midi: None) -> None: + """A muted pattern emits nothing on either the primary or any mirror. - """A muted pattern emits nothing on either the primary or any mirror. + Tests the full path through the sequencer: a muted ``_DecoratorPattern`` + short-circuits in ``_rebuild`` so its event collections stay empty; + ``schedule_pattern`` then has nothing to fan out. Worth pinning so a + future change to mute or to the fan-out doesn't accidentally leak events + to mirrors. + """ - Tests the full path through the sequencer: a muted ``_DecoratorPattern`` - short-circuits in ``_rebuild`` so its event collections stay empty; - ``schedule_pattern`` then has nothing to fan out. Worth pinning so a - future change to mute or to the fan-out doesn't accidentally leak events - to mirrors. - """ + composition = subsequence.Composition(bpm=120) - composition = subsequence.Composition(bpm=120) + @composition.pattern(channel=1, beats=4, mirrors=[(1, 5)]) + def silent(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0, velocity=100, duration=0.5) - @composition.pattern(channel=1, beats=4, mirrors=[(1, 5)]) - def silent (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0, velocity=100, duration=0.5) + pending = composition._pending_patterns[0] + pattern = composition._build_pattern_from_pending(pending) - pending = composition._pending_patterns[0] - pattern = composition._build_pattern_from_pending(pending) + pattern._muted = True + pattern._rebuild() - pattern._muted = True - pattern._rebuild() + await composition._sequencer.schedule_pattern(pattern, start_pulse=0) - await composition._sequencer.schedule_pattern(pattern, start_pulse=0) - - # Sequencer queue should be empty on both destinations. - assert composition._sequencer.event_queue == [] + # Sequencer queue should be empty on both destinations. + assert composition._sequencer.event_queue == [] # ── Symbolic mirror (per-destination drum_note_map) ───────────────────────── -def test_resolve_mirrors_normalizes_three_tuple () -> None: - - """A 3-tuple mirror resolves the channel and preserves the drum map.""" - - composition = subsequence.Composition(bpm=120) # 1-indexed - gm = {"hi_hat_closed": 42} +def test_resolve_mirrors_normalizes_three_tuple() -> None: + """A 3-tuple mirror resolves the channel and preserves the drum map.""" - @composition.pattern(channel=1, beats=4, mirrors=[(1, 10, gm)]) - def drums (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition = subsequence.Composition(bpm=120) # 1-indexed + gm = {"hi_hat_closed": 42} - pending = composition._pending_patterns[0] - assert pending.mirrors == [(1, 9, gm)] # channel 10 → 9, map preserved + @composition.pattern(channel=1, beats=4, mirrors=[(1, 10, gm)]) + def drums(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass + pending = composition._pending_patterns[0] + assert pending.mirrors == [(1, 9, gm)] # channel 10 → 9, map preserved -def test_resolve_mirrors_rejects_non_dict_map () -> None: - """A non-dict third element raises at decoration time.""" +def test_resolve_mirrors_rejects_non_dict_map() -> None: + """A non-dict third element raises at decoration time.""" - composition = subsequence.Composition(bpm=120) + composition = subsequence.Composition(bpm=120) - with pytest.raises(ValueError, match="drum_note_map must be a dict"): - @composition.pattern(channel=1, beats=4, mirrors=[(1, 10, "not a dict")]) # type: ignore[list-item] - def bad (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + with pytest.raises(ValueError, match="drum_note_map must be a dict"): + @composition.pattern(channel=1, beats=4, mirrors=[(1, 10, "not a dict")]) # type: ignore[list-item] + def bad(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass -def test_destination_pitch_helper () -> None: - """``_destination_pitch`` covers every branch incl. drop (None) and primary_unmapped.""" +def test_destination_pitch_helper() -> None: + """``_destination_pitch`` covers every branch incl. drop (None) and primary_unmapped.""" - named = subsequence.pattern.Note(pitch=44, velocity=100, duration=6, channel=0, origin="hi_hat_closed") - unnamed = subsequence.pattern.Note(pitch=44, velocity=100, duration=6, channel=0, origin=None) - other = subsequence.pattern.Note(pitch=44, velocity=100, duration=6, channel=0, origin="clap") - unmapped = subsequence.pattern.Note(pitch=49, velocity=100, duration=6, channel=0, origin="crash", primary_unmapped=True) + named = subsequence.pattern.Note( + pitch=44, velocity=100, duration=6, channel=0, origin="hi_hat_closed" + ) + unnamed = subsequence.pattern.Note( + pitch=44, velocity=100, duration=6, channel=0, origin=None + ) + other = subsequence.pattern.Note( + pitch=44, velocity=100, duration=6, channel=0, origin="clap" + ) + unmapped = subsequence.pattern.Note( + pitch=49, + velocity=100, + duration=6, + channel=0, + origin="crash", + primary_unmapped=True, + ) - with_map = subsequence.sequencer._MirrorTarget(1, 5, {"hi_hat_closed": 42}) - crash_map = subsequence.sequencer._MirrorTarget(1, 5, {"crash": 49}) - no_map = subsequence.sequencer._MirrorTarget(1, 5, None) + with_map = subsequence.sequencer._MirrorTarget(1, 5, {"hi_hat_closed": 42}) + crash_map = subsequence.sequencer._MirrorTarget(1, 5, {"crash": 49}) + no_map = subsequence.sequencer._MirrorTarget(1, 5, None) - dp = subsequence.sequencer._destination_pitch + dp = subsequence.sequencer._destination_pitch - # Primary uses the already-resolved pitch. - assert dp(named, with_map, primary=True) == 44 - # Mirror + map + matching name → re-resolved. - assert dp(named, with_map, primary=False) == 42 - # Mirror + map + name absent → DROP (not a wrong number). - assert dp(other, with_map, primary=False) is None - # Mirror + map + no origin → copy the literal pitch. - assert dp(unnamed, with_map, primary=False) == 44 - # Mirror + no map (2-tuple) → copy the literal pitch. - assert dp(named, no_map, primary=False) == 44 - # primary_unmapped: primary and 2-tuple mirrors drop it; only a mirror whose - # map contains the name voices it. - assert dp(unmapped, crash_map, primary=True) is None - assert dp(unmapped, no_map, primary=False) is None - assert dp(unmapped, crash_map, primary=False) == 49 - assert dp(unmapped, with_map, primary=False) is None # mirror lacks "crash" → drop + # Primary uses the already-resolved pitch. + assert dp(named, with_map, primary=True) == 44 + # Mirror + map + matching name → re-resolved. + assert dp(named, with_map, primary=False) == 42 + # Mirror + map + name absent → DROP (not a wrong number). + assert dp(other, with_map, primary=False) is None + # Mirror + map + no origin → copy the literal pitch. + assert dp(unnamed, with_map, primary=False) == 44 + # Mirror + no map (2-tuple) → copy the literal pitch. + assert dp(named, no_map, primary=False) == 44 + # primary_unmapped: primary and 2-tuple mirrors drop it; only a mirror whose + # map contains the name voices it. + assert dp(unmapped, crash_map, primary=True) is None + assert dp(unmapped, no_map, primary=False) is None + assert dp(unmapped, crash_map, primary=False) == 49 + assert dp(unmapped, with_map, primary=False) is None # mirror lacks "crash" → drop @pytest.mark.asyncio -async def test_symbolic_mirror_fans_out_per_device_map (patch_midi: None) -> None: +async def test_symbolic_mirror_fans_out_per_device_map(patch_midi: None) -> None: + """The headline case: one named hit, two device-specific notes. - """The headline case: one named hit, two device-specific notes. + A closed hi-hat is note 44 on the primary (DRM1) and 42 on the mirror (GM). + """ - A closed hi-hat is note 44 on the primary (DRM1) and 42 on the mirror (GM). - """ + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5, {"hi_hat_closed": 42})] + ) + pattern.add_note( + position=0, pitch=44, velocity=100, duration=12, origin="hi_hat_closed" + ) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5, {"hi_hat_closed": 42})]) - pattern.add_note(position=0, pitch=44, velocity=100, duration=12, origin="hi_hat_closed") + await sequencer.schedule_pattern(pattern, start_pulse=0) - await sequencer.schedule_pattern(pattern, start_pulse=0) + note_ons = [e for e in sequencer.event_queue if e.message_type == "note_on"] + by_device = {e.device: e for e in note_ons} - note_ons = [e for e in sequencer.event_queue if e.message_type == 'note_on'] - by_device = {e.device: e for e in note_ons} - - assert by_device[0].note == 44 # primary: the DRM1's resolved note - assert by_device[1].note == 42 # mirror: re-resolved via the GM map + assert by_device[0].note == 44 # primary: the DRM1's resolved note + assert by_device[1].note == 42 # mirror: re-resolved via the GM map @pytest.mark.asyncio -async def test_symbolic_mirror_drops_when_name_absent (patch_midi: None) -> None: - - """A name absent from the mirror's map is dropped on the mirror (not a wrong note).""" +async def test_symbolic_mirror_drops_when_name_absent(patch_midi: None) -> None: + """A name absent from the mirror's map is dropped on the mirror (not a wrong note).""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5, {"hi_hat_closed": 42})]) - pattern.add_note(position=0, pitch=44, velocity=100, duration=12, origin="clap") # not in mirror map + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5, {"hi_hat_closed": 42})] + ) + pattern.add_note( + position=0, pitch=44, velocity=100, duration=12, origin="clap" + ) # not in mirror map - await sequencer.schedule_pattern(pattern, start_pulse=0) + await sequencer.schedule_pattern(pattern, start_pulse=0) - note_ons = [e for e in sequencer.event_queue if e.message_type == 'note_on'] - by_device = {e.device: e for e in note_ons} + note_ons = [e for e in sequencer.event_queue if e.message_type == "note_on"] + by_device = {e.device: e for e in note_ons} - assert by_device[0].note == 44 # primary plays it - assert 1 not in by_device # mirror has no voice for "clap" → silent + assert by_device[0].note == 44 # primary plays it + assert 1 not in by_device # mirror has no voice for "clap" → silent @pytest.mark.asyncio -async def test_symbolic_mirror_drop_logs_debug (patch_midi: None, caplog: pytest.LogCaptureFixture) -> None: +async def test_symbolic_mirror_drop_logs_debug( + patch_midi: None, caplog: pytest.LogCaptureFixture +) -> None: + """A per-mirror drop is surfaced at debug level — the diagnostic for a voice + the primary plays but a mirror's map lacks (the build-time warning only + covers a name absent from *every* destination).""" - """A per-mirror drop is surfaced at debug level — the diagnostic for a voice - the primary plays but a mirror's map lacks (the build-time warning only - covers a name absent from *every* destination).""" + import logging - import logging + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5, {"hi_hat_closed": 42})] + ) + pattern.add_note( + position=0, pitch=44, velocity=100, duration=12, origin="clap" + ) # absent from the mirror map - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5, {"hi_hat_closed": 42})]) - pattern.add_note(position=0, pitch=44, velocity=100, duration=12, origin="clap") # absent from the mirror map + with caplog.at_level(logging.DEBUG): + await sequencer.schedule_pattern(pattern, start_pulse=0) - with caplog.at_level(logging.DEBUG): - await sequencer.schedule_pattern(pattern, start_pulse=0) - - assert any("clap" in r.message and "no voice" in r.message for r in caplog.records) + assert any("clap" in r.message and "no voice" in r.message for r in caplog.records) @pytest.mark.asyncio -async def test_symbolic_mirror_crash_authored_on_primary_plays_only_on_mirror (patch_midi: None) -> None: - - """End-to-end (builder → schedule): a voice the primary lacks but a mirror maps is - silent on the primary and sounds on the mirror — the faithful-core scenario. - - Also exercises the other direction: the kick (absent from this mirror's map) - is dropped on the mirror. - """ - - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5, {"kick": 36, "crash": 49})]) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0, drum_note_map={"kick": 36}, default_grid=16) - builder.note("kick", beat=0) - builder.note("crash", beat=0) # DRM1-like primary has no crash; the GM-like mirror does - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - await sequencer.schedule_pattern(pattern, start_pulse=0) - - note_ons = [e for e in sequencer.event_queue if e.message_type == 'note_on'] - primary = sorted(e.note for e in note_ons if e.device == 0) - mirror = sorted(e.note for e in note_ons if e.device == 1) - - assert primary == [36] # kick only — the crash has no primary voice - assert mirror == [36, 49] # the mirror plays both the kick and the crash +async def test_symbolic_mirror_crash_authored_on_primary_plays_only_on_mirror( + patch_midi: None, +) -> None: + """End-to-end (builder → schedule): a voice the primary lacks but a mirror maps is + silent on the primary and sounds on the mirror — the faithful-core scenario. + + Also exercises the other direction: the kick (absent from this mirror's map) + is dropped on the mirror. + """ + + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5, {"kick": 36, "crash": 49})] + ) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, drum_note_map={"kick": 36}, default_grid=16 + ) + builder.note("kick", beat=0) + builder.note( + "crash", beat=0 + ) # DRM1-like primary has no crash; the GM-like mirror does + + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + await sequencer.schedule_pattern(pattern, start_pulse=0) + + note_ons = [e for e in sequencer.event_queue if e.message_type == "note_on"] + primary = sorted(e.note for e in note_ons if e.device == 0) + mirror = sorted(e.note for e in note_ons if e.device == 1) + + assert primary == [36] # kick only — the crash has no primary voice + assert mirror == [36, 49] # the mirror plays both the kick and the crash @pytest.mark.asyncio -async def test_symbolic_mirror_two_tuple_no_translation (patch_midi: None) -> None: - - """A plain 2-tuple mirror copies the number even when the note is named.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0, mirrors=[(1, 5)]) - pattern.add_note(position=0, pitch=44, velocity=100, duration=12, origin="hi_hat_closed") - - await sequencer.schedule_pattern(pattern, start_pulse=0) - - note_ons = [e for e in sequencer.event_queue if e.message_type == 'note_on'] - by_device = {e.device: e for e in note_ons} +async def test_symbolic_mirror_two_tuple_no_translation(patch_midi: None) -> None: + """A plain 2-tuple mirror copies the number even when the note is named.""" - assert by_device[0].note == 44 - assert by_device[1].note == 44 # legacy behaviour, unchanged + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, length=4, device=0, mirrors=[(1, 5)] + ) + pattern.add_note( + position=0, pitch=44, velocity=100, duration=12, origin="hi_hat_closed" + ) + await sequencer.schedule_pattern(pattern, start_pulse=0) -def test_runtime_mirror_with_drum_note_map () -> None: + note_ons = [e for e in sequencer.event_queue if e.message_type == "note_on"] + by_device = {e.device: e for e in note_ons} - """composition.mirror(..., drum_note_map=...) stores a 3-tuple entry.""" + assert by_device[0].note == 44 + assert by_device[1].note == 44 # legacy behaviour, unchanged - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub() - gm = {"hi_hat_closed": 42} - composition.mirror("drums", device=1, channel=10, drum_note_map=gm) +def test_runtime_mirror_with_drum_note_map() -> None: + """composition.mirror(..., drum_note_map=...) stores a 3-tuple entry.""" - assert composition._running_patterns["drums"].mirrors == [(1, 9, gm)] + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub() + gm = {"hi_hat_closed": 42} + composition.mirror("drums", device=1, channel=10, drum_note_map=gm) -def test_runtime_unmirror_ignores_map () -> None: + assert composition._running_patterns["drums"].mirrors == [(1, 9, gm)] - """unmirror() removes a 3-tuple entry matching on (device, channel) only.""" - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub(mirrors=[(1, 9, {"hi_hat_closed": 42})]) +def test_runtime_unmirror_ignores_map() -> None: + """unmirror() removes a 3-tuple entry matching on (device, channel) only.""" - composition.unmirror("drums", device=1, channel=10) # no map argument + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub( + mirrors=[(1, 9, {"hi_hat_closed": 42})] + ) - assert composition._running_patterns["drums"].mirrors == [] + composition.unmirror("drums", device=1, channel=10) # no map argument + assert composition._running_patterns["drums"].mirrors == [] -def test_runtime_mirror_updates_map_in_place () -> None: - """Re-mirroring the same (device, channel) with a new map re-points it.""" +def test_runtime_mirror_updates_map_in_place() -> None: + """Re-mirroring the same (device, channel) with a new map re-points it.""" - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub() + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub() - composition.mirror("drums", device=1, channel=10, drum_note_map={"hi_hat_closed": 42}) - composition.mirror("drums", device=1, channel=10, drum_note_map={"hi_hat_closed": 99}) + composition.mirror( + "drums", device=1, channel=10, drum_note_map={"hi_hat_closed": 42} + ) + composition.mirror( + "drums", device=1, channel=10, drum_note_map={"hi_hat_closed": 99} + ) - mirrors = composition._running_patterns["drums"].mirrors - assert len(mirrors) == 1 - assert mirrors[0] == (1, 9, {"hi_hat_closed": 99}) + mirrors = composition._running_patterns["drums"].mirrors + assert len(mirrors) == 1 + assert mirrors[0] == (1, 9, {"hi_hat_closed": 99}) diff --git a/tests/test_motif_placement.py b/tests/test_motif_placement.py index e2d96b1..cfe4613 100644 --- a/tests/test_motif_placement.py +++ b/tests/test_motif_placement.py @@ -24,463 +24,474 @@ PPQ = subsequence.constants.MIDI_QUARTER_NOTE -def _builder (key: str = "A", scale: str = "minor", length: float = 4.0, seed: int = 1, **kwargs) -> subsequence.pattern_builder.PatternBuilder: +def _builder( + key: str = "A", scale: str = "minor", length: float = 4.0, seed: int = 1, **kwargs +) -> subsequence.pattern_builder.PatternBuilder: + """A standalone builder over a fresh pattern (no engine required).""" - """A standalone builder over a fresh pattern (no engine required).""" + pattern = subsequence.pattern.Pattern(channel=0, length=length) - pattern = subsequence.pattern.Pattern(channel=0, length=length) + return subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + key=key, + scale=scale, + rng=random.Random(seed), + **kwargs, + ) - return subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - key = key, - scale = scale, - rng = random.Random(seed), - **kwargs, - ) +def _placed(p: subsequence.pattern_builder.PatternBuilder) -> list: + """(beat, pitch) pairs for every placed note, in pulse order.""" -def _placed (p: subsequence.pattern_builder.PatternBuilder) -> list: + out = [] - """(beat, pitch) pairs for every placed note, in pulse order.""" + for pulse in sorted(p._pattern.steps): + for note in p._pattern.steps[pulse].notes: + out.append((pulse / PPQ, note.pitch)) - out = [] - - for pulse in sorted(p._pattern.steps): - for note in p._pattern.steps[pulse].notes: - out.append((pulse / PPQ, note.pitch)) - - return out + return out # ── degree resolution ─────────────────────────────────────────────────────── -def test_degrees_resolve_against_key_and_scale () -> None: - - """A minor, root=60: tonic anchors at A3 (57); degrees keep their contour.""" - - p = _builder(key="A", scale="minor") - p.motif(subsequence.motif([1, 5, 8], durations=0.5), root=60) - - assert _placed(p) == [(0.0, 57), (1.0, 64), (2.0, 69)] - - -def test_degrees_follow_the_key () -> None: - - """The same value sounds in the new key — relative content, late resolution.""" - c_major = _builder(key="C", scale="major") - c_major.motif(subsequence.motif([1, 3, 5], durations=0.5), root=60) +def test_degrees_resolve_against_key_and_scale() -> None: + """A minor, root=60: tonic anchors at A3 (57); degrees keep their contour.""" - assert [pitch for _, pitch in _placed(c_major)] == [60, 64, 67] + p = _builder(key="A", scale="minor") + p.motif(subsequence.motif([1, 5, 8], durations=0.5), root=60) - d_minor = _builder(key="D", scale="minor") - d_minor.motif(subsequence.motif([1, 3, 5], durations=0.5), root=60) + assert _placed(p) == [(0.0, 57), (1.0, 64), (2.0, 69)] - assert [pitch for _, pitch in _placed(d_minor)] == [62, 65, 69] +def test_degrees_follow_the_key() -> None: + """The same value sounds in the new key — relative content, late resolution.""" -def test_degree_octave_and_chroma () -> None: + c_major = _builder(key="C", scale="major") + c_major.motif(subsequence.motif([1, 3, 5], durations=0.5), root=60) - """Degree(step, octave=, chroma=) shifts after scale lookup.""" + assert [pitch for _, pitch in _placed(c_major)] == [60, 64, 67] - p = _builder(key="C", scale="major") - p.motif(M.degrees([Degree(5, octave=1), Degree(5, chroma=1)], durations=0.5), root=60) + d_minor = _builder(key="D", scale="minor") + d_minor.motif(subsequence.motif([1, 3, 5], durations=0.5), root=60) - assert [pitch for _, pitch in _placed(p)] == [79, 68] + assert [pitch for _, pitch in _placed(d_minor)] == [62, 65, 69] -def test_scale_defaults_to_ionian () -> None: +def test_degree_octave_and_chroma() -> None: + """Degree(step, octave=, chroma=) shifts after scale lookup.""" - """No scale set means major — the documented default.""" + p = _builder(key="C", scale="major") + p.motif( + M.degrees([Degree(5, octave=1), Degree(5, chroma=1)], durations=0.5), root=60 + ) - p = _builder(key="C", scale=None) - p.motif(subsequence.motif([3], durations=0.5), root=60) + assert [pitch for _, pitch in _placed(p)] == [79, 68] - assert [pitch for _, pitch in _placed(p)] == [64] +def test_scale_defaults_to_ionian() -> None: + """No scale set means major — the documented default.""" -def test_degrees_without_key_raise () -> None: + p = _builder(key="C", scale=None) + p.motif(subsequence.motif([3], durations=0.5), root=60) - """Degrees are relative; placing them with no key is a configuration error.""" + assert [pitch for _, pitch in _placed(p)] == [64] - p = _builder(key=None, scale=None) - with pytest.raises(ValueError, match="key"): - p.motif(subsequence.motif([1])) +def test_degrees_without_key_raise() -> None: + """Degrees are relative; placing them with no key is a configuration error.""" + p = _builder(key=None, scale=None) -def test_unknown_scale_teaches () -> None: + with pytest.raises(ValueError, match="key"): + p.motif(subsequence.motif([1])) - """Unknown mode names raise the registry's teaching error.""" - p = _builder(key="C", scale="klingon") +def test_unknown_scale_teaches() -> None: + """Unknown mode names raise the registry's teaching error.""" - with pytest.raises(ValueError, match="register_scale"): - p.motif(subsequence.motif([1])) + p = _builder(key="C", scale="klingon") + with pytest.raises(ValueError, match="register_scale"): + p.motif(subsequence.motif([1])) -def test_midi_ints_pass_through_and_drums_use_the_funnel () -> None: - """Absolute notes place as-is; drum names resolve through drum_note_map.""" +def test_midi_ints_pass_through_and_drums_use_the_funnel() -> None: + """Absolute notes place as-is; drum names resolve through drum_note_map.""" - p = _builder(drum_note_map={"kick": 36}) - p.motif(M.notes([72], durations=0.5) & M.hits("kick", beats=[1.0], length=4)) + p = _builder(drum_note_map={"kick": 36}) + p.motif(M.notes([72], durations=0.5) & M.hits("kick", beats=[1.0], length=4)) - assert _placed(p) == [(0.0, 72), (1.0, 36)] + assert _placed(p) == [(0.0, 72), (1.0, 36)] -def test_harmony_dependent_specs_raise_clearly_without_the_clock () -> None: +def test_harmony_dependent_specs_raise_clearly_without_the_clock() -> None: + """ChordTone — and an Approach AT a chord tone — need the clock; degree targets do not.""" - """ChordTone — and an Approach AT a chord tone — need the clock; degree targets do not.""" + p = _builder() - p = _builder() + with pytest.raises(ValueError, match="harmonic clock"): + p.motif(M.hits("kick", beats=[0], length=1).pitched("root")) - with pytest.raises(ValueError, match="harmonic clock"): - p.motif(M.hits("kick", beats=[0], length=1).pitched("root")) + with pytest.raises(ValueError, match="harmonic clock"): + p.motif( + M.from_events( + [subsequence.MotifEvent(beat=0.0, pitch=Approach(ChordTone("root")))], + length=1, + ) + ) - with pytest.raises(ValueError, match="harmonic clock"): - p.motif(M.from_events([subsequence.MotifEvent(beat=0.0, pitch=Approach(ChordTone("root")))], length=1)) + # A Degree target resolves with no harmony at all: one semitone below it. + p.motif( + M.from_events( + [subsequence.MotifEvent(beat=0.0, pitch=Approach(Degree(1)))], length=1 + ) + ) + assert _placed(p) == [(0.0, 56)] # A minor tonic at 57, approached from below - # A Degree target resolves with no harmony at all: one semitone below it. - p.motif(M.from_events([subsequence.MotifEvent(beat=0.0, pitch=Approach(Degree(1)))], length=1)) - assert _placed(p) == [(0.0, 56)] # A minor tonic at 57, approached from below +def test_approach_below_midi_zero_raises_clearly() -> None: + """An Approach that resolves below MIDI 0 fails loudly, like the degree/chord-tone resolvers.""" -def test_approach_below_midi_zero_raises_clearly () -> None: + p = _builder() - """An Approach that resolves below MIDI 0 fails loudly, like the degree/chord-tone resolvers.""" - - p = _builder() - - # An int target of 0 leaves the leading-tone approach at -1 — out of range. - with pytest.raises(ValueError, match="outside 0"): - p.motif(M.from_events([subsequence.MotifEvent(beat=0.0, pitch=Approach(0))], length=1)) + # An int target of 0 leaves the leading-tone approach at -1 — out of range. + with pytest.raises(ValueError, match="outside 0"): + p.motif( + M.from_events( + [subsequence.MotifEvent(beat=0.0, pitch=Approach(0))], length=1 + ) + ) class _StubHarmony: + """A HarmonyView stand-in: chords keyed by cycle beat ranges.""" - """A HarmonyView stand-in: chords keyed by cycle beat ranges.""" - - def __init__ (self, spans): - self._spans = spans # list of (start, end, chord) - - @property - def chord (self): - return self.chord_at(0.0) + def __init__(self, spans): + self._spans = spans # list of (start, end, chord) - def chord_at (self, beat): - for start, end, chord in self._spans: - if start <= beat < end: - return chord - return None + @property + def chord(self): + return self.chord_at(0.0) + def chord_at(self, beat): + for start, end, chord in self._spans: + if start <= beat < end: + return chord + return None -def test_chord_tone_resolves_against_the_chord_under_the_event () -> None: - """ChordTone indices voice the chord sounding at the EVENT's beat, not the cycle start.""" +def test_chord_tone_resolves_against_the_chord_under_the_event() -> None: + """ChordTone indices voice the chord sounding at the EVENT's beat, not the cycle start.""" - a_minor = subsequence.chords.parse_chord("Am") - f_major = subsequence.chords.parse_chord("F") - view = _StubHarmony([(0.0, 2.0, a_minor), (2.0, 4.0, f_major)]) + a_minor = subsequence.chords.parse_chord("Am") + f_major = subsequence.chords.parse_chord("F") + view = _StubHarmony([(0.0, 2.0, a_minor), (2.0, 4.0, f_major)]) - p = _builder(harmony=view) - value = M.from_events([ - subsequence.MotifEvent(beat=0.0, pitch=ChordTone("root")), - subsequence.MotifEvent(beat=1.0, pitch=ChordTone("third")), - subsequence.MotifEvent(beat=2.0, pitch=ChordTone("root")), - ], length=4) + p = _builder(harmony=view) + value = M.from_events( + [ + subsequence.MotifEvent(beat=0.0, pitch=ChordTone("root")), + subsequence.MotifEvent(beat=1.0, pitch=ChordTone("third")), + subsequence.MotifEvent(beat=2.0, pitch=ChordTone("root")), + ], + length=4, + ) - p.motif(value, root=60) + p.motif(value, root=60) - placed = _placed(p) - assert placed == [(0.0, 57), (1.0, 60), (2.0, 65)] # A3, C4 (Am), then F4 (F) + placed = _placed(p) + assert placed == [(0.0, 57), (1.0, 60), (2.0, 65)] # A3, C4 (Am), then F4 (F) -def test_chord_tone_octave_and_cycling () -> None: +def test_chord_tone_octave_and_cycling() -> None: + """Indices past the chord's size cycle into higher octaves; octave= shifts whole octaves.""" - """Indices past the chord's size cycle into higher octaves; octave= shifts whole octaves.""" + a_minor = subsequence.chords.parse_chord("Am") + view = _StubHarmony([(0.0, 4.0, a_minor)]) - a_minor = subsequence.chords.parse_chord("Am") - view = _StubHarmony([(0.0, 4.0, a_minor)]) + p = _builder(harmony=view) + p.motif( + M.from_events( + [ + subsequence.MotifEvent(beat=0.0, pitch=ChordTone(4)), + subsequence.MotifEvent(beat=1.0, pitch=ChordTone("root", octave=1)), + ], + length=4, + ), + root=60, + ) - p = _builder(harmony=view) - p.motif(M.from_events([ - subsequence.MotifEvent(beat=0.0, pitch=ChordTone(4)), - subsequence.MotifEvent(beat=1.0, pitch=ChordTone("root", octave=1)), - ], length=4), root=60) + assert _placed(p) == [(0.0, 69), (1.0, 69)] # A4: the cycled 4th tone; A3 + 12 - assert _placed(p) == [(0.0, 69), (1.0, 69)] # A4: the cycled 4th tone; A3 + 12 +def test_chord_tone_outside_window_raises() -> None: + """An event beat the window does not cover fails with the window message.""" -def test_chord_tone_outside_window_raises () -> None: + view = _StubHarmony([(0.0, 1.0, subsequence.chords.parse_chord("Am"))]) - """An event beat the window does not cover fails with the window message.""" + p = _builder(harmony=view) - view = _StubHarmony([(0.0, 1.0, subsequence.chords.parse_chord("Am"))]) + with pytest.raises(ValueError, match="window"): + p.motif( + M.from_events( + [subsequence.MotifEvent(beat=2.0, pitch=ChordTone(1))], length=4 + ) + ) - p = _builder(harmony=view) - with pytest.raises(ValueError, match="window"): - p.motif(M.from_events([subsequence.MotifEvent(beat=2.0, pitch=ChordTone(1))], length=4)) +def test_skeleton_placement_raises() -> None: + """A rhythm() skeleton must be re-pitched before placing.""" + p = _builder() -def test_skeleton_placement_raises () -> None: - - """A rhythm() skeleton must be re-pitched before placing.""" - - p = _builder() - - with pytest.raises(ValueError, match="pitched"): - p.motif(M.notes([60]).rhythm()) + with pytest.raises(ValueError, match="pitched"): + p.motif(M.notes([60]).rhythm()) # ── placement mechanics ───────────────────────────────────────────────────── -def test_placement_homomorphism () -> None: - - """Placing a & b equals placing a then b — the builder layers.""" - a = M.notes([60], durations=0.5) - b = M.notes([72], beats=[1.0], durations=0.5) +def test_placement_homomorphism() -> None: + """Placing a & b equals placing a then b — the builder layers.""" - merged = _builder() - merged.motif(a & b) + a = M.notes([60], durations=0.5) + b = M.notes([72], beats=[1.0], durations=0.5) - separate = _builder() - separate.motif(a) - separate.motif(b) + merged = _builder() + merged.motif(a & b) - assert _placed(merged) == _placed(separate) + separate = _builder() + separate.motif(a) + separate.motif(b) + assert _placed(merged) == _placed(separate) -def test_beat_offset_shifts_the_whole_motif () -> None: - """beat= is the motif's start within the pattern.""" +def test_beat_offset_shifts_the_whole_motif() -> None: + """beat= is the motif's start within the pattern.""" - p = _builder() - p.motif(M.notes([60, 62], durations=0.5), beat=1.0) + p = _builder() + p.motif(M.notes([60, 62], durations=0.5), beat=1.0) - assert [beat for beat, _ in _placed(p)] == [1.0, 2.0] + assert [beat for beat, _ in _placed(p)] == [1.0, 2.0] -def test_span_clamps_late_events () -> None: +def test_span_clamps_late_events() -> None: + """Events at or beyond span are dropped — the arpeggio convention.""" - """Events at or beyond span are dropped — the arpeggio convention.""" + p = _builder() + p.motif(M.notes([60, 62, 64, 65], durations=0.5), span=2.0) - p = _builder() - p.motif(M.notes([60, 62, 64, 65], durations=0.5), span=2.0) + assert [pitch for _, pitch in _placed(p)] == [60, 62] - assert [pitch for _, pitch in _placed(p)] == [60, 62] +def test_velocity_override() -> None: + """velocity= replaces every event's velocity at placement.""" -def test_velocity_override () -> None: + p = _builder() + p.motif(M.notes([60], velocities=80, durations=0.5), velocity=127) - """velocity= replaces every event's velocity at placement.""" + assert p._pattern.steps[0].notes[0].velocity == 127 - p = _builder() - p.motif(M.notes([60], velocities=80, durations=0.5), velocity=127) - assert p._pattern.steps[0].notes[0].velocity == 127 +def test_probability_rolls_on_the_pattern_stream() -> None: + """probability=0 never sounds; probability=1 always does; rolls use p.rng.""" + p = _builder() + p.motif(M.notes([60], durations=0.5, probabilities=0.0)) + p.motif(M.notes([72], beats=[1.0], durations=0.5, probabilities=1.0)) -def test_probability_rolls_on_the_pattern_stream () -> None: + assert [pitch for _, pitch in _placed(p)] == [72] - """probability=0 never sounds; probability=1 always does; rolls use p.rng.""" - p = _builder() - p.motif(M.notes([60], durations=0.5, probabilities=0.0)) - p.motif(M.notes([72], beats=[1.0], durations=0.5, probabilities=1.0)) +def test_motif_returns_self_for_chaining() -> None: + """Placement verbs chain like every other builder verb.""" - assert [pitch for _, pitch in _placed(p)] == [72] + p = _builder() + assert p.motif(M.notes([60], durations=0.5)) is p -def test_motif_returns_self_for_chaining () -> None: - """Placement verbs chain like every other builder verb.""" +def test_duck_typing_rejects_non_values() -> None: + """Anything without .events/.length is not placeable.""" - p = _builder() + p = _builder() - assert p.motif(M.notes([60], durations=0.5)) is p - - -def test_duck_typing_rejects_non_values () -> None: - - """Anything without .events/.length is not placeable.""" - - p = _builder() - - with pytest.raises(TypeError, match="events"): - p.motif([60, 62]) + with pytest.raises(TypeError, match="events"): + p.motif([60, 62]) # ── control gestures ──────────────────────────────────────────────────────── -def test_control_gestures_emit_through_the_cc_machinery () -> None: - - """A packaged sweep lands as ordinary cc_events at placement.""" - - p = _builder(cc_name_map={"cutoff": 74}) - acid = M.notes([60], durations=0.5) & M.cc_ramp("cutoff", 20, 110, beat_end=4) - p.motif(acid) - - cc = [e for e in p._pattern.cc_events if e.message_type == "control_change"] - assert cc, "the sweep emitted no CC events" - assert all(e.control == 74 for e in cc) - assert cc[0].value == 20 and cc[-1].value == 110 +def test_control_gestures_emit_through_the_cc_machinery() -> None: + """A packaged sweep lands as ordinary cc_events at placement.""" + p = _builder(cc_name_map={"cutoff": 74}) + acid = M.notes([60], durations=0.5) & M.cc_ramp("cutoff", 20, 110, beat_end=4) + p.motif(acid) -def test_discrete_control_write () -> None: + cc = [e for e in p._pattern.cc_events if e.message_type == "control_change"] - """A discrete CC event lands at its pulse with its value.""" + assert cc, "the sweep emitted no CC events" + assert all(e.control == 74 for e in cc) + assert cc[0].value == 20 and cc[-1].value == 110 - p = _builder() - p.motif(M.cc(74, [64], beats=[2.0]), beat=1.0) - cc = p._pattern.cc_events +def test_discrete_control_write() -> None: + """A discrete CC event lands at its pulse with its value.""" - assert len(cc) == 1 - assert cc[0].pulse == int(3.0 * PPQ) - assert cc[0].value == 64 + p = _builder() + p.motif(M.cc(74, [64], beats=[2.0]), beat=1.0) + cc = p._pattern.cc_events -def test_pitch_bend_gesture () -> None: + assert len(cc) == 1 + assert cc[0].pulse == int(3.0 * PPQ) + assert cc[0].value == 64 - """A bend dive emits pitchwheel events ending at the target.""" - p = _builder() - p.motif(M.pitch_bend_ramp(0.0, -0.5, beat_start=3.5, beat_end=4.0)) +def test_pitch_bend_gesture() -> None: + """A bend dive emits pitchwheel events ending at the target.""" - bends = [e for e in p._pattern.cc_events if e.message_type == "pitchwheel"] + p = _builder() + p.motif(M.pitch_bend_ramp(0.0, -0.5, beat_start=3.5, beat_end=4.0)) - assert bends - assert bends[-1].value == int(round(-0.5 * 8192)) + bends = [e for e in p._pattern.cc_events if e.message_type == "pitchwheel"] + assert bends + assert bends[-1].value == int(round(-0.5 * 8192)) -def test_nrpn_gesture_carries_flags () -> None: - """NRPN writes emit the select/data burst (multiple CC messages).""" +def test_nrpn_gesture_carries_flags() -> None: + """NRPN writes emit the select/data burst (multiple CC messages).""" - p = _builder() - p.motif(M.nrpn(9, [700], beats=[0.0], fine=True)) + p = _builder() + p.motif(M.nrpn(9, [700], beats=[0.0], fine=True)) - controls = [e.control for e in p._pattern.cc_events] + controls = [e.control for e in p._pattern.cc_events] - assert 99 in controls and 98 in controls and 6 in controls and 38 in controls + assert 99 in controls and 98 in controls and 6 in controls and 38 in controls -def test_control_probability_rolls () -> None: +def test_control_probability_rolls() -> None: + """A gesture with probability 0 never emits.""" - """A gesture with probability 0 never emits.""" + p = _builder() + p.motif(M.cc(74, [64], beats=[0.0], probabilities=0.0)) - p = _builder() - p.motif(M.cc(74, [64], beats=[0.0], probabilities=0.0)) + assert p._pattern.cc_events == [] - assert p._pattern.cc_events == [] +def test_resolution_override_thins_ramp_traffic() -> None: + """resolution= at the placement call reduces message density.""" -def test_resolution_override_thins_ramp_traffic () -> None: + dense = _builder() + dense.motif(M.cc_ramp(74, 0, 127, beat_end=4)) - """resolution= at the placement call reduces message density.""" + sparse = _builder() + sparse.motif(M.cc_ramp(74, 0, 127, beat_end=4), resolution=8) - dense = _builder() - dense.motif(M.cc_ramp(74, 0, 127, beat_end=4)) + assert len(sparse._pattern.cc_events) < len(dense._pattern.cc_events) - sparse = _builder() - sparse.motif(M.cc_ramp(74, 0, 127, beat_end=4), resolution=8) - assert len(sparse._pattern.cc_events) < len(dense._pattern.cc_events) +def test_fit_is_accepted() -> None: + """fit= parses today; the dial activates with a harmonic context later.""" + p = _builder() + p.motif(subsequence.motif([1], durations=0.5), fit=0.8) -def test_fit_is_accepted () -> None: - - """fit= parses today; the dial activates with a harmonic context later.""" - - p = _builder() - p.motif(subsequence.motif([1], durations=0.5), fit=0.8) - - assert len(_placed(p)) == 1 + assert len(_placed(p)) == 1 # ── capture ───────────────────────────────────────────────────────────────── -def test_capture_round_trip () -> None: - - """Placed notes read back as an absolute-MIDI motif that replaces identically.""" - source = _builder() - source.motif(M.notes([60, 64, 67], beats=[0.0, 1.5, 3.0], durations=0.5)) +def test_capture_round_trip() -> None: + """Placed notes read back as an absolute-MIDI motif that replaces identically.""" - captured = source.capture(beat=0.0, span=4.0) + source = _builder() + source.motif(M.notes([60, 64, 67], beats=[0.0, 1.5, 3.0], durations=0.5)) - assert captured.length == 4.0 - assert [e.pitch for e in captured.events] == [60, 64, 67] - assert [e.beat for e in captured.events] == [0.0, 1.5, 3.0] + captured = source.capture(beat=0.0, span=4.0) - replay = _builder() - replay.motif(captured) + assert captured.length == 4.0 + assert [e.pitch for e in captured.events] == [60, 64, 67] + assert [e.beat for e in captured.events] == [0.0, 1.5, 3.0] - assert _placed(replay) == _placed(source) + replay = _builder() + replay.motif(captured) + assert _placed(replay) == _placed(source) -def test_capture_is_absolute_even_for_degrees () -> None: - """Relative specs do not survive resolution — capture is lossy by design.""" +def test_capture_is_absolute_even_for_degrees() -> None: + """Relative specs do not survive resolution — capture is lossy by design.""" - p = _builder(key="A", scale="minor") - p.motif(subsequence.motif([1], durations=0.5), root=60) + p = _builder(key="A", scale="minor") + p.motif(subsequence.motif([1], durations=0.5), root=60) - captured = p.capture(beat=0.0, span=4.0) + captured = p.capture(beat=0.0, span=4.0) - assert captured.events[0].pitch == 57 # an int, not a Degree + assert captured.events[0].pitch == 57 # an int, not a Degree -def test_capture_windows () -> None: +def test_capture_windows() -> None: + """Only notes inside the window are captured, re-anchored to zero.""" - """Only notes inside the window are captured, re-anchored to zero.""" + p = _builder() + p.motif(M.notes([60, 62, 64, 65], durations=0.5)) - p = _builder() - p.motif(M.notes([60, 62, 64, 65], durations=0.5)) + window = p.capture(beat=1.0, span=2.0) - window = p.capture(beat=1.0, span=2.0) - - assert [e.pitch for e in window.events] == [62, 64] - assert [e.beat for e in window.events] == [0.0, 1.0] + assert [e.pitch for e in window.events] == [62, 64] + assert [e.beat for e in window.events] == [0.0, 1.0] # ── threading + metric weights ────────────────────────────────────────────── -def test_builder_exposes_scale_and_time_signature () -> None: - - """p.scale and p.time_signature are injected context.""" - - p = _builder(key="A", scale="minor", time_signature=(3, 4)) - assert p.scale == "minor" - assert p.time_signature == (3, 4) +def test_builder_exposes_scale_and_time_signature() -> None: + """p.scale and p.time_signature are injected context.""" + p = _builder(key="A", scale="minor", time_signature=(3, 4)) -def test_metric_weights_four_four () -> None: + assert p.scale == "minor" + assert p.time_signature == (3, 4) - """The 4/4 sixteenth table: downbeat, half-bar, beats, eighths, sixteenths.""" - w = subsequence.sequence_utils.build_metric_weights((4, 4), grid=16) +def test_metric_weights_four_four() -> None: + """The 4/4 sixteenth table: downbeat, half-bar, beats, eighths, sixteenths.""" - assert w == [ - 1.0, 0.125, 0.25, 0.125, - 0.5, 0.125, 0.25, 0.125, - 0.75, 0.125, 0.25, 0.125, - 0.5, 0.125, 0.25, 0.125, - ] + w = subsequence.sequence_utils.build_metric_weights((4, 4), grid=16) + assert w == [ + 1.0, + 0.125, + 0.25, + 0.125, + 0.5, + 0.125, + 0.25, + 0.125, + 0.75, + 0.125, + 0.25, + 0.125, + 0.5, + 0.125, + 0.25, + 0.125, + ] -def test_metric_weights_three_four_has_no_half_bar () -> None: - """Odd meters skip the half-bar tier.""" +def test_metric_weights_three_four_has_no_half_bar() -> None: + """Odd meters skip the half-bar tier.""" - w = subsequence.sequence_utils.build_metric_weights((3, 4), grid=12) + w = subsequence.sequence_utils.build_metric_weights((3, 4), grid=12) - assert w[0] == 1.0 - assert w[4] == w[8] == 0.5 - assert 0.75 not in w + assert w[0] == 1.0 + assert w[4] == w[8] == 0.5 + assert 0.75 not in w diff --git a/tests/test_motifs.py b/tests/test_motifs.py index f51fe78..d66bba5 100644 --- a/tests/test_motifs.py +++ b/tests/test_motifs.py @@ -20,687 +20,639 @@ ControlEvent = subsequence.ControlEvent -def _beats (m: M) -> list: +def _beats(m: M) -> list: + """Onset beats of a motif's note events.""" - """Onset beats of a motif's note events.""" + return [e.beat for e in m.events] - return [e.beat for e in m.events] +def _pitches(m: M) -> list: + """Pitch specs of a motif's note events.""" -def _pitches (m: M) -> list: - - """Pitch specs of a motif's note events.""" - - return [e.pitch for e in m.events] + return [e.pitch for e in m.events] # ── constructors: the standard form ───────────────────────────────────────── -def test_degrees_one_per_beat_with_rests () -> None: - - """Ints are 1-based degrees, one per beat; None is a rest whose slot still advances.""" - - m = M.degrees([5, 6, None, 3]) - - assert _beats(m) == [0.0, 1.0, 3.0] - assert _pitches(m) == [Degree(5), Degree(6), Degree(3)] - assert m.length == 4.0 - - -def test_degrees_durations_default_to_a_full_beat () -> None: - - """A written melody holds each beat slot by default.""" - - m = M.degrees([1, 2]) - - assert all(e.duration == 1.0 for e in m.events) - - -def test_degrees_length_covers_held_final_note () -> None: - - """Length defaults to the next whole beat after the last ring-out.""" - - m = subsequence.motif([1, 3, 5, 6, 5, 3, 2], durations=[1, 1, 1, 1, 1, 1, 2]) - - assert m.length == 8.0 +def test_degrees_one_per_beat_with_rests() -> None: + """Ints are 1-based degrees, one per beat; None is a rest whose slot still advances.""" -def test_degrees_rejects_pasted_midi () -> None: + m = M.degrees([5, 6, None, 3]) - """Implausibly large ints fail loud — they are MIDI notes, not degrees.""" + assert _beats(m) == [0.0, 1.0, 3.0] + assert _pitches(m) == [Degree(5), Degree(6), Degree(3)] + assert m.length == 4.0 - with pytest.raises(ValueError, match="Motif.notes"): - subsequence.motif([60, 62, 64]) +def test_degrees_durations_default_to_a_full_beat() -> None: + """A written melody holds each beat slot by default.""" -def test_degree_steps_are_one_based () -> None: + m = M.degrees([1, 2]) - """Degree 0 does not exist; musicians count from one.""" + assert all(e.duration == 1.0 for e in m.events) - with pytest.raises(ValueError, match="1-based"): - Degree(0) +def test_degrees_length_covers_held_final_note() -> None: + """Length defaults to the next whole beat after the last ring-out.""" -def test_lowercase_factory_is_degrees () -> None: + m = subsequence.motif([1, 3, 5, 6, 5, 3, 2], durations=[1, 1, 1, 1, 1, 1, 2]) - """subsequence.motif([...]) means scale degrees — relative pitch is primary.""" + assert m.length == 8.0 - assert subsequence.motif([1, 5]) == M.degrees([1, 5]) +def test_degrees_rejects_pasted_midi() -> None: + """Implausibly large ints fail loud — they are MIDI notes, not degrees.""" -def test_notes_are_absolute_midi () -> None: + with pytest.raises(ValueError, match="Motif.notes"): + subsequence.motif([60, 62, 64]) - """Motif.notes ints pass through as MIDI.""" - m = M.notes([60, None, 64]) +def test_degree_steps_are_one_based() -> None: + """Degree 0 does not exist; musicians count from one.""" - assert _pitches(m) == [60, 64] - assert _beats(m) == [0.0, 2.0] + with pytest.raises(ValueError, match="1-based"): + Degree(0) -def test_hits_places_one_pitch_at_beats () -> None: +def test_lowercase_factory_is_degrees() -> None: + """subsequence.motif([...]) means scale degrees — relative pitch is primary.""" - """The hit() convention: a drum name at a list of onsets, explicit length.""" + assert subsequence.motif([1, 5]) == M.degrees([1, 5]) - m = M.hits("kick", beats=[0, 1.5, 3], length=4) - assert _pitches(m) == ["kick"] * 3 - assert _beats(m) == [0.0, 1.5, 3.0] - assert m.length == 4.0 +def test_notes_are_absolute_midi() -> None: + """Motif.notes ints pass through as MIDI.""" + m = M.notes([60, None, 64]) -def test_steps_are_grid_indices () -> None: + assert _pitches(m) == [60, 64] + assert _beats(m) == [0.0, 2.0] - """The sequence() convention: 0-based sixteenth-grid indices by default.""" - m = M.steps([0, 4, 8, 12], pitches="kick") +def test_hits_places_one_pitch_at_beats() -> None: + """The hit() convention: a drum name at a list of onsets, explicit length.""" - assert _beats(m) == [0.0, 1.0, 2.0, 3.0] - assert m.length == 4.0 + m = M.hits("kick", beats=[0, 1.5, 3], length=4) + assert _pitches(m) == ["kick"] * 3 + assert _beats(m) == [0.0, 1.5, 3.0] + assert m.length == 4.0 -def test_steps_parallel_pitch_list () -> None: - """Multi-voice drum figures via a parallel pitches list.""" +def test_steps_are_grid_indices() -> None: + """The sequence() convention: 0-based sixteenth-grid indices by default.""" - m = M.steps([0, 4], pitches=["kick", "snare"]) + m = M.steps([0, 4, 8, 12], pitches="kick") - assert _pitches(m) == ["kick", "snare"] + assert _beats(m) == [0.0, 1.0, 2.0, 3.0] + assert m.length == 4.0 -def test_parallel_list_mismatch_raises () -> None: +def test_steps_parallel_pitch_list() -> None: + """Multi-voice drum figures via a parallel pitches list.""" - """Parallel lists must match the sequence length — no silent recycling in values.""" + m = M.steps([0, 4], pitches=["kick", "snare"]) - with pytest.raises(ValueError, match="parallel"): - M.degrees([1, 2, 3], velocities=[100, 90]) + assert _pitches(m) == ["kick", "snare"] -def test_euclidean_constructor () -> None: +def test_parallel_list_mismatch_raises() -> None: + """Parallel lists must match the sequence length — no silent recycling in values.""" - """A euclidean rhythm as a value: pulses spread across the grid over length beats.""" + with pytest.raises(ValueError, match="parallel"): + M.degrees([1, 2, 3], velocities=[100, 90]) - m = M.euclidean(4, 16, "kick", length=4) - assert _beats(m) == [0.0, 1.0, 2.0, 3.0] - assert m.length == 4.0 +def test_euclidean_constructor() -> None: + """A euclidean rhythm as a value: pulses spread across the grid over length beats.""" + m = M.euclidean(4, 16, "kick", length=4) -def test_chord_tone_names () -> None: + assert _beats(m) == [0.0, 1.0, 2.0, 3.0] + assert m.length == 4.0 - """'root'/'third'/'fifth'/'seventh' are sugar for 1-based indices.""" - assert ChordTone("root") == ChordTone(1) - assert ChordTone("seventh") == ChordTone(4) +def test_chord_tone_names() -> None: + """'root'/'third'/'fifth'/'seventh' are sugar for 1-based indices.""" - with pytest.raises(ValueError, match="ninth"): - ChordTone("ninth") + assert ChordTone("root") == ChordTone(1) + assert ChordTone("seventh") == ChordTone(4) + with pytest.raises(ValueError, match="ninth"): + ChordTone("ninth") -def test_values_are_hashable_and_structurally_equal () -> None: - """Frozen values: equal content, equal value, usable as dict keys.""" +def test_values_are_hashable_and_structurally_equal() -> None: + """Frozen values: equal content, equal value, usable as dict keys.""" - a = M.hits("kick", beats=[0, 2], length=4) - b = M.hits("kick", beats=[0, 2], length=4) + a = M.hits("kick", beats=[0, 2], length=4) + b = M.hits("kick", beats=[0, 2], length=4) - assert a == b - assert hash(a) == hash(b) - assert {a: "x"}[b] == "x" + assert a == b + assert hash(a) == hash(b) + assert {a: "x"}[b] == "x" # ── control-gesture constructors ──────────────────────────────────────────── -def test_cc_discrete_writes () -> None: - """Discrete CC writes via parallel value/beat lists.""" +def test_cc_discrete_writes() -> None: + """Discrete CC writes via parallel value/beat lists.""" - m = M.cc("cutoff", [20, 60, 110], beats=[0, 1, 2], length=4) + m = M.cc("cutoff", [20, 60, 110], beats=[0, 1, 2], length=4) - assert len(m.controls) == 3 - assert m.events == () - assert m.controls[0].signal == subsequence.motifs.CC("cutoff") - assert m.controls[0].end is None + assert len(m.controls) == 3 + assert m.events == () + assert m.controls[0].signal == subsequence.motifs.CC("cutoff") + assert m.controls[0].end is None -def test_cc_ramp_value () -> None: +def test_cc_ramp_value() -> None: + """A shaped sweep as a value; beat_end defaults from length.""" - """A shaped sweep as a value; beat_end defaults from length.""" + m = M.cc_ramp(74, 20, 110, length=4) + ramp = m.controls[0] - m = M.cc_ramp(74, 20, 110, length=4) - ramp = m.controls[0] + assert (ramp.start, ramp.end, ramp.span) == (20.0, 110.0, 4.0) + assert m.length == 4.0 - assert (ramp.start, ramp.end, ramp.span) == (20.0, 110.0, 4.0) - assert m.length == 4.0 +def test_ramp_needs_an_end() -> None: + """A ramp with neither beat_end nor length is unconstructible.""" -def test_ramp_needs_an_end () -> None: + with pytest.raises(ValueError, match="beat_end"): + M.cc_ramp(74, 0, 127) - """A ramp with neither beat_end nor length is unconstructible.""" - with pytest.raises(ValueError, match="beat_end"): - M.cc_ramp(74, 0, 127) +def test_nrpn_flags_travel() -> None: + """fine/null_reset ride the signal into the value.""" + m = M.nrpn_ramp(9, 0, 1400, beat_end=4) -def test_nrpn_flags_travel () -> None: + assert m.controls[0].signal.fine is True + assert m.controls[0].signal.null_reset is True - """fine/null_reset ride the signal into the value.""" - m = M.nrpn_ramp(9, 0, 1400, beat_end=4) +def test_control_event_invariants() -> None: + """end= and span= come together or not at all.""" - assert m.controls[0].signal.fine is True - assert m.controls[0].signal.null_reset is True - - -def test_control_event_invariants () -> None: - - """end= and span= come together or not at all.""" - - with pytest.raises(ValueError): - ControlEvent(beat=0.0, signal=subsequence.motifs.CC(74), start=0.0, end=100.0) + with pytest.raises(ValueError): + ControlEvent(beat=0.0, signal=subsequence.motifs.CC(74), start=0.0, end=100.0) # ── the algebra ───────────────────────────────────────────────────────────── -def test_add_lifts_to_phrase () -> None: - - """Motif + Motif is a two-segment Phrase; segmentation is preserved.""" - - a = M.hits("kick", beats=[0], length=4) - b = M.hits("snare", beats=[0], length=4) - phrase = a + b - - assert isinstance(phrase, P) - assert phrase.segments == (a, b) - assert phrase.length == 8.0 - -def test_then_is_the_closed_concat () -> None: +def test_add_lifts_to_phrase() -> None: + """Motif + Motif is a two-segment Phrase; segmentation is preserved.""" - """then() glues two cells into ONE longer motif, shifting the right operand.""" + a = M.hits("kick", beats=[0], length=4) + b = M.hits("snare", beats=[0], length=4) + phrase = a + b - a = M.hits("kick", beats=[0], length=4) - b = M.hits("snare", beats=[1], length=4) - glued = a.then(b) + assert isinstance(phrase, P) + assert phrase.segments == (a, b) + assert phrase.length == 8.0 - assert isinstance(glued, M) - assert glued.length == 8.0 - assert _beats(glued) == [0.0, 5.0] +def test_then_is_the_closed_concat() -> None: + """then() glues two cells into ONE longer motif, shifting the right operand.""" -def test_join_folds_with_then () -> None: + a = M.hits("kick", beats=[0], length=4) + b = M.hits("snare", beats=[1], length=4) + glued = a.then(b) - """Motif.join is the n-ary then; the empty motif is its identity.""" + assert isinstance(glued, M) + assert glued.length == 8.0 + assert _beats(glued) == [0.0, 5.0] - a = M.hits("kick", beats=[0], length=2) - assert M.join([a, a, a]).length == 6.0 - assert M.join([]) == M.empty() - assert M.empty().then(a) == a +def test_join_folds_with_then() -> None: + """Motif.join is the n-ary then; the empty motif is its identity.""" + a = M.hits("kick", beats=[0], length=2) -def test_multiplication_laws () -> None: + assert M.join([a, a, a]).length == 6.0 + assert M.join([]) == M.empty() + assert M.empty().then(a) == a - """m*0 is empty, m*1 is m, m*n is an n-segment Phrase.""" - m = M.hits("kick", beats=[0], length=4) +def test_multiplication_laws() -> None: + """m*0 is empty, m*1 is m, m*n is an n-segment Phrase.""" - assert m * 0 == M.empty() - assert m * 1 is m - assert (m * 3).segments == (m, m, m) - assert 2 * m == m * 2 + m = M.hits("kick", beats=[0], length=4) + assert m * 0 == M.empty() + assert m * 1 is m + assert (m * 3).segments == (m, m, m) + assert 2 * m == m * 2 -def test_parallel_merge_semantics () -> None: - """& is event union with length = max and no implicit tiling.""" +def test_parallel_merge_semantics() -> None: + """& is event union with length = max and no implicit tiling.""" - long = M.hits("kick", beats=[0, 2], length=4) - short = M.hits("snare", beats=[1], length=2) - merged = long & short + long = M.hits("kick", beats=[0, 2], length=4) + short = M.hits("snare", beats=[1], length=2) + merged = long & short - assert merged.length == 4.0 - assert len(merged.events) == 3 - assert _beats(merged) == [0.0, 1.0, 2.0] # the short one plays once — no tiling + assert merged.length == 4.0 + assert len(merged.events) == 3 + assert _beats(merged) == [0.0, 1.0, 2.0] # the short one plays once — no tiling -def test_parallel_merge_is_commutative_and_associative () -> None: +def test_parallel_merge_is_commutative_and_associative() -> None: + """Canonical event ordering makes & order-independent.""" - """Canonical event ordering makes & order-independent.""" + a = M.hits("kick", beats=[0], length=4) + b = M.hits("snare", beats=[1], length=4) + c = M.cc_ramp(74, 0, 127, length=4) - a = M.hits("kick", beats=[0], length=4) - b = M.hits("snare", beats=[1], length=4) - c = M.cc_ramp(74, 0, 127, length=4) + assert a & b == b & a + assert (a & b) & c == a & (b & c) - assert a & b == b & a - assert (a & b) & c == a & (b & c) +def test_stack_is_the_spelled_form() -> None: + """a.stack(b) == a & b.""" -def test_stack_is_the_spelled_form () -> None: + a = M.hits("kick", beats=[0], length=4) + b = M.hits("snare", beats=[1], length=4) - """a.stack(b) == a & b.""" + assert a.stack(b) == a & b - a = M.hits("kick", beats=[0], length=4) - b = M.hits("snare", beats=[1], length=4) - assert a.stack(b) == a & b +def test_merge_carries_controls() -> None: + """Stacking a control-only motif packages the gesture with the figure.""" + riff = M.notes([60, 63], length=4) + sweep = M.cc_ramp(74, 20, 110, beat_end=4) + acid = riff & sweep -def test_merge_carries_controls () -> None: + assert len(acid.events) == 2 + assert len(acid.controls) == 1 - """Stacking a control-only motif packages the gesture with the figure.""" - riff = M.notes([60, 63], length=4) - sweep = M.cc_ramp(74, 20, 110, beat_end=4) - acid = riff & sweep +def test_adding_non_music_raises() -> None: + """The algebra is closed over the sounding family.""" - assert len(acid.events) == 2 - assert len(acid.controls) == 1 + with pytest.raises(TypeError): + M.empty() + 3 -def test_adding_non_music_raises () -> None: +def test_slice_is_a_window() -> None: + """Events outside drop; straddlers truncate at the cut; beats shift to zero.""" - """The algebra is closed over the sounding family.""" + m = M.notes([60, 62, 64, 65], durations=[1.0, 1.0, 1.0, 2.0]) + window = m.slice(1, 4) - with pytest.raises(TypeError): - M.empty() + 3 + assert _pitches(window) == [62, 64, 65] + assert _beats(window) == [0.0, 1.0, 2.0] + assert window.length == 3.0 + assert window.events[-1].duration == 1.0 # the held final note is cut at the edge -def test_slice_is_a_window () -> None: +def test_slice_truncates_ramps_at_interpolated_value() -> None: + """A straddling ramp ends at its interpolated cut value.""" - """Events outside drop; straddlers truncate at the cut; beats shift to zero.""" + m = M.cc_ramp(74, 0, 100, beat_end=4) + window = m.slice(0, 2) + ramp = window.controls[0] - m = M.notes([60, 62, 64, 65], durations=[1.0, 1.0, 1.0, 2.0]) - window = m.slice(1, 4) - - assert _pitches(window) == [62, 64, 65] - assert _beats(window) == [0.0, 1.0, 2.0] - assert window.length == 3.0 - assert window.events[-1].duration == 1.0 # the held final note is cut at the edge - - -def test_slice_truncates_ramps_at_interpolated_value () -> None: - - """A straddling ramp ends at its interpolated cut value.""" - - m = M.cc_ramp(74, 0, 100, beat_end=4) - window = m.slice(0, 2) - ramp = window.controls[0] - - assert ramp.span == 2.0 - assert ramp.end == 50.0 + assert ramp.span == 2.0 + assert ramp.end == 50.0 # ── transform laws (the test-suite backbone) ──────────────────────────────── -def test_reverse_law_over_addition () -> None: - - """(a + b).reverse() == b.reverse() + a.reverse().""" - a = M.degrees([1, 3], length=2) - b = M.degrees([5, 6, 5], length=3) +def test_reverse_law_over_addition() -> None: + """(a + b).reverse() == b.reverse() + a.reverse().""" - assert (a + b).reverse() == b.reverse() + a.reverse() + a = M.degrees([1, 3], length=2) + b = M.degrees([5, 6, 5], length=3) + assert (a + b).reverse() == b.reverse() + a.reverse() -def test_reverse_is_an_involution () -> None: - """Reversing twice restores the motif.""" +def test_reverse_is_an_involution() -> None: + """Reversing twice restores the motif.""" - m = M.degrees([1, 3, 5], durations=[0.5, 1.0, 0.25]) + m = M.degrees([1, 3, 5], durations=[0.5, 1.0, 0.25]) - assert m.reverse().reverse() == m + assert m.reverse().reverse() == m -def test_reverse_mirrors_ramps () -> None: +def test_reverse_mirrors_ramps() -> None: + """A rising sweep becomes a falling one.""" - """A rising sweep becomes a falling one.""" + sweep = M.cc_ramp(74, 20, 110, beat_end=4) + back = sweep.reverse().controls[0] - sweep = M.cc_ramp(74, 20, 110, beat_end=4) - back = sweep.reverse().controls[0] + assert (back.start, back.end) == (110.0, 20.0) - assert (back.start, back.end) == (110.0, 20.0) +def test_transpose_distributes_over_merge_and_addition() -> None: + """Pitch transforms distribute over & and +.""" -def test_transpose_distributes_over_merge_and_addition () -> None: + a = M.degrees([1, 3], length=2) + b = M.degrees([5], length=2) - """Pitch transforms distribute over & and +.""" + assert (a & b).transpose(steps=2) == a.transpose(steps=2) & b.transpose(steps=2) + assert (a + b).transpose(steps=2) == a.transpose(steps=2) + b.transpose(steps=2) - a = M.degrees([1, 3], length=2) - b = M.degrees([5], length=2) - assert (a & b).transpose(steps=2) == a.transpose(steps=2) & b.transpose(steps=2) - assert (a + b).transpose(steps=2) == a.transpose(steps=2) + b.transpose(steps=2) +def test_stretch_distributes_over_then() -> None: + """Stretch scales the whole timeline coherently.""" + a = M.degrees([1], length=2) + b = M.degrees([5], length=2) -def test_stretch_distributes_over_then () -> None: + assert a.then(b).stretch(2.0) == a.stretch(2.0).then(b.stretch(2.0)) - """Stretch scales the whole timeline coherently.""" - a = M.degrees([1], length=2) - b = M.degrees([5], length=2) +def test_flatten_is_a_homomorphism() -> None: + """Phrase.flatten() maps (+) onto (then).""" - assert a.then(b).stretch(2.0) == a.stretch(2.0).then(b.stretch(2.0)) + a = M.degrees([1, 3], length=2) + b = M.degrees([5], length=2) + assert (a + b).flatten() == a.then(b) -def test_flatten_is_a_homomorphism () -> None: - """Phrase.flatten() maps (+) onto (then).""" +def test_rotate_is_whole_span_modular() -> None: + """Onsets wrap modulo the length; rotating by the length is identity.""" - a = M.degrees([1, 3], length=2) - b = M.degrees([5], length=2) + m = M.hits("kick", beats=[0, 1, 3], length=4) - assert (a + b).flatten() == a.then(b) - - -def test_rotate_is_whole_span_modular () -> None: - - """Onsets wrap modulo the length; rotating by the length is identity.""" - - m = M.hits("kick", beats=[0, 1, 3], length=4) - - assert _beats(m.rotate(1)) == [0.0, 1.0, 2.0] - assert m.rotate(4) == m + assert _beats(m.rotate(1)) == [0.0, 1.0, 2.0] + assert m.rotate(4) == m # ── transforms: behaviour ─────────────────────────────────────────────────── -def test_stretch_scales_everything () -> None: - - """Beats, durations, spans, and length scale together.""" - - m = M.notes([60], durations=[1.0]) & M.cc_ramp(74, 0, 127, beat_end=1) - wide = m.stretch(2.0) - - assert wide.length == 2.0 - assert wide.events[0].duration == 2.0 - assert wide.controls[0].span == 2.0 - - -def test_quantize_snaps_notes_only () -> None: - - """Note onsets snap to the grid; control gestures keep their timing.""" - - m = M.from_events([MotifEvent(beat=1.07, pitch=60)], length=4, controls=( - ControlEvent(beat=1.07, signal=subsequence.motifs.CC(74), start=64.0), - )) - snapped = m.quantize(0.25) - - assert snapped.events[0].beat == 1.0 - assert snapped.controls[0].beat == 1.07 - - -def test_accent_boosts_at_beat_position () -> None: - """accent() takes a 0-based beat position, not a note count.""" +def test_stretch_scales_everything() -> None: + """Beats, durations, spans, and length scale together.""" - m = M.degrees([1, 5], velocities=100) - accented = m.accent(1.0, amount=20) + m = M.notes([60], durations=[1.0]) & M.cc_ramp(74, 0, 127, beat_end=1) + wide = m.stretch(2.0) - assert [e.velocity for e in accented.events] == [100, 120] + assert wide.length == 2.0 + assert wide.events[0].duration == 2.0 + assert wide.controls[0].span == 2.0 -def test_with_velocity_replaces_all () -> None: +def test_quantize_snaps_notes_only() -> None: + """Note onsets snap to the grid; control gestures keep their timing.""" - """Uniform velocity replacement, tuples allowed.""" + m = M.from_events( + [MotifEvent(beat=1.07, pitch=60)], + length=4, + controls=( + ControlEvent(beat=1.07, signal=subsequence.motifs.CC(74), start=64.0), + ), + ) + snapped = m.quantize(0.25) - m = M.degrees([1, 5]).with_velocity((60, 90)) + assert snapped.events[0].beat == 1.0 + assert snapped.controls[0].beat == 1.07 - assert all(e.velocity == (60, 90) for e in m.events) +def test_accent_boosts_at_beat_position() -> None: + """accent() takes a 0-based beat position, not a note count.""" -def test_pitched_turns_rhythm_into_line () -> None: + m = M.degrees([1, 5], velocities=100) + accented = m.accent(1.0, amount=20) - """kick.pitched('root') — same rhythm, chord-tone pitches.""" + assert [e.velocity for e in accented.events] == [100, 120] - bass = M.hits("kick", beats=[0, 1.5, 3], length=4).pitched("root") - assert _pitches(bass) == [ChordTone(1)] * 3 - assert _beats(bass) == [0.0, 1.5, 3.0] +def test_with_velocity_replaces_all() -> None: + """Uniform velocity replacement, tuples allowed.""" + m = M.degrees([1, 5]).with_velocity((60, 90)) -def test_rhythm_strips_pitches_and_controls () -> None: + assert all(e.velocity == (60, 90) for e in m.events) - """A skeleton keeps timing/velocity/duration; pitches and gestures go.""" - m = (M.degrees([1, 5], velocities=[100, 80]) & M.cc_ramp(74, 0, 127, beat_end=2)).rhythm() +def test_pitched_turns_rhythm_into_line() -> None: + """kick.pitched('root') — same rhythm, chord-tone pitches.""" - assert _pitches(m) == [None, None] - assert [e.velocity for e in m.events] == [100, 80] - assert m.controls == () + bass = M.hits("kick", beats=[0, 1.5, 3], length=4).pitched("root") + assert _pitches(bass) == [ChordTone(1)] * 3 + assert _beats(bass) == [0.0, 1.5, 3.0] -def test_onsets_accessor () -> None: - """onsets() hands the beat list to rhythm-first generation.""" +def test_rhythm_strips_pitches_and_controls() -> None: + """A skeleton keeps timing/velocity/duration; pitches and gestures go.""" - assert M.hits("kick", beats=[0, 1.5, 3], length=4).onsets() == [0.0, 1.5, 3.0] + m = ( + M.degrees([1, 5], velocities=[100, 80]) & M.cc_ramp(74, 0, 127, beat_end=2) + ).rhythm() + assert _pitches(m) == [None, None] + assert [e.velocity for e in m.events] == [100, 80] + assert m.controls == () -def test_transpose_steps_moves_degrees () -> None: - """Diatonic sequencing: degrees shift by scale steps.""" +def test_onsets_accessor() -> None: + """onsets() hands the beat list to rhythm-first generation.""" - m = subsequence.motif([5, 6, 5, 3]).transpose(steps=2) + assert M.hits("kick", beats=[0, 1.5, 3], length=4).onsets() == [0.0, 1.5, 3.0] - assert _pitches(m) == [Degree(7), Degree(8), Degree(7), Degree(5)] +def test_transpose_steps_moves_degrees() -> None: + """Diatonic sequencing: degrees shift by scale steps.""" -def test_transpose_steps_raises_on_absolute_content () -> None: + m = subsequence.motif([5, 6, 5, 3]).transpose(steps=2) - """MIDI ints have no degrees; the unit keyword protects the meaning.""" + assert _pitches(m) == [Degree(7), Degree(8), Degree(7), Degree(5)] - with pytest.raises(TypeError, match="semitones"): - M.notes([60, 62]).transpose(steps=2) +def test_transpose_steps_raises_on_absolute_content() -> None: + """MIDI ints have no degrees; the unit keyword protects the meaning.""" -def test_transpose_semitones_is_chromatic () -> None: + with pytest.raises(TypeError, match="semitones"): + M.notes([60, 62]).transpose(steps=2) - """MIDI shifts literally; degrees gain chroma.""" - assert _pitches(M.notes([60]).transpose(semitones=3)) == [63] - assert _pitches(M.degrees([5]).transpose(semitones=1)) == [Degree(5, chroma=1)] +def test_transpose_semitones_is_chromatic() -> None: + """MIDI shifts literally; degrees gain chroma.""" + assert _pitches(M.notes([60]).transpose(semitones=3)) == [63] + assert _pitches(M.degrees([5]).transpose(semitones=1)) == [Degree(5, chroma=1)] -def test_transpose_drums_raise_on_both () -> None: - """A transposed drum name is a different instrument, not a transposition.""" +def test_transpose_drums_raise_on_both() -> None: + """A transposed drum name is a different instrument, not a transposition.""" - kick = M.hits("kick", beats=[0], length=1) + kick = M.hits("kick", beats=[0], length=1) - with pytest.raises(TypeError): - kick.transpose(steps=1) - with pytest.raises(TypeError): - kick.transpose(semitones=1) + with pytest.raises(TypeError): + kick.transpose(steps=1) + with pytest.raises(TypeError): + kick.transpose(semitones=1) -def test_transpose_takes_exactly_one_unit () -> None: +def test_transpose_takes_exactly_one_unit() -> None: + """steps= or semitones=, never both, never neither.""" - """steps= or semitones=, never both, never neither.""" + m = M.degrees([1]) - m = M.degrees([1]) + with pytest.raises(ValueError): + m.transpose() + with pytest.raises(ValueError): + m.transpose(steps=1, semitones=1) - with pytest.raises(ValueError): - m.transpose() - with pytest.raises(ValueError): - m.transpose(steps=1, semitones=1) +def test_transpose_reaches_into_approach() -> None: + """Approach targets move with the transposition.""" -def test_transpose_reaches_into_approach () -> None: + m = M.from_events([MotifEvent(beat=0.0, pitch=Approach(Degree(5)))], length=1) - """Approach targets move with the transposition.""" + assert _pitches(m.transpose(steps=2)) == [Approach(Degree(7))] - m = M.from_events([MotifEvent(beat=0.0, pitch=Approach(Degree(5)))], length=1) - assert _pitches(m.transpose(steps=2)) == [Approach(Degree(7))] +def test_invert_mirrors_around_pivot() -> None: + """T/I/R serial group: absolute content mirrors around a MIDI pivot.""" + m = M.notes([60, 64, 67]).invert() -def test_invert_mirrors_around_pivot () -> None: + assert _pitches(m) == [60, 56, 53] - """T/I/R serial group: absolute content mirrors around a MIDI pivot.""" - m = M.notes([60, 64, 67]).invert() +def test_invert_degrees() -> None: + """Degree content mirrors around a degree pivot.""" - assert _pitches(m) == [60, 56, 53] + m = M.degrees([1, 3, 5]).invert(pivot=3) + assert _pitches(m) == [Degree(5), Degree(3), Degree(1)] -def test_invert_degrees () -> None: - """Degree content mirrors around a degree pivot.""" +def test_invert_reflects_degree_octaves() -> None: + """A degree's register flips under inversion: an octave up lands an octave down. - m = M.degrees([1, 3, 5]).invert(pivot=3) + Regression: ``invert`` used to mirror only ``step`` and leave ``octave`` + untouched, so an octave-bearing degree inverted to itself (wrong pitch). + """ - assert _pitches(m) == [Degree(5), Degree(3), Degree(1)] + m = subsequence.motif([1, Degree(1, octave=1)]).invert(pivot=1) + assert _pitches(m) == [Degree(1, octave=0), Degree(1, octave=-1)] -def test_invert_reflects_degree_octaves () -> None: - """A degree's register flips under inversion: an octave up lands an octave down. +def test_controls_ignore_pitch_transforms() -> None: + """Transposing a riff does not rescale its bend depths.""" - Regression: ``invert`` used to mirror only ``step`` and leave ``octave`` - untouched, so an octave-bearing degree inverted to itself (wrong pitch). - """ + acid = M.degrees([1, 5]) & M.pitch_bend_ramp(0.0, -0.5, beat_end=2) - m = subsequence.motif([1, Degree(1, octave=1)]).invert(pivot=1) - - assert _pitches(m) == [Degree(1, octave=0), Degree(1, octave=-1)] - - -def test_controls_ignore_pitch_transforms () -> None: - - """Transposing a riff does not rescale its bend depths.""" - - acid = M.degrees([1, 5]) & M.pitch_bend_ramp(0.0, -0.5, beat_end=2) - - assert acid.transpose(steps=2).controls == acid.controls + assert acid.transpose(steps=2).controls == acid.controls # ── Phrase ────────────────────────────────────────────────────────────────── -def test_phrase_length_and_flatten_offsets () -> None: - - """Length sums segments; flatten shifts each segment to its offset.""" - - a = M.hits("kick", beats=[0], length=4) - b = M.hits("snare", beats=[1], length=4) - phrase = P([a, b]) - - assert phrase.length == 8.0 - assert _beats(phrase.flatten()) == [0.0, 5.0] - - -def test_phrase_addition_and_tiling () -> None: - - """Phrase + Motif appends; Phrase + Phrase concatenates; * tiles.""" - - a = M.degrees([1], length=1) - b = M.degrees([5], length=1) - - assert (P([a]) + b).segments == (a, b) - assert (P([a]) + P([b])).segments == (a, b) - assert (a + (P([b]))).segments == (a, b) - assert (P([a, b]) * 2).segments == (a, b, a, b) +def test_phrase_length_and_flatten_offsets() -> None: + """Length sums segments; flatten shifts each segment to its offset.""" -def test_phrase_merge_flattens_first () -> None: + a = M.hits("kick", beats=[0], length=4) + b = M.hits("snare", beats=[1], length=4) + phrase = P([a, b]) - """Parallel merge is vertical — segmentation is erased honestly.""" + assert phrase.length == 8.0 + assert _beats(phrase.flatten()) == [0.0, 5.0] - a = M.degrees([1], length=2) - b = M.degrees([5], length=2) - merged = P([a, b]) & M.hits("kick", beats=[0], length=4) - assert isinstance(merged, M) - assert merged.length == 4.0 +def test_phrase_addition_and_tiling() -> None: + """Phrase + Motif appends; Phrase + Phrase concatenates; * tiles.""" + a = M.degrees([1], length=1) + b = M.degrees([5], length=1) -def test_phrase_replace_is_one_based () -> None: + assert (P([a]) + b).segments == (a, b) + assert (P([a]) + P([b])).segments == (a, b) + assert (a + (P([b]))).segments == (a, b) + assert (P([a, b]) * 2).segments == (a, b, a, b) - """Musicians count segments from one.""" - a = M.degrees([1], length=1) - b = M.degrees([5], length=1) - swapped = P([a, a]).replace(2, b) +def test_phrase_merge_flattens_first() -> None: + """Parallel merge is vertical — segmentation is erased honestly.""" - assert swapped.segments == (a, b) + a = M.degrees([1], length=2) + b = M.degrees([5], length=2) + merged = P([a, b]) & M.hits("kick", beats=[0], length=4) - with pytest.raises(IndexError, match="1-based"): - P([a]).replace(0, b) + assert isinstance(merged, M) + assert merged.length == 4.0 -def test_phrase_reverse_law () -> None: +def test_phrase_replace_is_one_based() -> None: + """Musicians count segments from one.""" - """Reverse acts on the whole timeline and re-segments (the lifted law).""" + a = M.degrees([1], length=1) + b = M.degrees([5], length=1) + swapped = P([a, a]).replace(2, b) - a = M.degrees([1, 3], length=2) - b = M.degrees([5], length=2) + assert swapped.segments == (a, b) - assert (a + b).reverse() == P([b.reverse(), a.reverse()]) + with pytest.raises(IndexError, match="1-based"): + P([a]).replace(0, b) -def test_phrase_rotate_resegments_keeping_durations () -> None: +def test_phrase_reverse_law() -> None: + """Reverse acts on the whole timeline and re-segments (the lifted law).""" - """Rotation re-segments by onset; a note may ring past its new boundary.""" + a = M.degrees([1, 3], length=2) + b = M.degrees([5], length=2) - a = M.notes([60], durations=[2.0], length=2) - b = M.notes([72], durations=[2.0], length=2) - rotated = (a + b).rotate(1) + assert (a + b).reverse() == P([b.reverse(), a.reverse()]) - assert rotated.length == 4.0 - flat = rotated.flatten() - assert _beats(flat) == [1.0, 3.0] - assert all(e.duration == 2.0 for e in flat.events) +def test_phrase_rotate_resegments_keeping_durations() -> None: + """Rotation re-segments by onset; a note may ring past its new boundary.""" -def test_phrase_lifts_pitch_transforms_segment_wise () -> None: + a = M.notes([60], durations=[2.0], length=2) + b = M.notes([72], durations=[2.0], length=2) + rotated = (a + b).rotate(1) - """Non-time transforms apply per segment, preserving segmentation.""" + assert rotated.length == 4.0 + flat = rotated.flatten() + assert _beats(flat) == [1.0, 3.0] + assert all(e.duration == 2.0 for e in flat.events) - a = M.degrees([1], length=1) - b = M.degrees([5], length=1) - assert P([a, b]).transpose(steps=1).segments == (a.transpose(steps=1), b.transpose(steps=1)) +def test_phrase_lifts_pitch_transforms_segment_wise() -> None: + """Non-time transforms apply per segment, preserving segmentation.""" + a = M.degrees([1], length=1) + b = M.degrees([5], length=1) -def test_phrase_slice_resegments_at_cuts () -> None: + assert P([a, b]).transpose(steps=1).segments == ( + a.transpose(steps=1), + b.transpose(steps=1), + ) - """Slicing a phrase windows each overlapped segment.""" - a = M.degrees([1, 2], length=2) - b = M.degrees([5, 6], length=2) - window = (a + b).slice(1, 3) +def test_phrase_slice_resegments_at_cuts() -> None: + """Slicing a phrase windows each overlapped segment.""" - assert window.length == 2.0 - assert len(window.segments) == 2 - assert _pitches(window.flatten()) == [Degree(2), Degree(5)] + a = M.degrees([1, 2], length=2) + b = M.degrees([5, 6], length=2) + window = (a + b).slice(1, 3) + assert window.length == 2.0 + assert len(window.segments) == 2 + assert _pitches(window.flatten()) == [Degree(2), Degree(5)] -def test_describe_smoke () -> None: - """Every value prints something readable.""" +def test_describe_smoke() -> None: + """Every value prints something readable.""" - acid = M.hits("kick", beats=[0], length=4).pitched("root") & M.cc_ramp(74, 20, 110, beat_end=4) + acid = M.hits("kick", beats=[0], length=4).pitched("root") & M.cc_ramp( + 74, 20, 110, beat_end=4 + ) - assert "tone1" in str(acid) - assert "CC74" in str(acid) - assert "segments" in str(acid * 2) + assert "tone1" in str(acid) + assert "CC74" in str(acid) + assert "segments" in str(acid * 2) diff --git a/tests/test_multi_device.py b/tests/test_multi_device.py index 97c5bdf..8c2860f 100644 --- a/tests/test_multi_device.py +++ b/tests/test_multi_device.py @@ -21,708 +21,788 @@ # Helpers # --------------------------------------------------------------------------- -def _make_sequencer (spy: conftest.SpyMidiOut) -> subsequence.sequencer.Sequencer: - seq = subsequence.sequencer.Sequencer( - output_device_name = "Dummy MIDI", - initial_bpm = 120, - ) - seq.midi_out = spy - loop = asyncio.new_event_loop() - seq._midi_input_queue = asyncio.Queue() - seq._input_loop = loop - return seq +def _make_sequencer(spy: conftest.SpyMidiOut) -> subsequence.sequencer.Sequencer: + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + ) + seq.midi_out = spy + loop = asyncio.new_event_loop() + seq._midi_input_queue = asyncio.Queue() + seq._input_loop = loop + return seq -def _cc (control: int, value: int, channel: int = 0) -> mido.Message: - return mido.Message('control_change', channel=channel, control=control, value=value) + +def _cc(control: int, value: int, channel: int = 0) -> mido.Message: + return mido.Message("control_change", channel=channel, control=control, value=value) # --------------------------------------------------------------------------- # Backward compatibility — single device (must work exactly as before) # --------------------------------------------------------------------------- -def test_single_device_pattern_route (patch_midi: None) -> None: - """Single-device Composition still works; all events go to device 0.""" - comp = subsequence.Composition(bpm=120) - @comp.pattern(channel=1, beats=4) - def bass (p: subsequence.pattern_builder.PatternBuilder) -> None: - p.note(36, beat=0) +def test_single_device_pattern_route(patch_midi: None) -> None: + """Single-device Composition still works; all events go to device 0.""" + comp = subsequence.Composition(bpm=120) + + @comp.pattern(channel=1, beats=4) + def bass(p: subsequence.pattern_builder.PatternBuilder) -> None: + p.note(36, beat=0) + + # Pattern device defaults to 0 + assert comp._pending_patterns[0].device == 0 + - # Pattern device defaults to 0 - assert comp._pending_patterns[0].device == 0 +def test_single_device_midi_out_property(patch_midi: None) -> None: + """sequencer.midi_out property still works on single-device setup.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + assert seq.midi_out is not None -def test_single_device_midi_out_property (patch_midi: None) -> None: - """sequencer.midi_out property still works on single-device setup.""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - assert seq.midi_out is not None +def test_midi_out_setter_compat(patch_midi: None) -> None: + """sequencer.midi_out = spy still works (test injection path).""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + assert seq.midi_out is spy -def test_midi_out_setter_compat (patch_midi: None) -> None: - """sequencer.midi_out = spy still works (test injection path).""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - assert seq.midi_out is spy +def test_single_device_send(patch_midi: None) -> None: + """Events without a device field default to device 0.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + event = subsequence.sequencer.MidiEvent( + pulse=0, + message_type="control_change", + channel=0, + control=7, + value=100, + ) + seq._send_midi(event) + assert len(spy.sent) == 1 + assert spy.sent[0].value == 100 -def test_single_device_send (patch_midi: None) -> None: - """Events without a device field default to device 0.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - event = subsequence.sequencer.MidiEvent( - pulse=0, message_type='control_change', channel=0, - control=7, value=100, - ) - seq._send_midi(event) - assert len(spy.sent) == 1 - assert spy.sent[0].value == 100 +def test_single_device_active_notes_track(patch_midi: None) -> None: + """Dispatching note_on/note_off through _process_pulse tracks (device, channel, note).""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + async def drive() -> None: + seq._push_event( + subsequence.sequencer.MidiEvent( + pulse=0, + message_type="note_on", + channel=0, + note=60, + velocity=100, + ) + ) + await seq._process_pulse(0) -def test_single_device_active_notes_track (patch_midi: None) -> None: - """Dispatching note_on/note_off through _process_pulse tracks (device, channel, note).""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) + assert (0, 0, 60) in seq.active_notes - async def drive () -> None: - seq._push_event(subsequence.sequencer.MidiEvent( - pulse=0, message_type='note_on', channel=0, note=60, velocity=100, - )) - await seq._process_pulse(0) + seq._push_event( + subsequence.sequencer.MidiEvent( + pulse=1, + message_type="note_off", + channel=0, + note=60, + ) + ) + await seq._process_pulse(1) - assert (0, 0, 60) in seq.active_notes + asyncio.run(drive()) - seq._push_event(subsequence.sequencer.MidiEvent( - pulse=1, message_type='note_off', channel=0, note=60, - )) - await seq._process_pulse(1) + # The note_off cleared the tracking entry, and both events really went out. + assert (0, 0, 60) not in seq.active_notes + assert [m.type for m in spy.sent] == ["note_on", "note_off"] - asyncio.run(drive()) - # The note_off cleared the tracking entry, and both events really went out. - assert (0, 0, 60) not in seq.active_notes - assert [m.type for m in spy.sent] == ['note_on', 'note_off'] +def test_pattern_device_default(patch_midi: None) -> None: + """Pattern.device defaults to 0.""" + import subsequence.pattern as pat + p = pat.Pattern(channel=0) + assert p.device == 0 -def test_pattern_device_default (patch_midi: None) -> None: - """Pattern.device defaults to 0.""" - import subsequence.pattern as pat - p = pat.Pattern(channel=0) - assert p.device == 0 +def test_cc_event_device_default(patch_midi: None) -> None: + """CcEvent.device defaults to None (inherit from pattern).""" + import subsequence.pattern as pat -def test_cc_event_device_default (patch_midi: None) -> None: - """CcEvent.device defaults to None (inherit from pattern).""" - import subsequence.pattern as pat - ev = pat.CcEvent(pulse=0, message_type='control_change') - assert ev.device is None + ev = pat.CcEvent(pulse=0, message_type="control_change") + assert ev.device is None # --------------------------------------------------------------------------- # MidiDeviceRegistry # --------------------------------------------------------------------------- -def test_registry_add_returns_index () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - i0 = reg.add("A", object()) - i1 = reg.add("B", object()) - assert i0 == 0 - assert i1 == 1 +def test_registry_add_returns_index() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + i0 = reg.add("A", object()) + i1 = reg.add("B", object()) + assert i0 == 0 + assert i1 == 1 -def test_registry_get_none_returns_device_0 () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - obj = object() - reg.add("Primary", obj) - assert reg.get(None) is obj +def test_registry_get_none_returns_device_0() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + obj = object() + reg.add("Primary", obj) + assert reg.get(None) is obj -def test_registry_get_by_index () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - a, b = object(), object() - reg.add("A", a) - reg.add("B", b) - assert reg.get(1) is b +def test_registry_get_by_index() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + a, b = object(), object() + reg.add("A", a) + reg.add("B", b) + assert reg.get(1) is b -def test_registry_get_by_name () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - a, b = object(), object() - reg.add("Alpha", a) - reg.add("Beta", b) - assert reg.get("Beta") is b +def test_registry_get_by_name() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + a, b = object(), object() + reg.add("Alpha", a) + reg.add("Beta", b) + assert reg.get("Beta") is b -def test_registry_get_unknown_name_returns_none () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("A", object()) - assert reg.get("NoSuchDevice") is None +def test_registry_get_unknown_name_returns_none() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("A", object()) + assert reg.get("NoSuchDevice") is None -def test_registry_get_empty_returns_none () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - assert reg.get(None) is None +def test_registry_get_empty_returns_none() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + assert reg.get(None) is None -def test_registry_index_of () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("X", object()) - reg.add("Y", object()) - assert reg.index_of(None) == 0 - assert reg.index_of(1) == 1 - assert reg.index_of("Y") == 1 - assert reg.index_of("Z") == -1 +def test_registry_index_of() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("X", object()) + reg.add("Y", object()) + assert reg.index_of(None) == 0 + assert reg.index_of(1) == 1 + assert reg.index_of("Y") == 1 + assert reg.index_of("Z") == -1 -def test_registry_iter () -> None: - reg = subsequence.midi_utils.MidiDeviceRegistry() - objs = [object(), object()] - for i, o in enumerate(objs): - reg.add(f"dev{i}", o) - assert list(reg) == objs +def test_registry_iter() -> None: + reg = subsequence.midi_utils.MidiDeviceRegistry() + objs = [object(), object()] + for i, o in enumerate(objs): + reg.add(f"dev{i}", o) + assert list(reg) == objs -def test_registry_close_all () -> None: - closed = [] - class TrackClose: - def close (self) -> None: - closed.append(True) +def test_registry_close_all() -> None: + closed = [] - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("A", TrackClose()) - reg.add("B", TrackClose()) - reg.close_all() - assert len(closed) == 2 - assert len(reg) == 0 + class TrackClose: + def close(self) -> None: + closed.append(True) + + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("A", TrackClose()) + reg.add("B", TrackClose()) + reg.close_all() + assert len(closed) == 2 + assert len(reg) == 0 # --------------------------------------------------------------------------- # Multi-device output routing # --------------------------------------------------------------------------- -def test_pattern_device_by_index (patch_midi: None) -> None: - """@comp.pattern(device=1) stores device=1 on the pending pattern.""" - comp = subsequence.Composition(bpm=120) - comp._additional_outputs.append(subsequence.composition._AdditionalOutput(device="Secondary MIDI")) - - @comp.pattern(channel=1, beats=4, device=1) - def strings (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0) - - assert comp._pending_patterns[0].device == 1 - - -def test_pattern_device_by_name (patch_midi_multi) -> None: - """@comp.pattern(device='secondary') resolves to correct index.""" - comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") - comp.midi_output("Secondary MIDI", name="secondary") - - @comp.pattern(channel=1, beats=4, device="secondary") - def p1 (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - - # device=0 placeholder at registration time (name resolved in _run()); raw_device preserved - pending = comp._pending_patterns[0] - assert pending.raw_device == "secondary" - assert pending.device == 0 - - -def test_midi_output_returns_index (patch_midi_multi) -> None: - """comp.midi_output() returns sequential indices starting at 1.""" - comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") - i1 = comp.midi_output("Secondary MIDI", name="synth2") - i2 = comp.midi_output("Third MIDI", name="synth3") - assert i1 == 1 - assert i2 == 2 - -def test_events_routed_to_device_1 (patch_midi: None) -> None: - """MidiEvent with device=1 is sent to the device-1 port.""" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy0 # device 0 - seq._output_devices.add("Secondary", spy1) # device 1 - - event = subsequence.sequencer.MidiEvent( - pulse=0, message_type='note_on', channel=0, note=60, velocity=100, - device=1, - ) - seq._send_midi(event) - - assert len(spy0.sent) == 0 - assert len(spy1.sent) == 1 - assert spy1.sent[0].note == 60 - - -def test_events_default_to_device_0 (patch_midi: None) -> None: - """MidiEvent with device=0 (default) goes to the primary output.""" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy0 - seq._output_devices.add("Secondary", spy1) - - event = subsequence.sequencer.MidiEvent( - pulse=0, message_type='note_on', channel=0, note=60, velocity=100, - # device defaults to 0 - ) - seq._send_midi(event) - - assert len(spy0.sent) == 1 - assert len(spy1.sent) == 0 - - -def test_active_notes_device_aware (patch_midi: None) -> None: - """Note-off is sent to the same device the note-on came from.""" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy0 - seq._output_devices.add("Secondary", spy1) - - # Simulate a note-on on device 1 tracked in active_notes - seq.active_notes.add((1, 0, 60)) - - asyncio.run(seq._stop_all_active_notes()) - - assert len(spy0.sent) == 0 - assert len(spy1.sent) == 1 - assert spy1.sent[0].type == 'note_off' - assert spy1.sent[0].note == 60 +def test_pattern_device_by_index(patch_midi: None) -> None: + """@comp.pattern(device=1) stores device=1 on the pending pattern.""" + comp = subsequence.Composition(bpm=120) + comp._additional_outputs.append( + subsequence.composition._AdditionalOutput(device="Secondary MIDI") + ) + + @comp.pattern(channel=1, beats=4, device=1) + def strings(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0) + + assert comp._pending_patterns[0].device == 1 + + +def test_pattern_device_by_name(patch_midi_multi) -> None: + """@comp.pattern(device='secondary') resolves to correct index.""" + comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") + comp.midi_output("Secondary MIDI", name="secondary") + + @comp.pattern(channel=1, beats=4, device="secondary") + def p1(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass + + # device=0 placeholder at registration time (name resolved in _run()); raw_device preserved + pending = comp._pending_patterns[0] + assert pending.raw_device == "secondary" + assert pending.device == 0 + + +def test_midi_output_returns_index(patch_midi_multi) -> None: + """comp.midi_output() returns sequential indices starting at 1.""" + comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") + i1 = comp.midi_output("Secondary MIDI", name="synth2") + i2 = comp.midi_output("Third MIDI", name="synth3") + assert i1 == 1 + assert i2 == 2 + + +def test_events_routed_to_device_1(patch_midi: None) -> None: + """MidiEvent with device=1 is sent to the device-1 port.""" + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy0 # device 0 + seq._output_devices.add("Secondary", spy1) # device 1 + + event = subsequence.sequencer.MidiEvent( + pulse=0, + message_type="note_on", + channel=0, + note=60, + velocity=100, + device=1, + ) + seq._send_midi(event) + + assert len(spy0.sent) == 0 + assert len(spy1.sent) == 1 + assert spy1.sent[0].note == 60 + + +def test_events_default_to_device_0(patch_midi: None) -> None: + """MidiEvent with device=0 (default) goes to the primary output.""" + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy0 + seq._output_devices.add("Secondary", spy1) + + event = subsequence.sequencer.MidiEvent( + pulse=0, + message_type="note_on", + channel=0, + note=60, + velocity=100, + # device defaults to 0 + ) + seq._send_midi(event) + + assert len(spy0.sent) == 1 + assert len(spy1.sent) == 0 + + +def test_active_notes_device_aware(patch_midi: None) -> None: + """Note-off is sent to the same device the note-on came from.""" + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy0 + seq._output_devices.add("Secondary", spy1) + + # Simulate a note-on on device 1 tracked in active_notes + seq.active_notes.add((1, 0, 60)) + + asyncio.run(seq._stop_all_active_notes()) + + assert len(spy0.sent) == 0 + assert len(spy1.sent) == 1 + assert spy1.sent[0].type == "note_off" + assert spy1.sent[0].note == 60 @pytest.mark.asyncio -async def test_panic_all_devices (patch_midi: None) -> None: - """panic() sends All Notes Off to every registered output device.""" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy0 - seq._output_devices.add("Secondary", spy1) +async def test_panic_all_devices(patch_midi: None) -> None: + """panic() sends All Notes Off to every registered output device.""" + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy0 + seq._output_devices.add("Secondary", spy1) + + await seq.panic() + + # Both devices should have received CC 123 and CC 120 on all 16 channels + def has_all_notes_off(spy: conftest.SpyMidiOut) -> bool: + return any(m.type == "control_change" and m.control == 123 for m in spy.sent) + + assert has_all_notes_off(spy0) + assert has_all_notes_off(spy1) + + +def test_schedule_pattern_propagates_device(patch_midi: None) -> None: + """schedule_pattern() copies pattern.device onto every MidiEvent.""" + import subsequence.pattern as pat + + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy0 + seq._output_devices.add("Secondary", spy1) + + # Build a simple pattern targeting device 1 + p = pat.Pattern(channel=0, length=4, device=1) + p.add_note(position=0, pitch=60, velocity=100, duration=24) + + asyncio.run(seq.schedule_pattern(p, 0)) + + # Send the scheduled events + seq._send_midi(seq.event_queue[0]) + + assert len(spy1.sent) >= 1 + + +def test_cc_event_device_override(patch_midi: None) -> None: + """CcEvent.device overrides pattern.device during schedule_pattern.""" + import subsequence.pattern as pat + + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy0 + seq._output_devices.add("Secondary", spy1) + + # Pattern targets device 0, but a CC event overrides to device 1 + p = pat.Pattern(channel=0, length=4, device=0) + p.cc_events.append( + pat.CcEvent( + pulse=0, + message_type="control_change", + control=7, + value=100, + device=1, + ) + ) + + asyncio.run(seq.schedule_pattern(p, 0)) + + # Find and send the CC event + for ev in seq.event_queue: + if ev.message_type == "control_change": + assert ev.device == 1 + seq._send_midi(ev) + break + + assert len(spy1.sent) == 1 + assert spy1.sent[0].control == 7 - await seq.panic() - # Both devices should have received CC 123 and CC 120 on all 16 channels - def has_all_notes_off (spy: conftest.SpyMidiOut) -> bool: - return any( - m.type == 'control_change' and m.control == 123 - for m in spy.sent - ) - - assert has_all_notes_off(spy0) - assert has_all_notes_off(spy1) +# --------------------------------------------------------------------------- +# Multi-device input routing +# --------------------------------------------------------------------------- -def test_schedule_pattern_propagates_device (patch_midi: None) -> None: - """schedule_pattern() copies pattern.device onto every MidiEvent.""" - import subsequence.pattern as pat +def test_cc_map_input_device_filter(patch_midi: None) -> None: + """CC mapping with input_device only fires for the specified device.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_mappings = [ + { + "cc": 74, + "data_key": "filter", + "channel": None, + "min_val": 0.0, + "max_val": 1.0, + "input_device": 1, # only from device 1 + } + ] + seq._composition_data = {} + + # Message from device 0 → should NOT update data + seq._on_midi_input(_cc(74, 64), device_idx=0) + assert "filter" not in seq._composition_data + + # Message from device 1 → should update data + seq._on_midi_input(_cc(74, 64), device_idx=1) + assert "filter" in seq._composition_data + + +def test_cc_map_no_device_filter(patch_midi: None) -> None: + """CC mapping with input_device=None fires for any device (default).""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_mappings = [ + { + "cc": 74, + "data_key": "filter", + "channel": None, + "min_val": 0.0, + "max_val": 1.0, + "input_device": None, + } + ] + seq._composition_data = {} + + seq._on_midi_input(_cc(74, 64), device_idx=0) + assert "filter" in seq._composition_data + seq._composition_data.clear() + + seq._on_midi_input(_cc(74, 64), device_idx=1) + assert "filter" in seq._composition_data + + +def test_cc_forward_input_device_filter(patch_midi: None) -> None: + """CC forward with input_device only fires for the specified device.""" + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = _make_sequencer(spy0) + seq._output_devices.add("Secondary", spy1) + seq.cc_forwards = [ + { + "cc": 1, + "channel": None, + "mode": "instant", + "transform": lambda v, ch: mido.Message( + "control_change", channel=ch, control=1, value=v + ), + "input_device": 1, + "output_device": 0, + } + ] + + # Message from device 0 → should NOT forward + seq._on_midi_input(_cc(1, 64), device_idx=0) + assert len(spy0.sent) == 0 + + # Message from device 1 → should forward + seq._on_midi_input(_cc(1, 64), device_idx=1) + assert len(spy0.sent) == 1 + + +def test_cc_forward_output_device_routing(patch_midi: None) -> None: + """CC forward with output_device sends to the specified output port.""" + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = _make_sequencer(spy0) + seq._output_devices.add("Secondary", spy1) + seq.cc_forwards = [ + { + "cc": 1, + "channel": None, + "mode": "instant", + "transform": lambda v, ch: mido.Message( + "control_change", channel=ch, control=1, value=v + ), + "input_device": None, + "output_device": 1, # send to device 1 + } + ] + + seq._on_midi_input(_cc(1, 64), device_idx=0) + + assert len(spy0.sent) == 0 + assert len(spy1.sent) == 1 + + +def test_multiple_midi_input_calls(patch_midi: None) -> None: + """Multiple comp.midi_input() calls register primary + additional inputs.""" + comp = subsequence.Composition(bpm=120) + comp.midi_input("Arturia KeyStep", name="keys") + comp.midi_input("Faderfox EC4", name="faders") + + assert comp._input_device == "Arturia KeyStep" + assert len(comp._additional_inputs) == 1 + assert comp._additional_inputs[0][0] == "Faderfox EC4" + assert comp._additional_inputs[0][1] == "faders" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy0 - seq._output_devices.add("Secondary", spy1) - # Build a simple pattern targeting device 1 - p = pat.Pattern(channel=0, length=4, device=1) - p.add_note(position=0, pitch=60, velocity=100, duration=24) +# --------------------------------------------------------------------------- +# Composition API — device on cc_map / cc_forward +# --------------------------------------------------------------------------- - asyncio.run(seq.schedule_pattern(p, 0)) - # Send the scheduled events - seq._send_midi(seq.event_queue[0]) +def test_cc_map_stores_input_device(patch_midi: None) -> None: + """cc_map() stores input_device in the mapping dict.""" + comp = subsequence.Composition(bpm=120) + comp.cc_map(74, "filter", input_device="faders") + assert comp._cc_mappings[-1]["input_device"] == "faders" - assert len(spy1.sent) >= 1 +def test_cc_forward_stores_input_output_device(patch_midi: None) -> None: + """cc_forward() stores input_device and output_device.""" + comp = subsequence.Composition(bpm=120) + comp.cc_forward(1, "cc", input_device="keys", output_device=1) + fwd = comp._cc_forwards[-1] + assert fwd["input_device"] == "keys" + assert fwd["output_device"] == 1 -def test_cc_event_device_override (patch_midi: None) -> None: - """CcEvent.device overrides pattern.device during schedule_pattern.""" - import subsequence.pattern as pat - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy0 - seq._output_devices.add("Secondary", spy1) +# --------------------------------------------------------------------------- +# Clock output goes to all devices +# --------------------------------------------------------------------------- - # Pattern targets device 0, but a CC event overrides to device 1 - p = pat.Pattern(channel=0, length=4, device=0) - p.cc_events.append(pat.CcEvent( - pulse=0, message_type='control_change', - control=7, value=100, device=1, - )) - asyncio.run(seq.schedule_pattern(p, 0)) +def test_clock_goes_to_all_devices(patch_midi: None) -> None: + """_send_clock_message sends to every registered output port.""" + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy0 + seq._output_devices.add("Secondary", spy1) - # Find and send the CC event - for ev in seq.event_queue: - if ev.message_type == 'control_change': - assert ev.device == 1 - seq._send_midi(ev) - break + seq._send_clock_message("clock") - assert len(spy1.sent) == 1 - assert spy1.sent[0].control == 7 + assert any(m.type == "clock" for m in spy0.sent) + assert any(m.type == "clock" for m in spy1.sent) # --------------------------------------------------------------------------- -# Multi-device input routing +# Regression fixes — issues found during code review # --------------------------------------------------------------------------- -def test_cc_map_input_device_filter (patch_midi: None) -> None: - """CC mapping with input_device only fires for the specified device.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_mappings = [{ - 'cc': 74, - 'data_key': 'filter', - 'channel': None, - 'min_val': 0.0, - 'max_val': 1.0, - 'input_device': 1, # only from device 1 - }] - seq._composition_data = {} - - # Message from device 0 → should NOT update data - seq._on_midi_input(_cc(74, 64), device_idx=0) - assert 'filter' not in seq._composition_data - - # Message from device 1 → should update data - seq._on_midi_input(_cc(74, 64), device_idx=1) - assert 'filter' in seq._composition_data - - -def test_cc_map_no_device_filter (patch_midi: None) -> None: - """CC mapping with input_device=None fires for any device (default).""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_mappings = [{ - 'cc': 74, - 'data_key': 'filter', - 'channel': None, - 'min_val': 0.0, - 'max_val': 1.0, - 'input_device': None, - }] - seq._composition_data = {} - - seq._on_midi_input(_cc(74, 64), device_idx=0) - assert 'filter' in seq._composition_data - seq._composition_data.clear() - - seq._on_midi_input(_cc(74, 64), device_idx=1) - assert 'filter' in seq._composition_data - - -def test_cc_forward_input_device_filter (patch_midi: None) -> None: - """CC forward with input_device only fires for the specified device.""" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = _make_sequencer(spy0) - seq._output_devices.add("Secondary", spy1) - seq.cc_forwards = [{ - 'cc': 1, - 'channel': None, - 'mode': 'instant', - 'transform': lambda v, ch: mido.Message('control_change', channel=ch, control=1, value=v), - 'input_device': 1, - 'output_device': 0, - }] - - # Message from device 0 → should NOT forward - seq._on_midi_input(_cc(1, 64), device_idx=0) - assert len(spy0.sent) == 0 - - # Message from device 1 → should forward - seq._on_midi_input(_cc(1, 64), device_idx=1) - assert len(spy0.sent) == 1 - - -def test_cc_forward_output_device_routing (patch_midi: None) -> None: - """CC forward with output_device sends to the specified output port.""" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = _make_sequencer(spy0) - seq._output_devices.add("Secondary", spy1) - seq.cc_forwards = [{ - 'cc': 1, - 'channel': None, - 'mode': 'instant', - 'transform': lambda v, ch: mido.Message('control_change', channel=ch, control=1, value=v), - 'input_device': None, - 'output_device': 1, # send to device 1 - }] - - seq._on_midi_input(_cc(1, 64), device_idx=0) - - assert len(spy0.sent) == 0 - assert len(spy1.sent) == 1 - - -def test_multiple_midi_input_calls (patch_midi: None) -> None: - """Multiple comp.midi_input() calls register primary + additional inputs.""" - comp = subsequence.Composition(bpm=120) - comp.midi_input("Arturia KeyStep", name="keys") - comp.midi_input("Faderfox EC4", name="faders") - - assert comp._input_device == "Arturia KeyStep" - assert len(comp._additional_inputs) == 1 - assert comp._additional_inputs[0][0] == "Faderfox EC4" - assert comp._additional_inputs[0][1] == "faders" +def test_registry_replace() -> None: + """MidiDeviceRegistry.replace() swaps the port but keeps name and index intact.""" + reg = subsequence.midi_utils.MidiDeviceRegistry() + original = object() + replacement = object() + reg.add("primary", original) + reg.replace(0, replacement) + assert reg.get(0) is replacement + assert reg.get("primary") is replacement + assert reg.index_of("primary") == 0 -# --------------------------------------------------------------------------- -# Composition API — device on cc_map / cc_forward -# --------------------------------------------------------------------------- -def test_cc_map_stores_input_device (patch_midi: None) -> None: - """cc_map() stores input_device in the mapping dict.""" - comp = subsequence.Composition(bpm=120) - comp.cc_map(74, "filter", input_device="faders") - assert comp._cc_mappings[-1]['input_device'] == "faders" +def test_registry_replace_out_of_range() -> None: + """MidiDeviceRegistry.replace() raises IndexError for out-of-range index.""" + reg = subsequence.midi_utils.MidiDeviceRegistry() + reg.add("primary", object()) + with pytest.raises(IndexError): + reg.replace(5, object()) -def test_cc_forward_stores_input_output_device (patch_midi: None) -> None: - """cc_forward() stores input_device and output_device.""" - comp = subsequence.Composition(bpm=120) - comp.cc_forward(1, "cc", input_device="keys", output_device=1) - fwd = comp._cc_forwards[-1] - assert fwd['input_device'] == "keys" - assert fwd['output_device'] == 1 +def test_midi_out_setter_uses_replace(patch_midi: None) -> None: + """midi_out setter on an existing registry preserves port name.""" + spy_original = conftest.SpyMidiOut() + spy_replacement = conftest.SpyMidiOut() + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq.midi_out = spy_original + # After setter, "Dummy MIDI" name is in the registry. + original_name = seq._output_devices._ports[0][0] + seq.midi_out = spy_replacement -# --------------------------------------------------------------------------- -# Clock output goes to all devices -# --------------------------------------------------------------------------- + # Name must be preserved (not reset to "default"). + assert seq._output_devices._ports[0][0] == original_name + assert seq.midi_out is spy_replacement -def test_clock_goes_to_all_devices (patch_midi: None) -> None: - """_send_clock_message sends to every registered output port.""" - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy0 - seq._output_devices.add("Secondary", spy1) - seq._send_clock_message("clock") +def test_pending_pattern_raw_device_field(patch_midi: None) -> None: + """_PendingPattern.raw_device is a proper typed field, not a monkey-patch.""" + comp = subsequence.Composition(bpm=120) - assert any(m.type == 'clock' for m in spy0.sent) - assert any(m.type == 'clock' for m in spy1.sent) + @comp.pattern(channel=1, beats=4, device="integra") + def strings(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass + pending = comp._pending_patterns[0] + # raw_device is the original DeviceId passed by the user. + assert pending.raw_device == "integra" + # device placeholder is 0 until _resolve_pending_devices() runs. + assert pending.device == 0 + # No monkey-patched _raw_device attribute should be needed. + assert hasattr(pending, "raw_device") -# --------------------------------------------------------------------------- -# Regression fixes — issues found during code review -# --------------------------------------------------------------------------- -def test_registry_replace () -> None: - """MidiDeviceRegistry.replace() swaps the port but keeps name and index intact.""" - reg = subsequence.midi_utils.MidiDeviceRegistry() - original = object() - replacement = object() - reg.add("primary", original) - reg.replace(0, replacement) - assert reg.get(0) is replacement - assert reg.get("primary") is replacement - assert reg.index_of("primary") == 0 - - -def test_registry_replace_out_of_range () -> None: - """MidiDeviceRegistry.replace() raises IndexError for out-of-range index.""" - reg = subsequence.midi_utils.MidiDeviceRegistry() - reg.add("primary", object()) - with pytest.raises(IndexError): - reg.replace(5, object()) +def test_pending_pattern_int_device_resolved_immediately(patch_midi: None) -> None: + """@comp.pattern(device=1) resolves immediately — raw_device and device both set.""" + comp = subsequence.Composition(bpm=120) + + @comp.pattern(channel=1, beats=4, device=1) + def strings(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass + + pending = comp._pending_patterns[0] + assert pending.raw_device == 1 + assert pending.device == 1 + + +def test_input_alias_resolves_in_cc_map(patch_midi_multi) -> None: + """cc_map(input_device='faders') resolves the alias to the correct device index.""" + comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") + comp.midi_input("Input A", name="keys") + comp.midi_input("Input B", name="faders") + comp.cc_map(74, "filter", input_device="faders") + # Manual setup for resolution (simulating what _run would populate after opening ports). + comp._input_device_names["Input A"] = 0 + if comp._input_device_alias: + comp._input_device_names[comp._input_device_alias] = 0 + comp._input_device_names["Input B"] = 1 + comp._input_device_names["faders"] = 1 -def test_midi_out_setter_uses_replace (patch_midi: None) -> None: - """midi_out setter on an existing registry preserves port name.""" - spy_original = conftest.SpyMidiOut() - spy_replacement = conftest.SpyMidiOut() - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq.midi_out = spy_original - # After setter, "Dummy MIDI" name is in the registry. - original_name = seq._output_devices._ports[0][0] + # Trigger resolution (normally done in _run). + for mapping in comp._cc_mappings: + raw = mapping.get("input_device") + if isinstance(raw, str): + mapping["input_device"] = comp._resolve_input_device_id(raw) - seq.midi_out = spy_replacement + assert comp._cc_mappings[-1]["input_device"] == 1 - # Name must be preserved (not reset to "default"). - assert seq._output_devices._ports[0][0] == original_name - assert seq.midi_out is spy_replacement +def test_input_alias_resolves_in_cc_forward(patch_midi_multi) -> None: + """cc_forward(input_device='keys') resolves to the correct index.""" + comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") + comp.midi_input("Input A", name="keys") + comp.cc_forward(1, "cc", input_device="keys") -def test_pending_pattern_raw_device_field (patch_midi: None) -> None: - """_PendingPattern.raw_device is a proper typed field, not a monkey-patch.""" - comp = subsequence.Composition(bpm=120) + comp._input_device_names["Input A"] = 0 + if comp._input_device_alias: + comp._input_device_names[comp._input_device_alias] = 0 + + for fwd in comp._cc_forwards: + raw_in = fwd.get("input_device") + if isinstance(raw_in, str): + fwd["input_device"] = comp._resolve_input_device_id(raw_in) - @comp.pattern(channel=1, beats=4, device="integra") - def strings (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + assert comp._cc_forwards[-1]["input_device"] == 0 - pending = comp._pending_patterns[0] - # raw_device is the original DeviceId passed by the user. - assert pending.raw_device == "integra" - # device placeholder is 0 until _resolve_pending_devices() runs. - assert pending.device == 0 - # No monkey-patched _raw_device attribute should be needed. - assert hasattr(pending, 'raw_device') - - -def test_pending_pattern_int_device_resolved_immediately (patch_midi: None) -> None: - """@comp.pattern(device=1) resolves immediately — raw_device and device both set.""" - comp = subsequence.Composition(bpm=120) - - @comp.pattern(channel=1, beats=4, device=1) - def strings (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass - pending = comp._pending_patterns[0] - assert pending.raw_device == 1 - assert pending.device == 1 +def test_unknown_output_device_name_warns_and_defaults_to_zero( + patch_midi: None, caplog +) -> None: + """_resolve_device_id() logs a warning and falls back to device 0 on unknown name.""" + import logging + comp = subsequence.Composition(bpm=120) + comp._output_device_names["known"] = 1 -def test_input_alias_resolves_in_cc_map (patch_midi_multi) -> None: - """cc_map(input_device='faders') resolves the alias to the correct device index.""" - comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") - comp.midi_input("Input A", name="keys") - comp.midi_input("Input B", name="faders") - comp.cc_map(74, "filter", input_device="faders") + with caplog.at_level(logging.WARNING, logger="subsequence.composition"): + result = comp._resolve_device_id("typo_name") - # Manual setup for resolution (simulating what _run would populate after opening ports). - comp._input_device_names["Input A"] = 0 - if comp._input_device_alias: - comp._input_device_names[comp._input_device_alias] = 0 - comp._input_device_names["Input B"] = 1 - comp._input_device_names["faders"] = 1 + assert result == 0 + assert "typo_name" in caplog.text - # Trigger resolution (normally done in _run). - for mapping in comp._cc_mappings: - raw = mapping.get('input_device') - if isinstance(raw, str): - mapping['input_device'] = comp._resolve_input_device_id(raw) - assert comp._cc_mappings[-1]['input_device'] == 1 +def test_unknown_input_device_name_warns_and_never_matches( + patch_midi: None, caplog +) -> None: + """_resolve_input_device_id() warns and returns -1 on an unknown name. + -1 is an index no real device carries, so the mapping matches NOTHING — + returning None would fail OPEN (None means match-any) while the warning + claims the mapping will be ignored. + """ + import logging -def test_input_alias_resolves_in_cc_forward (patch_midi_multi) -> None: - """cc_forward(input_device='keys') resolves to the correct index.""" - comp = subsequence.Composition(bpm=120, output_device="Primary MIDI") - comp.midi_input("Input A", name="keys") - comp.cc_forward(1, "cc", input_device="keys") + comp = subsequence.Composition(bpm=120) + comp._input_device_names["known"] = 1 - comp._input_device_names["Input A"] = 0 - if comp._input_device_alias: - comp._input_device_names[comp._input_device_alias] = 0 + with caplog.at_level(logging.WARNING, logger="subsequence.composition"): + result = comp._resolve_input_device_id("typo_name") - for fwd in comp._cc_forwards: - raw_in = fwd.get('input_device') - if isinstance(raw_in, str): - fwd['input_device'] = comp._resolve_input_device_id(raw_in) + assert result == -1 + assert "typo_name" in caplog.text - assert comp._cc_forwards[-1]['input_device'] == 0 +def test_cc_map_input_device_none_means_any(patch_midi: None) -> None: + """A cc_map whose input_device is explicitly None matches any input device.""" + spy = conftest.SpyMidiOut() + seq = _make_sequencer(spy) + seq.cc_mappings = [ + { + "cc": 74, + "data_key": "filter", + "channel": None, + "min_val": 0.0, + "max_val": 1.0, + "input_device": None, # None = any device + } + ] + seq._composition_data = {} -def test_unknown_output_device_name_warns_and_defaults_to_zero (patch_midi: None, caplog) -> None: - """_resolve_device_id() logs a warning and falls back to device 0 on unknown name.""" - import logging - comp = subsequence.Composition(bpm=120) - comp._output_device_names["known"] = 1 + # Should fire for any device_idx when input_device is None. + seq._on_midi_input(_cc(74, 64), device_idx=0) + assert "filter" in seq._composition_data + seq._composition_data.clear() - with caplog.at_level(logging.WARNING, logger="subsequence.composition"): - result = comp._resolve_device_id("typo_name") + seq._on_midi_input(_cc(74, 64), device_idx=2) + assert "filter" in seq._composition_data - assert result == 0 - assert "typo_name" in caplog.text +# --------------------------------------------------------------------------- +# Multiple clock follower device tests +# --------------------------------------------------------------------------- -def test_unknown_input_device_name_warns_and_never_matches (patch_midi: None, caplog) -> None: - """_resolve_input_device_id() warns and returns -1 on an unknown name. - -1 is an index no real device carries, so the mapping matches NOTHING — - returning None would fail OPEN (None means match-any) while the warning - claims the mapping will be ignored. - """ - import logging - comp = subsequence.Composition(bpm=120) - comp._input_device_names["known"] = 1 +def test_multiple_clock_follow_raises_error(patch_midi: None) -> None: + """Setting clock_follow=True on multiple devices raises ValueError.""" + comp = subsequence.Composition(bpm=120) + comp.midi_input("Primary MIDI", clock_follow=True) - with caplog.at_level(logging.WARNING, logger="subsequence.composition"): - result = comp._resolve_input_device_id("typo_name") + with pytest.raises( + ValueError, + match="Only one input device can be configured to follow external clock", + ): + comp.midi_input("Secondary MIDI", clock_follow=True) - assert result == -1 - assert "typo_name" in caplog.text +@pytest.mark.asyncio +async def test_clock_follower_ignores_other_devices(monkeypatch) -> None: + """Sequencer ignores clock messages from devices that are not the clock_device_idx.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", + initial_bpm=120, + input_device_name="Mock MIDI", + clock_follow=True, + spin_wait=False, + ) + seq.clock_device_idx = 1 # We only want clock from device 1 -def test_cc_map_input_device_none_means_any (patch_midi: None) -> None: - """A cc_map whose input_device is explicitly None matches any input device.""" - spy = conftest.SpyMidiOut() - seq = _make_sequencer(spy) - seq.cc_mappings = [{ - 'cc': 74, - 'data_key': 'filter', - 'channel': None, - 'min_val': 0.0, - 'max_val': 1.0, - 'input_device': None, # None = any device - }] - seq._composition_data = {} + seq._midi_input_queue = asyncio.Queue() + seq.running = True - # Should fire for any device_idx when input_device is None. - seq._on_midi_input(_cc(74, 64), device_idx=0) - assert 'filter' in seq._composition_data - seq._composition_data.clear() + # Create a mock for _estimate_bpm to track if clock was processed + processed_clocks = [] - seq._on_midi_input(_cc(74, 64), device_idx=2) - assert 'filter' in seq._composition_data + def mock_estimate_bpm(t: float) -> None: + processed_clocks.append(t) + monkeypatch.setattr(seq, "_estimate_bpm", mock_estimate_bpm) + monkeypatch.setattr(seq, "_check_bar_change", lambda p, b: None) + monkeypatch.setattr(seq, "_check_beat_change", lambda p, b: None) + monkeypatch.setattr(seq, "_advance_pulse", lambda: asyncio.sleep(0)) -# --------------------------------------------------------------------------- -# Multiple clock follower device tests -# --------------------------------------------------------------------------- + # Send a clock message from device 0 (should be ignored) + seq._midi_input_queue.put_nowait((0, mido.Message("clock"))) -def test_multiple_clock_follow_raises_error (patch_midi: None) -> None: - """Setting clock_follow=True on multiple devices raises ValueError.""" - comp = subsequence.Composition(bpm=120) - comp.midi_input("Primary MIDI", clock_follow=True) - - with pytest.raises(ValueError, match="Only one input device can be configured to follow external clock"): - comp.midi_input("Secondary MIDI", clock_follow=True) + # Send a clock message from device 1 (should be processed) + seq._midi_input_queue.put_nowait((1, mido.Message("clock"))) + # Send a stop message from device 1 to break the loop + seq._midi_input_queue.put_nowait((1, mido.Message("stop"))) -@pytest.mark.asyncio -async def test_clock_follower_ignores_other_devices (monkeypatch) -> None: - """Sequencer ignores clock messages from devices that are not the clock_device_idx.""" - seq = subsequence.sequencer.Sequencer( - output_device_name="Dummy MIDI", - initial_bpm=120, - input_device_name="Mock MIDI", - clock_follow=True, - spin_wait=False - ) - seq.clock_device_idx = 1 # We only want clock from device 1 - - seq._midi_input_queue = asyncio.Queue() - seq.running = True - - # Create a mock for _estimate_bpm to track if clock was processed - processed_clocks = [] - def mock_estimate_bpm (t: float) -> None: - processed_clocks.append(t) - monkeypatch.setattr(seq, "_estimate_bpm", mock_estimate_bpm) - monkeypatch.setattr(seq, "_check_bar_change", lambda p, b: None) - monkeypatch.setattr(seq, "_check_beat_change", lambda p, b: None) - monkeypatch.setattr(seq, "_advance_pulse", lambda: asyncio.sleep(0)) - - # Send a clock message from device 0 (should be ignored) - seq._midi_input_queue.put_nowait((0, mido.Message('clock'))) - - # Send a clock message from device 1 (should be processed) - seq._midi_input_queue.put_nowait((1, mido.Message('clock'))) - - # Send a stop message from device 1 to break the loop - seq._midi_input_queue.put_nowait((1, mido.Message('stop'))) - - await seq._run_loop_external_clock(96) - - # Only the clock message from device 1 should have reached _estimate_bpm - assert len(processed_clocks) == 1 + await seq._run_loop_external_clock(96) + + # Only the clock message from device 1 should have reached _estimate_bpm + assert len(processed_clocks) == 1 diff --git a/tests/test_new_chord_graphs.py b/tests/test_new_chord_graphs.py index abef0f0..7232147 100644 --- a/tests/test_new_chord_graphs.py +++ b/tests/test_new_chord_graphs.py @@ -18,717 +18,685 @@ # ---- Helpers ---- -def _assert_no_dead_ends (graph_obj: subsequence.chord_graphs.ChordGraph, key: str) -> int: - """BFS from tonic - every reachable chord must have outgoing transitions. Returns node count.""" +def _assert_no_dead_ends( + graph_obj: subsequence.chord_graphs.ChordGraph, key: str +) -> int: + """BFS from tonic - every reachable chord must have outgoing transitions. Returns node count.""" - graph, tonic = graph_obj.build(key) + graph, tonic = graph_obj.build(key) - visited = set() - queue = [tonic] + visited = set() + queue = [tonic] - while queue: - current = queue.pop(0) + while queue: + current = queue.pop(0) - if current in visited: - continue + if current in visited: + continue - visited.add(current) + visited.add(current) - transitions = graph.get_transitions(current) + transitions = graph.get_transitions(current) - assert len(transitions) > 0, f"Dead end: {current} has no outgoing transitions" + assert len(transitions) > 0, f"Dead end: {current} has no outgoing transitions" - for target, _ in transitions: + for target, _ in transitions: + if target not in visited: + queue.append(target) - if target not in visited: - queue.append(target) + return len(visited) - return len(visited) +def _assert_stepping_stable(style: str, key: str = "C", steps: int = 50) -> None: + """Calling step() many times should not raise.""" -def _assert_stepping_stable (style: str, key: str = "C", steps: int = 50) -> None: + state = subsequence.harmonic_state.HarmonicState( + key_name=key, graph_style=style, key_gravity_blend=0.8 + ) - """Calling step() many times should not raise.""" + rng = random.Random(42) + state.rng = rng - state = subsequence.harmonic_state.HarmonicState( - key_name = key, - graph_style = style, - key_gravity_blend = 0.8 - ) - - rng = random.Random(42) - state.rng = rng - - for _ in range(steps): - state.step() + for _ in range(steps): + state.step() # =========================================================================== # Lydian Major # =========================================================================== -class TestLydianMajor: - - def test_tonic_is_major (self) -> None: - - """Tonic should be a major chord on the key root.""" - - graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() - _, tonic = graph_obj.build("C") - - assert tonic.quality == "major" - assert tonic.root_pc == 0 - - def test_lydian_shimmer (self) -> None: - - """The graph should include a II → I transition (the Lydian shimmer).""" - graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() - graph, tonic = graph_obj.build("C") - - # II of C Lydian is D major (root_pc=2). - supertonic = subsequence.chords.Chord(root_pc=2, quality="major") - - transitions = graph.get_transitions(supertonic) +class TestLydianMajor: + def test_tonic_is_major(self) -> None: + """Tonic should be a major chord on the key root.""" - assert any(chord == tonic for chord, _ in transitions) + graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() + _, tonic = graph_obj.build("C") - def test_no_natural_iv (self) -> None: + assert tonic.quality == "major" + assert tonic.root_pc == 0 - """The graph should not contain a natural IV chord (F major in key of C).""" + def test_lydian_shimmer(self) -> None: + """The graph should include a II → I transition (the Lydian shimmer).""" - graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() - graph, tonic = graph_obj.build("C") + graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() + graph, tonic = graph_obj.build("C") - natural_iv = subsequence.chords.Chord(root_pc=5, quality="major") + # II of C Lydian is D major (root_pc=2). + supertonic = subsequence.chords.Chord(root_pc=2, quality="major") - # Walk all reachable chords. - visited = set() - queue = [tonic] + transitions = graph.get_transitions(supertonic) - while queue: - current = queue.pop(0) + assert any(chord == tonic for chord, _ in transitions) - if current in visited: - continue + def test_no_natural_iv(self) -> None: + """The graph should not contain a natural IV chord (F major in key of C).""" - visited.add(current) + graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() + graph, tonic = graph_obj.build("C") - assert current != natural_iv, "Natural IV should not appear in a Lydian graph" + natural_iv = subsequence.chords.Chord(root_pc=5, quality="major") - for target, _ in graph.get_transitions(current): + # Walk all reachable chords. + visited = set() + queue = [tonic] - if target not in visited: - queue.append(target) + while queue: + current = queue.pop(0) - def test_no_dead_ends (self) -> None: + if current in visited: + continue - """Every chord reachable in the graph should have outgoing transitions.""" + visited.add(current) - graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor(include_dominant_7th=True) - _assert_no_dead_ends(graph_obj, "C") + assert current != natural_iv, ( + "Natural IV should not appear in a Lydian graph" + ) - def test_gravity_sets (self) -> None: + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) - """Tonic should be in both diatonic and functional sets. II should be in functional.""" + def test_no_dead_ends(self) -> None: + """Every chord reachable in the graph should have outgoing transitions.""" - graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() - diatonic, functional = graph_obj.gravity_sets("C") + graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor( + include_dominant_7th=True + ) + _assert_no_dead_ends(graph_obj, "C") - tonic = subsequence.chords.Chord(root_pc=0, quality="major") + def test_gravity_sets(self) -> None: + """Tonic should be in both diatonic and functional sets. II should be in functional.""" - assert tonic in diatonic - assert tonic in functional + graph_obj = subsequence.chord_graphs.lydian_major.LydianMajor() + diatonic, functional = graph_obj.gravity_sets("C") - # II (D major) - the Lydian signature - should be functional. - supertonic = subsequence.chords.Chord(root_pc=2, quality="major") + tonic = subsequence.chords.Chord(root_pc=0, quality="major") - assert supertonic in functional + assert tonic in diatonic + assert tonic in functional - def test_string_name (self) -> None: + # II (D major) - the Lydian signature - should be functional. + supertonic = subsequence.chords.Chord(root_pc=2, quality="major") - """HarmonicState should accept 'lydian_major' as a style string.""" + assert supertonic in functional - state = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="lydian_major") + def test_string_name(self) -> None: + """HarmonicState should accept 'lydian_major' as a style string.""" - assert state.current_chord.quality == "major" + state = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="lydian_major" + ) - def test_stepping_stable (self) -> None: + assert state.current_chord.quality == "major" - """Stepping 50 times should not raise any errors.""" + def test_stepping_stable(self) -> None: + """Stepping 50 times should not raise any errors.""" - _assert_stepping_stable("lydian_major") + _assert_stepping_stable("lydian_major") # =========================================================================== # Dorian Minor # =========================================================================== -class TestDorianMinor: - - def test_tonic_is_minor (self) -> None: - - """Tonic should be a minor chord on the key root.""" - - graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor() - _, tonic = graph_obj.build("D") - - assert tonic.quality == "minor" - assert tonic.root_pc == 2 - - def test_dorian_iv_major (self) -> None: - """The graph should include a IV (major) → i transition.""" - - graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor() - graph, tonic = graph_obj.build("D") +class TestDorianMinor: + def test_tonic_is_minor(self) -> None: + """Tonic should be a minor chord on the key root.""" - # IV of D Dorian is G major (root_pc=7). - subdominant = subsequence.chords.Chord(root_pc=7, quality="major") + graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor() + _, tonic = graph_obj.build("D") - transitions = graph.get_transitions(subdominant) + assert tonic.quality == "minor" + assert tonic.root_pc == 2 - assert any(chord == tonic for chord, _ in transitions) + def test_dorian_iv_major(self) -> None: + """The graph should include a IV (major) → i transition.""" - def test_no_dead_ends (self) -> None: + graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor() + graph, tonic = graph_obj.build("D") - """Every chord reachable in the graph should have outgoing transitions.""" + # IV of D Dorian is G major (root_pc=7). + subdominant = subsequence.chords.Chord(root_pc=7, quality="major") - graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor(include_dominant_7th=True) - _assert_no_dead_ends(graph_obj, "D") + transitions = graph.get_transitions(subdominant) - def test_gravity_sets (self) -> None: + assert any(chord == tonic for chord, _ in transitions) - """Tonic should be in both sets. IV (major) should be in functional.""" + def test_no_dead_ends(self) -> None: + """Every chord reachable in the graph should have outgoing transitions.""" - graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor() - diatonic, functional = graph_obj.gravity_sets("D") + graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor( + include_dominant_7th=True + ) + _assert_no_dead_ends(graph_obj, "D") - tonic = subsequence.chords.Chord(root_pc=2, quality="minor") + def test_gravity_sets(self) -> None: + """Tonic should be in both sets. IV (major) should be in functional.""" - assert tonic in diatonic - assert tonic in functional + graph_obj = subsequence.chord_graphs.dorian_minor.DorianMinor() + diatonic, functional = graph_obj.gravity_sets("D") - # IV (G major) should be in functional. - subdominant = subsequence.chords.Chord(root_pc=7, quality="major") + tonic = subsequence.chords.Chord(root_pc=2, quality="minor") - assert subdominant in functional + assert tonic in diatonic + assert tonic in functional - def test_string_name (self) -> None: + # IV (G major) should be in functional. + subdominant = subsequence.chords.Chord(root_pc=7, quality="major") - """HarmonicState should accept 'dorian_minor' as a style string.""" + assert subdominant in functional - state = subsequence.harmonic_state.HarmonicState(key_name="D", graph_style="dorian_minor") + def test_string_name(self) -> None: + """HarmonicState should accept 'dorian_minor' as a style string.""" - assert state.current_chord.quality == "minor" + state = subsequence.harmonic_state.HarmonicState( + key_name="D", graph_style="dorian_minor" + ) - def test_stepping_stable (self) -> None: + assert state.current_chord.quality == "minor" - """Stepping 50 times should not raise any errors.""" + def test_stepping_stable(self) -> None: + """Stepping 50 times should not raise any errors.""" - _assert_stepping_stable("dorian_minor", key="D") + _assert_stepping_stable("dorian_minor", key="D") # =========================================================================== # Chromatic Mediant # =========================================================================== -class TestChromaticMediant: - - def test_tonic_is_major (self) -> None: - - """Tonic should be a major chord on the key root.""" - - graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() - _, tonic = graph_obj.build("C") - - assert tonic.quality == "major" - assert tonic.root_pc == 0 - - def test_mediant_transition (self) -> None: - - """The graph should include a bIII → I transition (third relation).""" - graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() - graph, tonic = graph_obj.build("C") +class TestChromaticMediant: + def test_tonic_is_major(self) -> None: + """Tonic should be a major chord on the key root.""" - # bIII of C is Eb major (root_pc=3). - flat_mediant = subsequence.chords.Chord(root_pc=3, quality="major") + graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() + _, tonic = graph_obj.build("C") - transitions = graph.get_transitions(flat_mediant) + assert tonic.quality == "major" + assert tonic.root_pc == 0 - assert any(chord == tonic for chord, _ in transitions) + def test_mediant_transition(self) -> None: + """The graph should include a bIII → I transition (third relation).""" - def test_no_dead_ends (self) -> None: + graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() + graph, tonic = graph_obj.build("C") - """Every chord reachable in the graph should have outgoing transitions.""" + # bIII of C is Eb major (root_pc=3). + flat_mediant = subsequence.chords.Chord(root_pc=3, quality="major") - graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() - _assert_no_dead_ends(graph_obj, "C") + transitions = graph.get_transitions(flat_mediant) - def test_gravity_sets (self) -> None: + assert any(chord == tonic for chord, _ in transitions) - """Tonic and bIII should be in the functional set.""" + def test_no_dead_ends(self) -> None: + """Every chord reachable in the graph should have outgoing transitions.""" - graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() - diatonic, functional = graph_obj.gravity_sets("C") + graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() + _assert_no_dead_ends(graph_obj, "C") - tonic = subsequence.chords.Chord(root_pc=0, quality="major") + def test_gravity_sets(self) -> None: + """Tonic and bIII should be in the functional set.""" - assert tonic in diatonic - assert tonic in functional + graph_obj = subsequence.chord_graphs.chromatic_mediant.ChromaticMediant() + diatonic, functional = graph_obj.gravity_sets("C") - flat_mediant = subsequence.chords.Chord(root_pc=3, quality="major") + tonic = subsequence.chords.Chord(root_pc=0, quality="major") - assert flat_mediant in functional + assert tonic in diatonic + assert tonic in functional - def test_string_name (self) -> None: + flat_mediant = subsequence.chords.Chord(root_pc=3, quality="major") - """HarmonicState should accept 'chromatic_mediant' as a style string.""" + assert flat_mediant in functional - state = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="chromatic_mediant") + def test_string_name(self) -> None: + """HarmonicState should accept 'chromatic_mediant' as a style string.""" - assert state.current_chord.quality == "major" + state = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="chromatic_mediant" + ) - def test_stepping_stable (self) -> None: + assert state.current_chord.quality == "major" - """Stepping 50 times should not raise any errors.""" + def test_stepping_stable(self) -> None: + """Stepping 50 times should not raise any errors.""" - _assert_stepping_stable("chromatic_mediant") + _assert_stepping_stable("chromatic_mediant") # =========================================================================== # Suspended # =========================================================================== -class TestSuspended: - - def test_sus_chord_qualities_exist (self) -> None: - - """sus2 and sus4 chord qualities should be defined.""" - - assert "sus2" in subsequence.chords.CHORD_INTERVALS - assert "sus4" in subsequence.chords.CHORD_INTERVALS - - assert subsequence.chords.CHORD_INTERVALS["sus2"] == [0, 2, 7] - assert subsequence.chords.CHORD_INTERVALS["sus4"] == [0, 5, 7] - - def test_tonic_is_sus2 (self) -> None: - - """Tonic should be a sus2 chord on the key root.""" - - graph_obj = subsequence.chord_graphs.suspended.Suspended() - _, tonic = graph_obj.build("A") - assert tonic.quality == "sus2" - assert tonic.root_pc == 9 - - def test_colour_change (self) -> None: - - """The graph should include a sus2 → sus4 colour change on the same root.""" +class TestSuspended: + def test_sus_chord_qualities_exist(self) -> None: + """sus2 and sus4 chord qualities should be defined.""" - graph_obj = subsequence.chord_graphs.suspended.Suspended() - graph, tonic = graph_obj.build("A") + assert "sus2" in subsequence.chords.CHORD_INTERVALS + assert "sus4" in subsequence.chords.CHORD_INTERVALS - tonic_sus4 = subsequence.chords.Chord(root_pc=9, quality="sus4") - transitions = graph.get_transitions(tonic) + assert subsequence.chords.CHORD_INTERVALS["sus2"] == [0, 2, 7] + assert subsequence.chords.CHORD_INTERVALS["sus4"] == [0, 5, 7] - assert any(chord == tonic_sus4 for chord, _ in transitions) + def test_tonic_is_sus2(self) -> None: + """Tonic should be a sus2 chord on the key root.""" - def test_minor_resolution (self) -> None: + graph_obj = subsequence.chord_graphs.suspended.Suspended() + _, tonic = graph_obj.build("A") - """The graph should include a path to the minor tonic resolution chord.""" + assert tonic.quality == "sus2" + assert tonic.root_pc == 9 - graph_obj = subsequence.chord_graphs.suspended.Suspended() - graph, tonic = graph_obj.build("A") + def test_colour_change(self) -> None: + """The graph should include a sus2 → sus4 colour change on the same root.""" - tonic_minor = subsequence.chords.Chord(root_pc=9, quality="minor") + graph_obj = subsequence.chord_graphs.suspended.Suspended() + graph, tonic = graph_obj.build("A") - # Walk all reachable chords and check that tonic_minor is reachable. - visited = set() - queue = [tonic] + tonic_sus4 = subsequence.chords.Chord(root_pc=9, quality="sus4") + transitions = graph.get_transitions(tonic) - while queue: - current = queue.pop(0) + assert any(chord == tonic_sus4 for chord, _ in transitions) - if current in visited: - continue + def test_minor_resolution(self) -> None: + """The graph should include a path to the minor tonic resolution chord.""" - visited.add(current) + graph_obj = subsequence.chord_graphs.suspended.Suspended() + graph, tonic = graph_obj.build("A") - for target, _ in graph.get_transitions(current): + tonic_minor = subsequence.chords.Chord(root_pc=9, quality="minor") - if target not in visited: - queue.append(target) + # Walk all reachable chords and check that tonic_minor is reachable. + visited = set() + queue = [tonic] - assert tonic_minor in visited, "Minor tonic should be reachable from the graph" + while queue: + current = queue.pop(0) - def test_no_dead_ends (self) -> None: + if current in visited: + continue - """Every chord reachable in the graph should have outgoing transitions.""" + visited.add(current) - graph_obj = subsequence.chord_graphs.suspended.Suspended() - _assert_no_dead_ends(graph_obj, "A") + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) - def test_gravity_sets (self) -> None: + assert tonic_minor in visited, "Minor tonic should be reachable from the graph" - """Tonic sus2/sus4 and minor should be in the functional set.""" + def test_no_dead_ends(self) -> None: + """Every chord reachable in the graph should have outgoing transitions.""" - graph_obj = subsequence.chord_graphs.suspended.Suspended() - diatonic, functional = graph_obj.gravity_sets("A") + graph_obj = subsequence.chord_graphs.suspended.Suspended() + _assert_no_dead_ends(graph_obj, "A") - tonic_sus2 = subsequence.chords.Chord(root_pc=9, quality="sus2") - tonic_sus4 = subsequence.chords.Chord(root_pc=9, quality="sus4") - tonic_minor = subsequence.chords.Chord(root_pc=9, quality="minor") + def test_gravity_sets(self) -> None: + """Tonic sus2/sus4 and minor should be in the functional set.""" - assert tonic_sus2 in functional - assert tonic_sus4 in functional - assert tonic_minor in functional + graph_obj = subsequence.chord_graphs.suspended.Suspended() + diatonic, functional = graph_obj.gravity_sets("A") - assert tonic_sus2 in diatonic - assert tonic_sus4 in diatonic - assert tonic_minor in diatonic + tonic_sus2 = subsequence.chords.Chord(root_pc=9, quality="sus2") + tonic_sus4 = subsequence.chords.Chord(root_pc=9, quality="sus4") + tonic_minor = subsequence.chords.Chord(root_pc=9, quality="minor") - def test_string_name (self) -> None: + assert tonic_sus2 in functional + assert tonic_sus4 in functional + assert tonic_minor in functional - """HarmonicState should accept 'suspended' as a style string.""" + assert tonic_sus2 in diatonic + assert tonic_sus4 in diatonic + assert tonic_minor in diatonic - state = subsequence.harmonic_state.HarmonicState(key_name="A", graph_style="suspended") + def test_string_name(self) -> None: + """HarmonicState should accept 'suspended' as a style string.""" - assert state.current_chord.quality == "sus2" + state = subsequence.harmonic_state.HarmonicState( + key_name="A", graph_style="suspended" + ) - def test_stepping_stable (self) -> None: + assert state.current_chord.quality == "sus2" - """Stepping 50 times should not raise any errors.""" + def test_stepping_stable(self) -> None: + """Stepping 50 times should not raise any errors.""" - _assert_stepping_stable("suspended", key="A") + _assert_stepping_stable("suspended", key="A") # =========================================================================== # Mixolydian # =========================================================================== -class TestMixolydian: - - def test_tonic_is_major (self) -> None: - - """Tonic should be a major chord on the key root.""" - - graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() - _, tonic = graph_obj.build("C") - - assert tonic.quality == "major" - assert tonic.root_pc == 0 - - def test_flat_seven_is_major (self) -> None: - """bVII should be a major chord (the Mixolydian signature).""" - - graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() - graph, tonic = graph_obj.build("C") - - # bVII of C is Bb major (root_pc=10). - flat_seven = subsequence.chords.Chord(root_pc=10, quality="major") - - transitions = graph.get_transitions(tonic) +class TestMixolydian: + def test_tonic_is_major(self) -> None: + """Tonic should be a major chord on the key root.""" - assert any(chord == flat_seven for chord, _ in transitions) + graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() + _, tonic = graph_obj.build("C") - def test_no_dominant_major (self) -> None: + assert tonic.quality == "major" + assert tonic.root_pc == 0 - """v should be minor (not V major) - Mixolydian avoids dominant function.""" + def test_flat_seven_is_major(self) -> None: + """bVII should be a major chord (the Mixolydian signature).""" - graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() - graph, tonic = graph_obj.build("C") + graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() + graph, tonic = graph_obj.build("C") - # Walk all reachable chords - G major (root_pc=7) should not appear. - dominant_major = subsequence.chords.Chord(root_pc=7, quality="major") + # bVII of C is Bb major (root_pc=10). + flat_seven = subsequence.chords.Chord(root_pc=10, quality="major") - visited = set() - queue = [tonic] + transitions = graph.get_transitions(tonic) - while queue: - current = queue.pop(0) + assert any(chord == flat_seven for chord, _ in transitions) - if current in visited: - continue + def test_no_dominant_major(self) -> None: + """v should be minor (not V major) - Mixolydian avoids dominant function.""" - visited.add(current) + graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() + graph, tonic = graph_obj.build("C") - assert current != dominant_major, "V major should not appear in a Mixolydian graph" + # Walk all reachable chords - G major (root_pc=7) should not appear. + dominant_major = subsequence.chords.Chord(root_pc=7, quality="major") - for target, _ in graph.get_transitions(current): + visited = set() + queue = [tonic] - if target not in visited: - queue.append(target) + while queue: + current = queue.pop(0) - def test_no_dead_ends (self) -> None: + if current in visited: + continue - """Every chord reachable in the graph should have outgoing transitions.""" + visited.add(current) - graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() - _assert_no_dead_ends(graph_obj, "C") + assert current != dominant_major, ( + "V major should not appear in a Mixolydian graph" + ) - def test_gravity_sets (self) -> None: + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) - """Diatonic set should have 7 chords. Functional should include I, IV, bVII.""" + def test_no_dead_ends(self) -> None: + """Every chord reachable in the graph should have outgoing transitions.""" - graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() - diatonic, functional = graph_obj.gravity_sets("C") + graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() + _assert_no_dead_ends(graph_obj, "C") - assert len(diatonic) == 7 + def test_gravity_sets(self) -> None: + """Diatonic set should have 7 chords. Functional should include I, IV, bVII.""" - tonic = subsequence.chords.Chord(root_pc=0, quality="major") - subdominant = subsequence.chords.Chord(root_pc=5, quality="major") - flat_seven = subsequence.chords.Chord(root_pc=10, quality="major") + graph_obj = subsequence.chord_graphs.mixolydian.Mixolydian() + diatonic, functional = graph_obj.gravity_sets("C") - assert tonic in functional - assert subdominant in functional - assert flat_seven in functional + assert len(diatonic) == 7 - def test_string_name (self) -> None: + tonic = subsequence.chords.Chord(root_pc=0, quality="major") + subdominant = subsequence.chords.Chord(root_pc=5, quality="major") + flat_seven = subsequence.chords.Chord(root_pc=10, quality="major") - """HarmonicState should accept 'mixolydian' as a style string.""" + assert tonic in functional + assert subdominant in functional + assert flat_seven in functional - state = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="mixolydian") + def test_string_name(self) -> None: + """HarmonicState should accept 'mixolydian' as a style string.""" - assert state.current_chord.quality == "major" + state = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="mixolydian" + ) - def test_stepping_stable (self) -> None: + assert state.current_chord.quality == "major" - """Stepping 100 times should not raise any errors.""" + def test_stepping_stable(self) -> None: + """Stepping 100 times should not raise any errors.""" - _assert_stepping_stable("mixolydian", steps=100) + _assert_stepping_stable("mixolydian", steps=100) # =========================================================================== # Whole Tone # =========================================================================== -class TestWholeTone: - - def test_all_augmented (self) -> None: - - """Every chord in the graph should be augmented quality.""" - - graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() - graph, tonic = graph_obj.build("C") - - visited = set() - queue = [tonic] - while queue: - current = queue.pop(0) - - if current in visited: - continue - - visited.add(current) - - assert current.quality == "augmented", f"Expected augmented, got {current.quality}" - - for target, _ in graph.get_transitions(current): - - if target not in visited: - queue.append(target) +class TestWholeTone: + def test_all_augmented(self) -> None: + """Every chord in the graph should be augmented quality.""" - def test_six_chords (self) -> None: + graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() + graph, tonic = graph_obj.build("C") - """The graph should contain exactly 6 chords.""" + visited = set() + queue = [tonic] - graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() - count = _assert_no_dead_ends(graph_obj, "C") + while queue: + current = queue.pop(0) - assert count == 6 + if current in visited: + continue - def test_fully_connected (self) -> None: + visited.add(current) - """Every chord should have edges to all 5 other chords.""" + assert current.quality == "augmented", ( + f"Expected augmented, got {current.quality}" + ) - graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() - graph, tonic = graph_obj.build("C") + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) - visited = set() - queue = [tonic] + def test_six_chords(self) -> None: + """The graph should contain exactly 6 chords.""" - while queue: - current = queue.pop(0) + graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() + count = _assert_no_dead_ends(graph_obj, "C") - if current in visited: - continue + assert count == 6 - visited.add(current) + def test_fully_connected(self) -> None: + """Every chord should have edges to all 5 other chords.""" - transitions = graph.get_transitions(current) + graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() + graph, tonic = graph_obj.build("C") - assert len(transitions) == 5, f"Expected 5 transitions, got {len(transitions)}" + visited = set() + queue = [tonic] - for target, _ in transitions: + while queue: + current = queue.pop(0) - if target not in visited: - queue.append(target) + if current in visited: + continue - def test_no_dead_ends (self) -> None: + visited.add(current) - """Every chord reachable in the graph should have outgoing transitions.""" + transitions = graph.get_transitions(current) - graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() - _assert_no_dead_ends(graph_obj, "C") + assert len(transitions) == 5, ( + f"Expected 5 transitions, got {len(transitions)}" + ) - def test_gravity_sets (self) -> None: + for target, _ in transitions: + if target not in visited: + queue.append(target) - """Diatonic set should have 6 chords. Functional should have 1 (tonic only).""" + def test_no_dead_ends(self) -> None: + """Every chord reachable in the graph should have outgoing transitions.""" - graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() - diatonic, functional = graph_obj.gravity_sets("C") + graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() + _assert_no_dead_ends(graph_obj, "C") - assert len(diatonic) == 6 - assert len(functional) == 1 + def test_gravity_sets(self) -> None: + """Diatonic set should have 6 chords. Functional should have 1 (tonic only).""" - tonic = subsequence.chords.Chord(root_pc=0, quality="augmented") + graph_obj = subsequence.chord_graphs.whole_tone.WholeTone() + diatonic, functional = graph_obj.gravity_sets("C") - assert tonic in diatonic - assert tonic in functional + assert len(diatonic) == 6 + assert len(functional) == 1 - def test_string_name (self) -> None: + tonic = subsequence.chords.Chord(root_pc=0, quality="augmented") - """HarmonicState should accept 'whole_tone' as a style string.""" + assert tonic in diatonic + assert tonic in functional - state = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="whole_tone") + def test_string_name(self) -> None: + """HarmonicState should accept 'whole_tone' as a style string.""" - assert state.current_chord.quality == "augmented" + state = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="whole_tone" + ) - def test_stepping_stable (self) -> None: + assert state.current_chord.quality == "augmented" - """Stepping 100 times should not raise any errors.""" + def test_stepping_stable(self) -> None: + """Stepping 100 times should not raise any errors.""" - _assert_stepping_stable("whole_tone", steps=100) + _assert_stepping_stable("whole_tone", steps=100) # =========================================================================== # Diminished # =========================================================================== -class TestDiminished: - - def test_diminished_count (self) -> None: - - """The graph should contain 4 diminished chords.""" - graph_obj = subsequence.chord_graphs.diminished.Diminished() - graph, tonic = graph_obj.build("C") - - visited = set() - queue = [tonic] - - while queue: - current = queue.pop(0) - - if current in visited: - continue - - visited.add(current) - - for target, _ in graph.get_transitions(current): - - if target not in visited: - queue.append(target) - - dim_count = sum(1 for c in visited if c.quality == "diminished") - - assert dim_count == 4 +class TestDiminished: + def test_diminished_count(self) -> None: + """The graph should contain 4 diminished chords.""" - def test_dominant_count (self) -> None: + graph_obj = subsequence.chord_graphs.diminished.Diminished() + graph, tonic = graph_obj.build("C") - """The graph should contain 4 dominant 7th chords.""" + visited = set() + queue = [tonic] - graph_obj = subsequence.chord_graphs.diminished.Diminished() - graph, tonic = graph_obj.build("C") + while queue: + current = queue.pop(0) - visited = set() - queue = [tonic] + if current in visited: + continue - while queue: - current = queue.pop(0) + visited.add(current) - if current in visited: - continue + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) - visited.add(current) + dim_count = sum(1 for c in visited if c.quality == "diminished") - for target, _ in graph.get_transitions(current): + assert dim_count == 4 - if target not in visited: - queue.append(target) + def test_dominant_count(self) -> None: + """The graph should contain 4 dominant 7th chords.""" - dom_count = sum(1 for c in visited if c.quality == "dominant_7th") + graph_obj = subsequence.chord_graphs.diminished.Diminished() + graph, tonic = graph_obj.build("C") - assert dom_count == 4 + visited = set() + queue = [tonic] - def test_symmetry (self) -> None: + while queue: + current = queue.pop(0) - """Diminished chord roots should be 3 semitones apart (minor third symmetry).""" + if current in visited: + continue - graph_obj = subsequence.chord_graphs.diminished.Diminished() - _, tonic = graph_obj.build("C") + visited.add(current) - # In key of C, diminished roots should be 0, 3, 6, 9. - expected_roots = {0, 3, 6, 9} + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) - graph, _ = graph_obj.build("C") + dom_count = sum(1 for c in visited if c.quality == "dominant_7th") - visited = set() - queue = [tonic] + assert dom_count == 4 - while queue: - current = queue.pop(0) + def test_symmetry(self) -> None: + """Diminished chord roots should be 3 semitones apart (minor third symmetry).""" - if current in visited: - continue + graph_obj = subsequence.chord_graphs.diminished.Diminished() + _, tonic = graph_obj.build("C") - visited.add(current) + # In key of C, diminished roots should be 0, 3, 6, 9. + expected_roots = {0, 3, 6, 9} - for target, _ in graph.get_transitions(current): + graph, _ = graph_obj.build("C") - if target not in visited: - queue.append(target) + visited = set() + queue = [tonic] - dim_roots = {c.root_pc for c in visited if c.quality == "diminished"} + while queue: + current = queue.pop(0) - assert dim_roots == expected_roots + if current in visited: + continue - def test_no_dead_ends (self) -> None: + visited.add(current) - """Every chord reachable in the graph should have outgoing transitions.""" + for target, _ in graph.get_transitions(current): + if target not in visited: + queue.append(target) - graph_obj = subsequence.chord_graphs.diminished.Diminished() - _assert_no_dead_ends(graph_obj, "C") + dim_roots = {c.root_pc for c in visited if c.quality == "diminished"} - def test_gravity_sets (self) -> None: + assert dim_roots == expected_roots - """Diatonic should have 8 chords. Functional should be the 4 diminished chords.""" + def test_no_dead_ends(self) -> None: + """Every chord reachable in the graph should have outgoing transitions.""" - graph_obj = subsequence.chord_graphs.diminished.Diminished() - diatonic, functional = graph_obj.gravity_sets("C") + graph_obj = subsequence.chord_graphs.diminished.Diminished() + _assert_no_dead_ends(graph_obj, "C") - assert len(diatonic) == 8 - assert len(functional) == 4 + def test_gravity_sets(self) -> None: + """Diatonic should have 8 chords. Functional should be the 4 diminished chords.""" - # All functional chords should be diminished. - for chord in functional: - assert chord.quality == "diminished" + graph_obj = subsequence.chord_graphs.diminished.Diminished() + diatonic, functional = graph_obj.gravity_sets("C") - def test_string_name (self) -> None: + assert len(diatonic) == 8 + assert len(functional) == 4 - """HarmonicState should accept 'diminished' as a style string.""" + # All functional chords should be diminished. + for chord in functional: + assert chord.quality == "diminished" - state = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="diminished") + def test_string_name(self) -> None: + """HarmonicState should accept 'diminished' as a style string.""" - assert state.current_chord.quality == "diminished" + state = subsequence.harmonic_state.HarmonicState( + key_name="C", graph_style="diminished" + ) - def test_stepping_stable (self) -> None: + assert state.current_chord.quality == "diminished" - """Stepping 100 times should not raise any errors.""" + def test_stepping_stable(self) -> None: + """Stepping 100 times should not raise any errors.""" - _assert_stepping_stable("diminished", steps=100) + _assert_stepping_stable("diminished", steps=100) diff --git a/tests/test_osc.py b/tests/test_osc.py index 8a6b876..090e5d5 100644 --- a/tests/test_osc.py +++ b/tests/test_osc.py @@ -10,148 +10,146 @@ import subsequence.osc -async def _wait_for (condition: typing.Callable[[], bool], deadline: float = 2.0, poll: float = 0.01) -> None: +async def _wait_for( + condition: typing.Callable[[], bool], deadline: float = 2.0, poll: float = 0.01 +) -> None: + """Poll until `condition()` is true or the deadline passes; avoids flat sleeps that flake under load.""" - """Poll until `condition()` is true or the deadline passes; avoids flat sleeps that flake under load.""" + loop = asyncio.get_running_loop() + end = loop.time() + deadline - loop = asyncio.get_running_loop() - end = loop.time() + deadline + while not condition(): + if loop.time() >= end: + return - while not condition(): - if loop.time() >= end: - return - - await asyncio.sleep(poll) + await asyncio.sleep(poll) @pytest.fixture -def composition (patch_midi: None) -> subsequence.Composition: - - """Create a composition for testing.""" +def composition(patch_midi: None) -> subsequence.Composition: + """Create a composition for testing.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - return comp + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + return comp @pytest.mark.asyncio -async def test_osc_bpm_handler (composition: subsequence.Composition) -> None: +async def test_osc_bpm_handler(composition: subsequence.Composition) -> None: + """Sending /bpm should update composition tempo.""" - """Sending /bpm should update composition tempo.""" + server = subsequence.osc.OscServer(composition, receive_port=0, send_port=0) + await server.start() - server = subsequence.osc.OscServer(composition, receive_port=0, send_port=0) - await server.start() - - # Get the actual port if 0 was used (OscServer needs to expose it or we check the transport) - port = server._transport.get_extra_info("sockname")[1] + # Get the actual port if 0 was used (OscServer needs to expose it or we check the transport) + port = server._transport.get_extra_info("sockname")[1] - # Send OSC message - client = pythonosc.udp_client.SimpleUDPClient("127.0.0.1", port) - client.send_message("/bpm", 145) + # Send OSC message + client = pythonosc.udp_client.SimpleUDPClient("127.0.0.1", port) + client.send_message("/bpm", 145) - # Poll until the handler has run (UDP delivery is async). - await _wait_for(lambda: composition.bpm == 145) + # Poll until the handler has run (UDP delivery is async). + await _wait_for(lambda: composition.bpm == 145) - assert composition.bpm == 145 - assert composition._sequencer.current_bpm == 145 + assert composition.bpm == 145 + assert composition._sequencer.current_bpm == 145 - await server.stop() + await server.stop() @pytest.mark.asyncio -async def test_osc_mute_handler (composition: subsequence.Composition) -> None: +async def test_osc_mute_handler(composition: subsequence.Composition) -> None: + """Sending /mute/ should mute the pattern.""" - """Sending /mute/ should mute the pattern.""" + def drums(p: "subsequence.pattern_builder.PatternBuilder") -> None: + pass - def drums (p: "subsequence.pattern_builder.PatternBuilder") -> None: - pass + composition.pattern(channel=9)(drums) - composition.pattern(channel=9)(drums) - - # Simulate _run's distribution of running patterns - pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) - composition._running_patterns["drums"] = pattern + # Simulate _run's distribution of running patterns + pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) + composition._running_patterns["drums"] = pattern - server = subsequence.osc.OscServer(composition, receive_port=0, send_port=0) - await server.start() - port = server._transport.get_extra_info("sockname")[1] + server = subsequence.osc.OscServer(composition, receive_port=0, send_port=0) + await server.start() + port = server._transport.get_extra_info("sockname")[1] - client = pythonosc.udp_client.SimpleUDPClient("127.0.0.1", port) - client.send_message("/mute/drums", []) + client = pythonosc.udp_client.SimpleUDPClient("127.0.0.1", port) + client.send_message("/mute/drums", []) - await _wait_for(lambda: pattern._muted is True) + await _wait_for(lambda: pattern._muted is True) - assert pattern._muted is True + assert pattern._muted is True - client.send_message("/unmute/drums", []) - await _wait_for(lambda: pattern._muted is False) - assert pattern._muted is False + client.send_message("/unmute/drums", []) + await _wait_for(lambda: pattern._muted is False) + assert pattern._muted is False - await server.stop() + await server.stop() @pytest.mark.asyncio -async def test_osc_data_handler (composition: subsequence.Composition) -> None: +async def test_osc_data_handler(composition: subsequence.Composition) -> None: + """Sending /data/ should update composition.data.""" - """Sending /data/ should update composition.data.""" + server = subsequence.osc.OscServer(composition, receive_port=0, send_port=0) + await server.start() + port = server._transport.get_extra_info("sockname")[1] - server = subsequence.osc.OscServer(composition, receive_port=0, send_port=0) - await server.start() - port = server._transport.get_extra_info("sockname")[1] + client = pythonosc.udp_client.SimpleUDPClient("127.0.0.1", port) + client.send_message("/data/velocity", 0.75) - client = pythonosc.udp_client.SimpleUDPClient("127.0.0.1", port) - client.send_message("/data/velocity", 0.75) + await _wait_for(lambda: composition.data.get("velocity") == 0.75) - await _wait_for(lambda: composition.data.get("velocity") == 0.75) + assert composition.data["velocity"] == 0.75 - assert composition.data["velocity"] == 0.75 - - await server.stop() + await server.stop() @pytest.mark.asyncio -async def test_osc_status_broadcasting (composition: subsequence.Composition) -> None: - - """The composition should broadcast status via OSC on each bar.""" - - # Setup a receiver server - - received_messages = [] - - def handle_status (address: str, *args: typing.Any) -> None: - received_messages.append((address, args)) - - dispatcher = pythonosc.dispatcher.Dispatcher() - dispatcher.map("/bar", handle_status) - dispatcher.map("/bpm", handle_status) - - loop = asyncio.get_running_loop() - recv_server = pythonosc.osc_server.AsyncIOOSCUDPServer(("127.0.0.1", 0), dispatcher, loop) - transport, _ = await recv_server.create_serve_endpoint() - recv_port = transport.get_extra_info("sockname")[1] - - # Configure composition OSC to send to our receiver - composition.osc(receive_port=0, send_port=recv_port) - - # We need to manually wire up the broadcast since we aren't calling - # composition.play(); register the PRODUCTION method that _run() uses - # so this test exercises real code, not a copy of it. - await composition._osc_server.start() - - composition.on_event("bar", composition._broadcast_osc_status) - - # Emit bar event - composition._sequencer.events.emit_sync("bar", 42) - - await _wait_for(lambda: {"/bar", "/bpm"} <= {m[0] for m in received_messages}) - - # Check if we received anything - addresses = [m[0] for m in received_messages] - assert "/bar" in addresses - assert "/bpm" in addresses - - # Find bar value - bar_val = next(m[1][0] for m in received_messages if m[0] == "/bar") - assert bar_val == 42 - - await composition._osc_server.stop() - transport.close() +async def test_osc_status_broadcasting(composition: subsequence.Composition) -> None: + """The composition should broadcast status via OSC on each bar.""" + + # Setup a receiver server + + received_messages = [] + + def handle_status(address: str, *args: typing.Any) -> None: + received_messages.append((address, args)) + + dispatcher = pythonosc.dispatcher.Dispatcher() + dispatcher.map("/bar", handle_status) + dispatcher.map("/bpm", handle_status) + + loop = asyncio.get_running_loop() + recv_server = pythonosc.osc_server.AsyncIOOSCUDPServer( + ("127.0.0.1", 0), dispatcher, loop + ) + transport, _ = await recv_server.create_serve_endpoint() + recv_port = transport.get_extra_info("sockname")[1] + + # Configure composition OSC to send to our receiver + composition.osc(receive_port=0, send_port=recv_port) + + # We need to manually wire up the broadcast since we aren't calling + # composition.play(); register the PRODUCTION method that _run() uses + # so this test exercises real code, not a copy of it. + await composition._osc_server.start() + + composition.on_event("bar", composition._broadcast_osc_status) + + # Emit bar event + composition._sequencer.events.emit_sync("bar", 42) + + await _wait_for(lambda: {"/bar", "/bpm"} <= {m[0] for m in received_messages}) + + # Check if we received anything + addresses = [m[0] for m in received_messages] + assert "/bar" in addresses + assert "/bpm" in addresses + + # Find bar value + bar_val = next(m[1][0] for m in received_messages if m[0] == "/bar") + assert bar_val == 42 + + await composition._osc_server.stop() + transport.close() diff --git a/tests/test_pattern_algorithmic.py b/tests/test_pattern_algorithmic.py index 1fcdcd2..314ed20 100644 --- a/tests/test_pattern_algorithmic.py +++ b/tests/test_pattern_algorithmic.py @@ -11,44 +11,46 @@ import subsequence.pattern_builder -def _make_builder ( - channel: int = 0, - length: float = 4, - cycle: int = 0, - data: typing.Optional[dict] = None, -) -> typing.Tuple[subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder]: - - """Create a Pattern and PatternBuilder pair for testing.""" - - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - pattern = subsequence.pattern.Pattern(channel=channel, length=length) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=cycle, - default_grid=default_grid, - data=data if data is not None else {}, - ) - return pattern, builder - - -def _drum_builder ( - drum_note_map: dict, - length: float = 4, - cycle: int = 0, -) -> typing.Tuple[subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder]: - - """A Pattern/PatternBuilder pair carrying a ``drum_note_map`` (for name tests).""" - - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - pattern = subsequence.pattern.Pattern(channel=0, length=length) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=cycle, - default_grid=default_grid, - drum_note_map=drum_note_map, - data={}, - ) - return pattern, builder +def _make_builder( + channel: int = 0, + length: float = 4, + cycle: int = 0, + data: typing.Optional[dict] = None, +) -> typing.Tuple[ + subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder +]: + """Create a Pattern and PatternBuilder pair for testing.""" + + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pattern = subsequence.pattern.Pattern(channel=channel, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=cycle, + default_grid=default_grid, + data=data if data is not None else {}, + ) + return pattern, builder + + +def _drum_builder( + drum_note_map: dict, + length: float = 4, + cycle: int = 0, +) -> typing.Tuple[ + subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder +]: + """A Pattern/PatternBuilder pair carrying a ``drum_note_map`` (for name tests).""" + + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pattern = subsequence.pattern.Pattern(channel=0, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=cycle, + default_grid=default_grid, + drum_note_map=drum_note_map, + data={}, + ) + return pattern, builder # ── Unified lenient drum-name resolution (thin / ratchet / evolve / branch) ── @@ -56,285 +58,304 @@ def _drum_builder ( # A voice the device's map lacks is dropped (warned once), never raised — the # same rule the step-note methods use. A name with no map at all still raises. -def test_thin_unknown_pitch_is_noop (caplog: pytest.LogCaptureFixture) -> None: - """thin() targeting a voice the map lacks is a no-op, not an error.""" +def test_thin_unknown_pitch_is_noop(caplog: pytest.LogCaptureFixture) -> None: + """thin() targeting a voice the map lacks is a no-op, not an error.""" - import logging + import logging - pattern, builder = _drum_builder({"kick": 36}) - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - before = sum(len(s.notes) for s in pattern.steps.values()) + pattern, builder = _drum_builder({"kick": 36}) + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + before = sum(len(s.notes) for s in pattern.steps.values()) - with caplog.at_level(logging.WARNING): - builder.thin("cymbal", "uniform", amount=1.0) + with caplog.at_level(logging.WARNING): + builder.thin("cymbal", "uniform", amount=1.0) - after = sum(len(s.notes) for s in pattern.steps.values()) - assert after == before - assert any("cymbal" in r.message for r in caplog.records) + after = sum(len(s.notes) for s in pattern.steps.values()) + assert after == before + assert any("cymbal" in r.message for r in caplog.records) -def test_ratchet_unknown_pitch_is_noop () -> None: +def test_ratchet_unknown_pitch_is_noop() -> None: + """ratchet() targeting a voice the map lacks leaves the pattern unchanged.""" - """ratchet() targeting a voice the map lacks leaves the pattern unchanged.""" + pattern, builder = _drum_builder({"kick": 36}) + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + before = sum(len(s.notes) for s in pattern.steps.values()) - pattern, builder = _drum_builder({"kick": 36}) - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - before = sum(len(s.notes) for s in pattern.steps.values()) + builder.ratchet(4, pitch="cymbal") - builder.ratchet(4, pitch="cymbal") + after = sum(len(s.notes) for s in pattern.steps.values()) + assert after == before - after = sum(len(s.notes) for s in pattern.steps.values()) - assert after == before +def test_evolve_drops_unknown_seed_names() -> None: + """evolve() drops seed names the map lacks; an all-unknown seed is a no-op.""" -def test_evolve_drops_unknown_seed_names () -> None: + pattern, builder = _drum_builder({"kick": 36}) + builder.evolve(["kick", "cymbal"], drift=0.0, spacing=0.5) + pitches = {n.pitch for s in pattern.steps.values() for n in s.notes} + assert pitches == {36} - """evolve() drops seed names the map lacks; an all-unknown seed is a no-op.""" + pattern2, builder2 = _drum_builder({"kick": 36}) + builder2.evolve(["cymbal", "triangle"], spacing=0.5) + assert pattern2.steps == {} - pattern, builder = _drum_builder({"kick": 36}) - builder.evolve(["kick", "cymbal"], drift=0.0, spacing=0.5) - pitches = {n.pitch for s in pattern.steps.values() for n in s.notes} - assert pitches == {36} - pattern2, builder2 = _drum_builder({"kick": 36}) - builder2.evolve(["cymbal", "triangle"], spacing=0.5) - assert pattern2.steps == {} +def test_branch_drops_unknown_seed_names() -> None: + """branch() drops seed names the map lacks; an all-unknown seed is a no-op.""" + pattern, builder = _drum_builder({"kick": 36, "snare": 38}) + builder.branch(["kick", "snare"], depth=1, path=0, spacing=0.5) + assert sum(len(s.notes) for s in pattern.steps.values()) > 0 -def test_branch_drops_unknown_seed_names () -> None: - - """branch() drops seed names the map lacks; an all-unknown seed is a no-op.""" - - pattern, builder = _drum_builder({"kick": 36, "snare": 38}) - builder.branch(["kick", "snare"], depth=1, path=0, spacing=0.5) - assert sum(len(s.notes) for s in pattern.steps.values()) > 0 - - pattern2, builder2 = _drum_builder({"kick": 36}) - builder2.branch(["cymbal"], spacing=0.5) - assert pattern2.steps == {} + pattern2, builder2 = _drum_builder({"kick": 36}) + builder2.branch(["cymbal"], spacing=0.5) + assert pattern2.steps == {} # --------------------------------------------------------------------------- # evolve() # --------------------------------------------------------------------------- -def test_evolve_drift_zero_locks_loop () -> None: - """drift=0.0 must produce identical pitch output on every cycle.""" - seed = [60, 62, 64, 67] - shared_data: dict = {} - pitches_by_cycle = [] - for cycle in range(5): - pattern, builder = _make_builder(cycle=cycle, data=shared_data) - builder.evolve(seed, drift=0.0, spacing=0.25) - pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] - pitches_by_cycle.append(pitches) +def test_evolve_drift_zero_locks_loop() -> None: + """drift=0.0 must produce identical pitch output on every cycle.""" + seed = [60, 62, 64, 67] + shared_data: dict = {} - # All cycles must produce the same pitches. - for later in pitches_by_cycle[1:]: - assert later == pitches_by_cycle[0], "drift=0.0 should never change pitches" + pitches_by_cycle = [] + for cycle in range(5): + pattern, builder = _make_builder(cycle=cycle, data=shared_data) + builder.evolve(seed, drift=0.0, spacing=0.25) + pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] + pitches_by_cycle.append(pitches) + # All cycles must produce the same pitches. + for later in pitches_by_cycle[1:]: + assert later == pitches_by_cycle[0], "drift=0.0 should never change pitches" -def test_evolve_drift_zero_seed_matches () -> None: - """On cycle 0 with drift=0 the output matches the seed exactly.""" - seed = [60, 62, 64, 67] - pattern, builder = _make_builder(cycle=0) - builder.evolve(seed, drift=0.0, spacing=0.25) - pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] - assert pitches == seed +def test_evolve_drift_zero_seed_matches() -> None: + """On cycle 0 with drift=0 the output matches the seed exactly.""" + seed = [60, 62, 64, 67] + pattern, builder = _make_builder(cycle=0) + builder.evolve(seed, drift=0.0, spacing=0.25) + pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] + assert pitches == seed -def test_evolve_steps_truncates_seed () -> None: - """steps=2 should produce exactly 2 notes from a longer seed.""" - seed = [60, 62, 64, 67] - pattern, builder = _make_builder(cycle=0) - builder.evolve(seed, length=2, drift=0.0, spacing=0.25) - count = sum(len(step.notes) for step in pattern.steps.values()) - assert count == 2 +def test_evolve_steps_truncates_seed() -> None: + """steps=2 should produce exactly 2 notes from a longer seed.""" + seed = [60, 62, 64, 67] + pattern, builder = _make_builder(cycle=0) + builder.evolve(seed, length=2, drift=0.0, spacing=0.25) + count = sum(len(step.notes) for step in pattern.steps.values()) + assert count == 2 -def test_evolve_steps_extends_seed () -> None: - """steps=6 with a 4-note seed should cycle and produce 6 notes.""" - seed = [60, 62, 64, 67] - pattern, builder = _make_builder(length=8, cycle=0) - builder.evolve(seed, length=6, drift=0.0, spacing=0.5) - count = sum(len(step.notes) for step in pattern.steps.values()) - assert count == 6 +def test_evolve_steps_extends_seed() -> None: + """steps=6 with a 4-note seed should cycle and produce 6 notes.""" + seed = [60, 62, 64, 67] + pattern, builder = _make_builder(length=8, cycle=0) + builder.evolve(seed, length=6, drift=0.0, spacing=0.5) - # The first 4 notes repeat as per cycling. - pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] - assert pitches[:4] == seed - assert pitches[4:] == seed[:2] + count = sum(len(step.notes) for step in pattern.steps.values()) + assert count == 6 + # The first 4 notes repeat as per cycling. + pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] + assert pitches[:4] == seed + assert pitches[4:] == seed[:2] -def test_evolve_drift_one_replaces_all () -> None: - """drift=1.0 must replace every note on cycle >= 1 (statistically certain).""" - seed = [60, 62, 64, 67] - shared_data: dict = {} - # Cycle 0 — establish seed in data. - _, builder0 = _make_builder(cycle=0, data=shared_data) - builder0.evolve(seed, drift=1.0, spacing=0.25) +def test_evolve_drift_one_replaces_all() -> None: + """drift=1.0 must replace every note on cycle >= 1 (statistically certain).""" + seed = [60, 62, 64, 67] + shared_data: dict = {} - # Cycle 1 — all steps replaced, but still drawn from pool. - pattern1, builder1 = _make_builder(cycle=1, data=shared_data) - builder1.evolve(seed, drift=1.0, spacing=0.25) - pitches1 = [n.pitch for step in pattern1.steps.values() for n in step.notes] + # Cycle 0 — establish seed in data. + _, builder0 = _make_builder(cycle=0, data=shared_data) + builder0.evolve(seed, drift=1.0, spacing=0.25) - # All pitches must be valid members of the seed pool. - for p in pitches1: - assert p in seed, f"pitch {p} not in seed pool" + # Cycle 1 — all steps replaced, but still drawn from pool. + pattern1, builder1 = _make_builder(cycle=1, data=shared_data) + builder1.evolve(seed, drift=1.0, spacing=0.25) + pitches1 = [n.pitch for step in pattern1.steps.values() for n in step.notes] + # All pitches must be valid members of the seed pool. + for p in pitches1: + assert p in seed, f"pitch {p} not in seed pool" -def test_evolve_deterministic_with_fixed_rng () -> None: - """Same seed + same rng seed must produce identical evolution path.""" - seed = [60, 62, 64, 67] - import random - results = [] - for _ in range(2): - shared_data: dict = {} - all_pitches = [] - for cycle in range(4): - pattern, builder = _make_builder(cycle=cycle, data=shared_data) - builder.rng = random.Random(42) - builder.evolve(seed, drift=0.3, spacing=0.25) - pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] - all_pitches.append(pitches) - results.append(all_pitches) +def test_evolve_deterministic_with_fixed_rng() -> None: + """Same seed + same rng seed must produce identical evolution path.""" + seed = [60, 62, 64, 67] + import random - assert results[0] == results[1], "evolve() must be deterministic given the same rng seed" + results = [] + for _ in range(2): + shared_data: dict = {} + all_pitches = [] + for cycle in range(4): + pattern, builder = _make_builder(cycle=cycle, data=shared_data) + builder.rng = random.Random(42) + builder.evolve(seed, drift=0.3, spacing=0.25) + pitches = [ + n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes + ] + all_pitches.append(pitches) + results.append(all_pitches) + assert results[0] == results[1], ( + "evolve() must be deterministic given the same rng seed" + ) -def test_evolve_buffer_stays_in_pool () -> None: - """After many cycles of drift=1.0, all pitches must remain in the seed pool.""" - seed = [60, 62, 64, 67] - shared_data: dict = {} - for cycle in range(10): - pattern, builder = _make_builder(cycle=cycle, data=shared_data) - builder.evolve(seed, drift=1.0, spacing=0.25) +def test_evolve_buffer_stays_in_pool() -> None: + """After many cycles of drift=1.0, all pitches must remain in the seed pool.""" + seed = [60, 62, 64, 67] + shared_data: dict = {} - pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] - for p in pitches: - assert p in seed + for cycle in range(10): + pattern, builder = _make_builder(cycle=cycle, data=shared_data) + builder.evolve(seed, drift=1.0, spacing=0.25) + + pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] + for p in pitches: + assert p in seed # --------------------------------------------------------------------------- # branch() # --------------------------------------------------------------------------- -def test_branch_depth_zero_plays_seed () -> None: - """depth=0 must play the seed unchanged (no transforms applied).""" - seed = [60, 64, 67, 72] - pattern, builder = _make_builder(cycle=0) - builder.branch(seed, depth=0, path=0, mutation=0.0, spacing=0.5) - - pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] - assert pitches == seed - - -def test_branch_path_zero_and_one_differ () -> None: - """path=0 and path=1 at depth=1 must produce different sequences.""" - seed = [60, 64, 67, 72] - - pattern0, builder0 = _make_builder(cycle=0) - builder0.branch(seed, depth=1, path=0, mutation=0.0, spacing=0.5) - pitches0 = [n.pitch for step in sorted(pattern0.steps.items()) for n in step[1].notes] - - pattern1, builder1 = _make_builder(cycle=0) - builder1.branch(seed, depth=1, path=1, mutation=0.0, spacing=0.5) - pitches1 = [n.pitch for step in sorted(pattern1.steps.items()) for n in step[1].notes] - assert pitches0 != pitches1, "path=0 and path=1 should produce different variations" +def test_branch_depth_zero_plays_seed() -> None: + """depth=0 must play the seed unchanged (no transforms applied).""" + seed = [60, 64, 67, 72] + pattern, builder = _make_builder(cycle=0) + builder.branch(seed, depth=0, path=0, mutation=0.0, spacing=0.5) + pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] + assert pitches == seed -def test_branch_deterministic () -> None: - """Same seed + depth + path must always produce the same output.""" - seed = [60, 64, 67, 72] - def _get_pitches (path: int) -> typing.List[int]: - pattern, builder = _make_builder(cycle=0) - builder.branch(seed, depth=3, path=path, mutation=0.0, spacing=0.5) - return [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] +def test_branch_path_zero_and_one_differ() -> None: + """path=0 and path=1 at depth=1 must produce different sequences.""" + seed = [60, 64, 67, 72] + + pattern0, builder0 = _make_builder(cycle=0) + builder0.branch(seed, depth=1, path=0, mutation=0.0, spacing=0.5) + pitches0 = [ + n.pitch for step in sorted(pattern0.steps.items()) for n in step[1].notes + ] + + pattern1, builder1 = _make_builder(cycle=0) + builder1.branch(seed, depth=1, path=1, mutation=0.0, spacing=0.5) + pitches1 = [ + n.pitch for step in sorted(pattern1.steps.items()) for n in step[1].notes + ] + + assert pitches0 != pitches1, "path=0 and path=1 should produce different variations" - # Call twice with same path — must produce identical result. - assert _get_pitches(2) == _get_pitches(2) - assert _get_pitches(5) == _get_pitches(5) +def test_branch_deterministic() -> None: + """Same seed + depth + path must always produce the same output.""" + seed = [60, 64, 67, 72] -def test_branch_path_wraps () -> None: - """path=0 and path=2**depth should produce the same result (wrapping).""" - seed = [60, 64, 67, 72] - depth = 3 - num_variations = 2 ** depth + def _get_pitches(path: int) -> typing.List[int]: + pattern, builder = _make_builder(cycle=0) + builder.branch(seed, depth=3, path=path, mutation=0.0, spacing=0.5) + return [ + n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes + ] - def _get_pitches (path: int) -> typing.List[int]: - pattern, builder = _make_builder(cycle=0) - builder.branch(seed, depth=depth, path=path, mutation=0.0, spacing=0.5) - return [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] + # Call twice with same path — must produce identical result. + assert _get_pitches(2) == _get_pitches(2) + assert _get_pitches(5) == _get_pitches(5) - assert _get_pitches(0) == _get_pitches(num_variations) - assert _get_pitches(1) == _get_pitches(num_variations + 1) +def test_branch_path_wraps() -> None: + """path=0 and path=2**depth should produce the same result (wrapping).""" + seed = [60, 64, 67, 72] + depth = 3 + num_variations = 2**depth -def test_branch_note_count_matches_seed () -> None: - """Output should have the same number of notes as the seed.""" - seed = [60, 64, 67, 72] - for depth in range(4): - for path in range(2 ** depth): - pattern, builder = _make_builder(cycle=0) - builder.branch(seed, depth=depth, path=path, mutation=0.0, spacing=0.5) - count = sum(len(s.notes) for s in pattern.steps.values()) - assert count == len(seed), f"depth={depth}, path={path}: expected {len(seed)} notes, got {count}" + def _get_pitches(path: int) -> typing.List[int]: + pattern, builder = _make_builder(cycle=0) + builder.branch(seed, depth=depth, path=path, mutation=0.0, spacing=0.5) + return [ + n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes + ] + assert _get_pitches(0) == _get_pitches(num_variations) + assert _get_pitches(1) == _get_pitches(num_variations + 1) -def test_branch_mutation_zero_is_deterministic () -> None: - """mutation=0.0 must produce purely deterministic output (no rng involvement).""" - seed = [60, 64, 67, 72] - import random - results = [] - for rng_seed in [1, 99, 12345]: - pattern, builder = _make_builder(cycle=0) - builder.rng = random.Random(rng_seed) - builder.branch(seed, depth=2, path=3, mutation=0.0, spacing=0.5) - pitches = [n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes] - results.append(pitches) +def test_branch_note_count_matches_seed() -> None: + """Output should have the same number of notes as the seed.""" + seed = [60, 64, 67, 72] + for depth in range(4): + for path in range(2**depth): + pattern, builder = _make_builder(cycle=0) + builder.branch(seed, depth=depth, path=path, mutation=0.0, spacing=0.5) + count = sum(len(s.notes) for s in pattern.steps.values()) + assert count == len(seed), ( + f"depth={depth}, path={path}: expected {len(seed)} notes, got {count}" + ) - # All three rng seeds should produce identical output since mutation=0. - assert results[0] == results[1] == results[2] +def test_branch_mutation_zero_is_deterministic() -> None: + """mutation=0.0 must produce purely deterministic output (no rng involvement).""" + seed = [60, 64, 67, 72] + import random -def test_branch_mutation_one_draws_from_seed_pool () -> None: - """mutation=1.0 must still draw only from the seed pool.""" - seed = [60, 64, 67, 72] - pattern, builder = _make_builder(cycle=0) - builder.branch(seed, depth=2, path=0, mutation=1.0, spacing=0.5) + results = [] + for rng_seed in [1, 99, 12345]: + pattern, builder = _make_builder(cycle=0) + builder.rng = random.Random(rng_seed) + builder.branch(seed, depth=2, path=3, mutation=0.0, spacing=0.5) + pitches = [ + n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes + ] + results.append(pitches) - pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] - for p in pitches: - assert p in seed, f"pitch {p} not in seed pool" + # All three rng seeds should produce identical output since mutation=0. + assert results[0] == results[1] == results[2] -def test_branch_cycle_path_advances () -> None: - """Using path=cycle should step through unique variations.""" - seed = [60, 64, 67, 72] - depth = 3 - variations = set() - - for cycle in range(2 ** depth): - pattern, builder = _make_builder(cycle=cycle) - builder.branch(seed, depth=depth, path=cycle, mutation=0.0, spacing=0.5) - pitches = tuple(n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes) - variations.add(pitches) - - # Each path should produce a unique sequence. - assert len(variations) == 2 ** depth, f"Expected {2**depth} unique variations, got {len(variations)}" +def test_branch_mutation_one_draws_from_seed_pool() -> None: + """mutation=1.0 must still draw only from the seed pool.""" + seed = [60, 64, 67, 72] + pattern, builder = _make_builder(cycle=0) + builder.branch(seed, depth=2, path=0, mutation=1.0, spacing=0.5) + + pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] + for p in pitches: + assert p in seed, f"pitch {p} not in seed pool" + + +def test_branch_cycle_path_advances() -> None: + """Using path=cycle should step through unique variations.""" + seed = [60, 64, 67, 72] + depth = 3 + variations = set() + + for cycle in range(2**depth): + pattern, builder = _make_builder(cycle=cycle) + builder.branch(seed, depth=depth, path=cycle, mutation=0.0, spacing=0.5) + pitches = tuple( + n.pitch for step in sorted(pattern.steps.items()) for n in step[1].notes + ) + variations.add(pitches) + + # Each path should produce a unique sequence. + assert len(variations) == 2**depth, ( + f"Expected {2**depth} unique variations, got {len(variations)}" + ) # --------------------------------------------------------------------------- @@ -344,196 +365,206 @@ def test_branch_cycle_path_advances () -> None: PPQN = subsequence.constants.MIDI_QUARTER_NOTE # 24 -def test_ratchet_basic_subdivision () -> None: - """A single note with subdivisions=3 becomes exactly 3 evenly-spaced notes.""" - pattern, builder = _make_builder(length=4) - # Place one note at beat 0 with duration 1 beat (24 pulses). - builder.note(60, beat=0, velocity=100, duration=1.0) - builder.ratchet(3) - - notes = [(pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes] - assert len(notes) == 3 - - pulses = [p for p, _ in notes] - slot = PPQN / 3 # 8 pulses per slot - assert pulses[0] == 0 - assert pulses[1] == round(slot) - assert pulses[2] == round(2 * slot) +def test_ratchet_basic_subdivision() -> None: + """A single note with subdivisions=3 becomes exactly 3 evenly-spaced notes.""" + pattern, builder = _make_builder(length=4) + # Place one note at beat 0 with duration 1 beat (24 pulses). + builder.note(60, beat=0, velocity=100, duration=1.0) + builder.ratchet(3) + notes = [ + (pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes + ] + assert len(notes) == 3 -def test_ratchet_velocity_linear_shaping () -> None: - """velocity_start/end with linear shape interpolates evenly across sub-hits.""" - pattern, builder = _make_builder(length=4) - builder.note(60, beat=0, velocity=100, duration=1.0) - builder.ratchet(4, velocity_start=0.5, velocity_end=1.0, shape="linear") + pulses = [p for p, _ in notes] + slot = PPQN / 3 # 8 pulses per slot + assert pulses[0] == 0 + assert pulses[1] == round(slot) + assert pulses[2] == round(2 * slot) - notes = [n for pulse, step in sorted(pattern.steps.items()) for n in step.notes] - assert len(notes) == 4 - velocities = [n.velocity for n in notes] - # t values: 0/3, 1/3, 2/3, 3/3 → multipliers: 0.5, 0.667, 0.833, 1.0 - assert velocities[0] == round(100 * 0.5) - assert velocities[3] == 100 +def test_ratchet_velocity_linear_shaping() -> None: + """velocity_start/end with linear shape interpolates evenly across sub-hits.""" + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0, velocity=100, duration=1.0) + builder.ratchet(4, velocity_start=0.5, velocity_end=1.0, shape="linear") + notes = [n for pulse, step in sorted(pattern.steps.items()) for n in step.notes] + assert len(notes) == 4 -def test_markov_velocity_tuple_is_seed_reproducible () -> None: + velocities = [n.velocity for n in notes] + # t values: 0/3, 1/3, 2/3, 3/3 → multipliers: 0.5, 0.667, 0.833, 1.0 + assert velocities[0] == round(100 * 0.5) + assert velocities[3] == 100 - """markov() with a velocity range reproduces from seed=, independent of self.rng. - Regression: velocity was resolved against the builder's own ``self.rng`` rather - than the ``seed=``/``rng=``-resolved generator, so seeded velocities drifted - while pitches stayed fixed. - """ +def test_markov_velocity_tuple_is_seed_reproducible() -> None: + """markov() with a velocity range reproduces from seed=, independent of self.rng. - transitions = {"a": [("b", 1.0)], "b": [("a", 1.0)]} - pitch_map = {"a": 60, "b": 64} + Regression: velocity was resolved against the builder's own ``self.rng`` rather + than the ``seed=``/``rng=``-resolved generator, so seeded velocities drifted + while pitches stayed fixed. + """ - def run (self_rng_seed: int) -> list: + transitions = {"a": [("b", 1.0)], "b": [("a", 1.0)]} + pitch_map = {"a": 60, "b": 64} - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(self_rng_seed) # distinct per-pattern generator - builder.markov(transitions, pitch_map, velocity=(40, 120), spacing=0.5, start="a", seed=42) + def run(self_rng_seed: int) -> list: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(self_rng_seed) # distinct per-pattern generator + builder.markov( + transitions, pitch_map, velocity=(40, 120), spacing=0.5, start="a", seed=42 + ) - return [(pulse, n.pitch, n.velocity) for pulse, step in sorted(pattern.steps.items()) for n in step.notes] + return [ + (pulse, n.pitch, n.velocity) + for pulse, step in sorted(pattern.steps.items()) + for n in step.notes + ] - # Same seed=42 → identical pitches AND velocities, even though self.rng differs. - result_a = run(1) - result_b = run(99) + # Same seed=42 → identical pitches AND velocities, even though self.rng differs. + result_a = run(1) + result_b = run(99) - assert result_a == result_b - assert len(result_a) == 8 + assert result_a == result_b + assert len(result_a) == 8 -def test_ratchet_pitch_filter_leaves_other_notes_unchanged () -> None: - """With pitch filter, non-matching notes are untouched.""" - drum_map = {"kick": 36, "hh": 42} - pattern, builder = _make_builder(length=4) - builder._drum_note_map = drum_map +def test_ratchet_pitch_filter_leaves_other_notes_unchanged() -> None: + """With pitch filter, non-matching notes are untouched.""" + drum_map = {"kick": 36, "hh": 42} + pattern, builder = _make_builder(length=4) + builder._drum_note_map = drum_map - # Place kick at beat 0, hh at beat 1 — both 1-beat duration. - builder.note(36, beat=0, velocity=100, duration=1.0) - builder.note(42, beat=1, velocity=80, duration=1.0) + # Place kick at beat 0, hh at beat 1 — both 1-beat duration. + builder.note(36, beat=0, velocity=100, duration=1.0) + builder.note(42, beat=1, velocity=80, duration=1.0) - builder.ratchet(3, pitch=42) + builder.ratchet(3, pitch=42) - all_notes = [(pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes] - kick_notes = [(p, n) for p, n in all_notes if n.pitch == 36] - hh_notes = [(p, n) for p, n in all_notes if n.pitch == 42] + all_notes = [ + (pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes + ] + kick_notes = [(p, n) for p, n in all_notes if n.pitch == 36] + hh_notes = [(p, n) for p, n in all_notes if n.pitch == 42] - # Kick: unchanged — still 1 note at pulse 0. - assert len(kick_notes) == 1 - assert kick_notes[0][0] == 0 - assert kick_notes[0][1].velocity == 100 + # Kick: unchanged — still 1 note at pulse 0. + assert len(kick_notes) == 1 + assert kick_notes[0][0] == 0 + assert kick_notes[0][1].velocity == 100 - # HH: subdivided into 3. - assert len(hh_notes) == 3 + # HH: subdivided into 3. + assert len(hh_notes) == 3 -def test_ratchet_probability_zero_leaves_all_unchanged () -> None: - """probability=0.0 — no note is ratcheted.""" - pattern, builder = _make_builder(length=4) - builder.note(60, beat=0, velocity=100, duration=1.0) - builder.note(60, beat=1, velocity=100, duration=1.0) - builder.ratchet(4, probability=0.0) +def test_ratchet_probability_zero_leaves_all_unchanged() -> None: + """probability=0.0 — no note is ratcheted.""" + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0, velocity=100, duration=1.0) + builder.note(60, beat=1, velocity=100, duration=1.0) + builder.ratchet(4, probability=0.0) + + notes = [n for pulse, step in pattern.steps.items() for n in step.notes] + assert len(notes) == 2 - notes = [n for pulse, step in pattern.steps.items() for n in step.notes] - assert len(notes) == 2 +def test_ratchet_probability_one_ratchets_all() -> None: + """probability=1.0 — every note is ratcheted.""" + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0, velocity=100, duration=1.0) + builder.note(60, beat=1, velocity=100, duration=1.0) + builder.ratchet(2, probability=1.0) -def test_ratchet_probability_one_ratchets_all () -> None: - """probability=1.0 — every note is ratcheted.""" - pattern, builder = _make_builder(length=4) - builder.note(60, beat=0, velocity=100, duration=1.0) - builder.note(60, beat=1, velocity=100, duration=1.0) - builder.ratchet(2, probability=1.0) + notes = [n for pulse, step in pattern.steps.items() for n in step.notes] + assert len(notes) == 4 - notes = [n for pulse, step in pattern.steps.items() for n in step.notes] - assert len(notes) == 4 +def test_ratchet_gate_controls_duration() -> None: + """gate parameter sets sub-note duration as fraction of subdivision slot.""" + pattern, builder = _make_builder(length=4) + # 1-beat note = 24 pulses, ratchet(2) → slot = 12 pulses + builder.note(60, beat=0, velocity=100, duration=1.0) + builder.ratchet(2, gate=1.0) -def test_ratchet_gate_controls_duration () -> None: - """gate parameter sets sub-note duration as fraction of subdivision slot.""" - pattern, builder = _make_builder(length=4) - # 1-beat note = 24 pulses, ratchet(2) → slot = 12 pulses - builder.note(60, beat=0, velocity=100, duration=1.0) - builder.ratchet(2, gate=1.0) + notes = [n for pulse, step in sorted(pattern.steps.items()) for n in step.notes] + # gate=1.0 → duration = max(1, round(12 * 1.0)) = 12 + assert all(n.duration == 12 for n in notes) - notes = [n for pulse, step in sorted(pattern.steps.items()) for n in step.notes] - # gate=1.0 → duration = max(1, round(12 * 1.0)) = 12 - assert all(n.duration == 12 for n in notes) + # Reset and test gate=0.5 + pattern2, builder2 = _make_builder(length=4) + builder2.note(60, beat=0, velocity=100, duration=1.0) + builder2.ratchet(2, gate=0.5) - # Reset and test gate=0.5 - pattern2, builder2 = _make_builder(length=4) - builder2.note(60, beat=0, velocity=100, duration=1.0) - builder2.ratchet(2, gate=0.5) + notes2 = [n for pulse, step in sorted(pattern2.steps.items()) for n in step.notes] + assert all(n.duration == 6 for n in notes2) - notes2 = [n for pulse, step in sorted(pattern2.steps.items()) for n in step.notes] - assert all(n.duration == 6 for n in notes2) +def test_ratchet_short_note_clamping() -> None: + """Subdivisions are clamped to note.duration so sub-hits never stack.""" + pattern, builder = _make_builder(length=4) + # Place a note with duration=2 pulses (very short) — use pattern.add_note directly. + pattern.add_note(0, pitch=60, velocity=100, duration=2) + builder.ratchet(8) -def test_ratchet_short_note_clamping () -> None: - """Subdivisions are clamped to note.duration so sub-hits never stack.""" - pattern, builder = _make_builder(length=4) - # Place a note with duration=2 pulses (very short) — use pattern.add_note directly. - pattern.add_note(0, pitch=60, velocity=100, duration=2) - builder.ratchet(8) + notes = [n for pulse, step in pattern.steps.items() for n in step.notes] + # Clamped to 2 subdivisions (= note.duration). + assert len(notes) == 2 - notes = [n for pulse, step in pattern.steps.items() for n in step.notes] - # Clamped to 2 subdivisions (= note.duration). - assert len(notes) == 2 +def test_ratchet_steps_mask_targets_correct_positions() -> None: + """steps mask only ratchets notes at specified grid zones.""" + pattern, builder = _make_builder(length=4) # default_grid=16 + # Three notes at beat 0, 1, 2 (grid steps 0, 4, 8 in a 16-step bar). + builder.note(60, beat=0, velocity=100, duration=1.0) + builder.note(60, beat=1, velocity=100, duration=1.0) + builder.note(60, beat=2, velocity=100, duration=1.0) -def test_ratchet_steps_mask_targets_correct_positions () -> None: - """steps mask only ratchets notes at specified grid zones.""" - pattern, builder = _make_builder(length=4) # default_grid=16 - # Three notes at beat 0, 1, 2 (grid steps 0, 4, 8 in a 16-step bar). - builder.note(60, beat=0, velocity=100, duration=1.0) - builder.note(60, beat=1, velocity=100, duration=1.0) - builder.note(60, beat=2, velocity=100, duration=1.0) + # Only ratchet grid step 0 (beat 0) and step 8 (beat 2). + builder.ratchet(2, steps=[0, 8]) - # Only ratchet grid step 0 (beat 0) and step 8 (beat 2). - builder.ratchet(2, steps=[0, 8]) + notes = [ + (pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes + ] + # Beat 0 → 2 subdivisions; beat 1 → 1 note unchanged; beat 2 → 2 subdivisions = 5 total. + assert len(notes) == 5 - notes = [(pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes] - # Beat 0 → 2 subdivisions; beat 1 → 1 note unchanged; beat 2 → 2 subdivisions = 5 total. - assert len(notes) == 5 +def test_ratchet_chainable() -> None: + """ratchet() returns self so it can be chained.""" + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0, velocity=100, duration=1.0) + result = builder.ratchet(2).ratchet(1) + assert result is builder -def test_ratchet_chainable () -> None: - """ratchet() returns self so it can be chained.""" - pattern, builder = _make_builder(length=4) - builder.note(60, beat=0, velocity=100, duration=1.0) - result = builder.ratchet(2).ratchet(1) - assert result is builder +def test_ratchet_deterministic_with_seed() -> None: + """Same RNG seed + probability < 1.0 produces identical output across calls.""" + import random -def test_ratchet_deterministic_with_seed () -> None: - """Same RNG seed + probability < 1.0 produces identical output across calls.""" - import random + def run() -> tuple: + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0, velocity=100, duration=1.0) + builder.note(60, beat=1, velocity=100, duration=1.0) + builder.note(60, beat=2, velocity=100, duration=1.0) + builder.ratchet(3, probability=0.5, rng=random.Random(42)) + return tuple( + (pulse, n.velocity, n.duration) + for pulse, step in sorted(pattern.steps.items()) + for n in step.notes + ) - def run () -> tuple: - pattern, builder = _make_builder(length=4) - builder.note(60, beat=0, velocity=100, duration=1.0) - builder.note(60, beat=1, velocity=100, duration=1.0) - builder.note(60, beat=2, velocity=100, duration=1.0) - builder.ratchet(3, probability=0.5, rng=random.Random(42)) - return tuple( - (pulse, n.velocity, n.duration) - for pulse, step in sorted(pattern.steps.items()) - for n in step.notes - ) + assert run() == run() - assert run() == run() +def test_ratchet_velocity_preserved_without_shaping() -> None: + """Default velocity_start=1.0, velocity_end=1.0 keeps original velocity.""" + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0, velocity=80, duration=1.0) + builder.ratchet(4) -def test_ratchet_velocity_preserved_without_shaping () -> None: - """Default velocity_start=1.0, velocity_end=1.0 keeps original velocity.""" - pattern, builder = _make_builder(length=4) - builder.note(60, beat=0, velocity=80, duration=1.0) - builder.ratchet(4) - - notes = [n for pulse, step in pattern.steps.items() for n in step.notes] - assert all(n.velocity == 80 for n in notes) + notes = [n for pulse, step in pattern.steps.items() for n in step.notes] + assert all(n.velocity == 80 for n in notes) # ── Degenerate-input handling (empty pools raise; zero resolution no-ops) ── @@ -545,48 +576,47 @@ def test_ratchet_velocity_preserved_without_shaping () -> None: # / cellular_1d / fibonacci / reaction_diffusion). These previously crashed with # opaque IndexError / ZeroDivisionError. -def test_cellular_2d_empty_pitches_raises () -> None: - - """cellular_2d with an empty pitch pool raises, like its melodic-pool siblings.""" - - pattern, builder = _make_builder() - - with pytest.raises(ValueError, match="pitches list cannot be empty"): - builder.cellular_2d([]) +def test_cellular_2d_empty_pitches_raises() -> None: + """cellular_2d with an empty pitch pool raises, like its melodic-pool siblings.""" -def test_cellular_2d_empty_velocity_list_raises () -> None: + pattern, builder = _make_builder() - """cellular_2d with an empty velocity list raises a clear error (was ZeroDivisionError).""" + with pytest.raises(ValueError, match="pitches list cannot be empty"): + builder.cellular_2d([]) - pattern, builder = _make_builder() - with pytest.raises(ValueError, match="velocity list cannot be empty"): - builder.cellular_2d([60], velocity=[]) +def test_cellular_2d_empty_velocity_list_raises() -> None: + """cellular_2d with an empty velocity list raises a clear error (was ZeroDivisionError).""" + pattern, builder = _make_builder() -def test_ghost_fill_zero_grid_is_noop () -> None: + with pytest.raises(ValueError, match="velocity list cannot be empty"): + builder.cellular_2d([60], velocity=[]) - """ghost_fill with grid=0 places nothing instead of dividing by zero.""" - pattern, builder = _make_builder() +def test_ghost_fill_zero_grid_is_noop() -> None: + """ghost_fill with grid=0 places nothing instead of dividing by zero.""" - assert builder.ghost_fill(60, grid=0) is builder - assert pattern.steps == {} + pattern, builder = _make_builder() + assert builder.ghost_fill(60, grid=0) is builder + assert pattern.steps == {} -def test_thue_morse_zero_resolution_is_noop () -> None: - """thue_morse on a zero-resolution pattern no-ops in both single- and two-pitch modes.""" +def test_thue_morse_zero_resolution_is_noop() -> None: + """thue_morse on a zero-resolution pattern no-ops in both single- and two-pitch modes.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=0, - default_grid=0, - data={}, - ) + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + default_grid=0, + data={}, + ) - assert builder.thue_morse(60) is builder # single-pitch path (already no-op'd) - assert builder.thue_morse(60, pitch_b=62) is builder # two-pitch path (the fixed crash) - assert pattern.steps == {} + assert builder.thue_morse(60) is builder # single-pitch path (already no-op'd) + assert ( + builder.thue_morse(60, pitch_b=62) is builder + ) # two-pitch path (the fixed crash) + assert pattern.steps == {} diff --git a/tests/test_pattern_builder.py b/tests/test_pattern_builder.py index 6910861..d5dda69 100644 --- a/tests/test_pattern_builder.py +++ b/tests/test_pattern_builder.py @@ -12,5706 +12,5536 @@ import subsequence.pattern_builder -def _make_builder (channel: int = 0, length: float = 4, drum_note_map: typing.Optional[dict] = None, default_grid: typing.Optional[int] = None) -> tuple: +def _make_builder( + channel: int = 0, + length: float = 4, + drum_note_map: typing.Optional[dict] = None, + default_grid: typing.Optional[int] = None, +) -> tuple: + """ + Create a Pattern and PatternBuilder pair for testing. - """ - Create a Pattern and PatternBuilder pair for testing. + When *default_grid* is not given it is derived from *length* using + sixteenth-note resolution, matching the composition decorator fallback. + """ - When *default_grid* is not given it is derived from *length* using - sixteenth-note resolution, matching the composition decorator fallback. - """ + if default_grid is None: + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - if default_grid is None: - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pattern = subsequence.pattern.Pattern(channel=channel, length=length) - pattern = subsequence.pattern.Pattern(channel=channel, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, drum_note_map=drum_note_map, default_grid=default_grid + ) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - drum_note_map = drum_note_map, - default_grid = default_grid - ) + return pattern, builder - return pattern, builder +def test_note_places_at_beat() -> None: + """ + A note placed at beat 1.0 should appear at the correct pulse position. + """ -def test_note_places_at_beat () -> None: + pattern, builder = _make_builder() - """ - A note placed at beat 1.0 should appear at the correct pulse position. - """ + builder.note(60, beat=1.0, velocity=100, duration=0.5) - pattern, builder = _make_builder() + expected_pulse = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - builder.note(60, beat=1.0, velocity=100, duration=0.5) + assert expected_pulse in pattern.steps + assert len(pattern.steps[expected_pulse].notes) == 1 + assert pattern.steps[expected_pulse].notes[0].pitch == 60 + assert pattern.steps[expected_pulse].notes[0].velocity == 100 - expected_pulse = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert expected_pulse in pattern.steps - assert len(pattern.steps[expected_pulse].notes) == 1 - assert pattern.steps[expected_pulse].notes[0].pitch == 60 - assert pattern.steps[expected_pulse].notes[0].velocity == 100 +def test_note_negative_beat_wraps() -> None: + """ + A negative beat value should wrap to the end of the pattern. + """ + pattern, builder = _make_builder(length=4) -def test_note_negative_beat_wraps () -> None: + builder.note(60, beat=-0.5, velocity=85) - """ - A negative beat value should wrap to the end of the pattern. - """ + expected_beat = 4 - 0.5 + expected_pulse = int(expected_beat * subsequence.constants.MIDI_QUARTER_NOTE) - pattern, builder = _make_builder(length=4) + assert expected_pulse in pattern.steps - builder.note(60, beat=-0.5, velocity=85) - expected_beat = 4 - 0.5 - expected_pulse = int(expected_beat * subsequence.constants.MIDI_QUARTER_NOTE) +def test_hit_places_multiple_beats() -> None: + """ + Hits placed at beats 1 and 3 should create notes at both positions. + """ - assert expected_pulse in pattern.steps + pattern, builder = _make_builder() + builder.hit(38, beats=[1, 3], velocity=100) -def test_hit_places_multiple_beats () -> None: + pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + pulse_3 = int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE) - """ - Hits placed at beats 1 and 3 should create notes at both positions. - """ + assert pulse_1 in pattern.steps + assert pulse_3 in pattern.steps - pattern, builder = _make_builder() + assert pattern.steps[pulse_1].notes[0].pitch == 38 + assert pattern.steps[pulse_3].notes[0].pitch == 38 - builder.hit(38, beats=[1, 3], velocity=100) - pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - pulse_3 = int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE) +def test_repeat_covers_pattern() -> None: + """ + Fill with spacing=0.25 over a 4-beat pattern should place 16 notes. + """ - assert pulse_1 in pattern.steps - assert pulse_3 in pattern.steps + pattern, builder = _make_builder(length=4) - assert pattern.steps[pulse_1].notes[0].pitch == 38 - assert pattern.steps[pulse_3].notes[0].pitch == 38 + builder.repeat(60, spacing=0.25, velocity=90, duration=0.2) + # 4 beats / 0.25 step = 16 notes + total_notes = sum(len(step.notes) for step in pattern.steps.values()) -def test_repeat_covers_pattern () -> None: + assert total_notes == 16 - """ - Fill with spacing=0.25 over a 4-beat pattern should place 16 notes. - """ - pattern, builder = _make_builder(length=4) +def test_repeat_invalid_step_raises() -> None: + """ + Fill with non-positive step should raise ValueError. + """ - builder.repeat(60, spacing=0.25, velocity=90, duration=0.2) + pattern, builder = _make_builder() - # 4 beats / 0.25 step = 16 notes - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + with pytest.raises(ValueError): + builder.repeat(60, spacing=0) - assert total_notes == 16 + with pytest.raises(ValueError): + builder.repeat(60, spacing=-1) -def test_repeat_invalid_step_raises () -> None: +def test_duck_map_builds_multiplier_list() -> None: + """ + duck_map should return floor at trigger steps and 1.0 elsewhere. + """ - """ - Fill with non-positive step should raise ValueError. - """ + _, builder = _make_builder(default_grid=4) - pattern, builder = _make_builder() + result = builder.duck_map(steps=[0, 2], floor=0.0) - with pytest.raises(ValueError): - builder.repeat(60, spacing=0) + assert result == [0.0, 1.0, 0.0, 1.0] - with pytest.raises(ValueError): - builder.repeat(60, spacing=-1) +def test_duck_map_partial_floor() -> None: + """ + duck_map should write the given floor value, not just 0.0. + """ -def test_duck_map_builds_multiplier_list () -> None: + _, builder = _make_builder(default_grid=4) - """ - duck_map should return floor at trigger steps and 1.0 elsewhere. - """ + result = builder.duck_map(steps=[1], floor=0.5) - _, builder = _make_builder(default_grid=4) + assert result == [1.0, 0.5, 1.0, 1.0] - result = builder.duck_map(steps=[0, 2], floor=0.0) - assert result == [0.0, 1.0, 0.0, 1.0] +def test_scale_velocities_applies_factors() -> None: + """ + scale_velocities should multiply each note's velocity by the per-step factor. + """ + pattern, builder = _make_builder(default_grid=4, length=4) -def test_duck_map_partial_floor () -> None: + builder.sequence(steps=[0, 1, 2, 3], pitches=60, velocities=100, durations=0.1) + builder.scale_velocities([0.0, 0.5, 1.0, 1.0]) - """ - duck_map should write the given floor value, not just 0.0. - """ + pps = subsequence.constants.MIDI_QUARTER_NOTE - _, builder = _make_builder(default_grid=4) + assert pattern.steps[0].notes[0].velocity == 0 + assert pattern.steps[pps].notes[0].velocity == 50 + assert pattern.steps[pps * 2].notes[0].velocity == 100 - result = builder.duck_map(steps=[1], floor=0.5) - assert result == [1.0, 0.5, 1.0, 1.0] +def test_chord_places_all_tones() -> None: + """ + A major chord should place 3 notes at beat 0. + """ + pattern, builder = _make_builder() -def test_scale_velocities_applies_factors () -> None: + chord = subsequence.chords.Chord(root_pc=4, quality="major") - """ - scale_velocities should multiply each note's velocity by the per-step factor. - """ + builder.chord(chord, root=52, velocity=90) - pattern, builder = _make_builder(default_grid=4, length=4) + # Beat 0 = pulse 0 + assert 0 in pattern.steps + assert len(pattern.steps[0].notes) == 3 - builder.sequence(steps=[0, 1, 2, 3], pitches=60, velocities=100, durations=0.1) - builder.scale_velocities([0.0, 0.5, 1.0, 1.0]) + pitches = sorted([n.pitch for n in pattern.steps[0].notes]) - pps = subsequence.constants.MIDI_QUARTER_NOTE + assert pitches == [52, 56, 59] - assert pattern.steps[0].notes[0].velocity == 0 - assert pattern.steps[pps].notes[0].velocity == 50 - assert pattern.steps[pps * 2].notes[0].velocity == 100 +def test_chord_sustain() -> None: + """ + Sustain=True should set note duration to the full pattern length. + """ -def test_chord_places_all_tones () -> None: + pattern, builder = _make_builder(length=4) - """ - A major chord should place 3 notes at beat 0. - """ + chord = subsequence.chords.Chord(root_pc=0, quality="major") - pattern, builder = _make_builder() + builder.chord(chord, root=60, velocity=90, sustain=True) - chord = subsequence.chords.Chord(root_pc=4, quality="major") + expected_duration = int(4 * subsequence.constants.MIDI_QUARTER_NOTE) - builder.chord(chord, root=52, velocity=90) + for note in pattern.steps[0].notes: + assert note.duration == expected_duration - # Beat 0 = pulse 0 - assert 0 in pattern.steps - assert len(pattern.steps[0].notes) == 3 - pitches = sorted([n.pitch for n in pattern.steps[0].notes]) +def test_swing_applies() -> None: + """ + Swing should modify step positions in the pattern. + p.swing() takes a percentage: 67 ≈ triplet swing. + """ - assert pitches == [52, 56, 59] + pattern, builder = _make_builder(length=2) + # Place notes on every 8th note + for i in range(4): + builder.note(60, beat=i * 0.5, velocity=100, duration=0.25) -def test_chord_sustain () -> None: + positions_before = set(pattern.steps.keys()) - """ - Sustain=True should set note duration to the full pattern length. - """ + builder.swing(67, grid=0.5) # triplet swing on 8th-note grid - pattern, builder = _make_builder(length=4) + positions_after = set(pattern.steps.keys()) - chord = subsequence.chords.Chord(root_pc=0, quality="major") + # Swing should move at least one off-beat position + assert positions_before != positions_after - builder.chord(chord, root=60, velocity=90, sustain=True) - expected_duration = int(4 * subsequence.constants.MIDI_QUARTER_NOTE) +def test_drum_note_map_resolves_strings() -> None: + """ + String pitches should resolve to MIDI notes via the drum note map. + """ - for note in pattern.steps[0].notes: - assert note.duration == expected_duration + drum_map = {"kick": 36, "snare": 38} + pattern, builder = _make_builder(drum_note_map=drum_map) + builder.note("kick", beat=0, velocity=100) + builder.note("snare", beat=1, velocity=100) -def test_swing_applies () -> None: + notes_at_0 = pattern.steps[0].notes + pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + notes_at_1 = pattern.steps[pulse_1].notes - """ - Swing should modify step positions in the pattern. - p.swing() takes a percentage: 67 ≈ triplet swing. - """ + assert notes_at_0[0].pitch == 36 + assert notes_at_1[0].pitch == 38 - pattern, builder = _make_builder(length=2) - # Place notes on every 8th note - for i in range(4): - builder.note(60, beat=i * 0.5, velocity=100, duration=0.25) +def test_unknown_drum_name_warns_and_drops(caplog: pytest.LogCaptureFixture) -> None: + """ + An unknown drum name mapped by no destination is dropped and warned once, + not raised — faithful-core device maps legitimately lack voices. + """ - positions_before = set(pattern.steps.keys()) + import logging - builder.swing(67, grid=0.5) # triplet swing on 8th-note grid + drum_map = {"kick": 36} + pattern, builder = _make_builder(drum_note_map=drum_map) - positions_after = set(pattern.steps.keys()) + with caplog.at_level(logging.WARNING): + builder.note("cymbal", beat=0) - # Swing should move at least one off-beat position - assert positions_before != positions_after + assert pattern.steps == {} # nothing placed + assert any("cymbal" in r.message and "dropped" in r.message for r in caplog.records) -def test_drum_note_map_resolves_strings () -> None: +def test_string_pitch_without_map_raises() -> None: + """ + String pitches without a drum note map should raise a clear error. + """ - """ - String pitches should resolve to MIDI notes via the drum note map. - """ + pattern, builder = _make_builder(drum_note_map=None) - drum_map = {"kick": 36, "snare": 38} - pattern, builder = _make_builder(drum_note_map=drum_map) + with pytest.raises(ValueError, match="requires a drum_note_map"): + builder.note("kick", beat=0) - builder.note("kick", beat=0, velocity=100) - builder.note("snare", beat=1, velocity=100) - notes_at_0 = pattern.steps[0].notes - pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - notes_at_1 = pattern.steps[pulse_1].notes +def test_integer_pitch_bypasses_map() -> None: + """ + Integer pitches should work regardless of drum note map presence. + """ - assert notes_at_0[0].pitch == 36 - assert notes_at_1[0].pitch == 38 + pattern, builder = _make_builder(drum_note_map=None) + builder.note(60, beat=0, velocity=100) -def test_unknown_drum_name_warns_and_drops (caplog: pytest.LogCaptureFixture) -> None: + assert 0 in pattern.steps + assert pattern.steps[0].notes[0].pitch == 60 - """ - An unknown drum name mapped by no destination is dropped and warned once, - not raised — faithful-core device maps legitimately lack voices. - """ - import logging +def test_note_origin_set_for_string_pitch() -> None: + """A named drum hit carries its original name on the Note (for mirroring).""" - drum_map = {"kick": 36} - pattern, builder = _make_builder(drum_note_map=drum_map) + drum_map = {"hi_hat_closed": 44} + pattern, builder = _make_builder(drum_note_map=drum_map) - with caplog.at_level(logging.WARNING): - builder.note("cymbal", beat=0) + builder.note("hi_hat_closed", beat=0) - assert pattern.steps == {} # nothing placed - assert any("cymbal" in r.message and "dropped" in r.message for r in caplog.records) + note = pattern.steps[0].notes[0] + assert note.pitch == 44 + assert note.origin == "hi_hat_closed" -def test_string_pitch_without_map_raises () -> None: +def test_note_origin_none_for_int_pitch() -> None: + """A numeric pitch leaves ``origin`` unset — nothing to re-resolve.""" - """ - String pitches without a drum note map should raise a clear error. - """ + pattern, builder = _make_builder() - pattern, builder = _make_builder(drum_note_map=None) + builder.note(60, beat=0) - with pytest.raises(ValueError, match="requires a drum_note_map"): - builder.note("kick", beat=0) + assert pattern.steps[0].notes[0].origin is None -def test_integer_pitch_bypasses_map () -> None: +def test_add_note_origin_passthrough() -> None: + """``Pattern.add_note`` forwards ``origin`` to the Note; default is None.""" - """ - Integer pitches should work regardless of drum note map presence. - """ + pattern = subsequence.pattern.Pattern(channel=0, length=4) - pattern, builder = _make_builder(drum_note_map=None) + pattern.add_note( + position=0, pitch=44, velocity=100, duration=6, origin="hi_hat_closed" + ) + pattern.add_note(position=6, pitch=60, velocity=100, duration=6) - builder.note(60, beat=0, velocity=100) + assert pattern.steps[0].notes[0].origin == "hi_hat_closed" + assert pattern.steps[6].notes[0].origin is None - assert 0 in pattern.steps - assert pattern.steps[0].notes[0].pitch == 60 +def test_note_primary_unmapped_when_only_mirror_has_it() -> None: + """A name absent from the primary map but present in a mirror's is placed as primary_unmapped.""" -def test_note_origin_set_for_string_pitch () -> None: + pattern = subsequence.pattern.Pattern( + channel=0, length=4, mirrors=[(1, 5, {"crash": 49})] + ) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, drum_note_map={"kick": 36}, default_grid=16 + ) - """A named drum hit carries its original name on the Note (for mirroring).""" + builder.note("crash", beat=0) - drum_map = {"hi_hat_closed": 44} - pattern, builder = _make_builder(drum_note_map=drum_map) + note = pattern.steps[0].notes[0] + assert note.origin == "crash" + assert note.primary_unmapped is True + assert ( + note.pitch == 49 + ) # placeholder from the mirror map — used only by transforms/display - builder.note("hi_hat_closed", beat=0) - note = pattern.steps[0].notes[0] - assert note.pitch == 44 - assert note.origin == "hi_hat_closed" +def test_note_on_unknown_drum_warns_and_drops(caplog: pytest.LogCaptureFixture) -> None: + """A drone / note_on for a voice this device's map lacks is dropped and + warned once — not raised — matching the step-note methods (unified rule).""" + import logging -def test_note_origin_none_for_int_pitch () -> None: + pattern, builder = _make_builder(drum_note_map={"kick": 36}) - """A numeric pitch leaves ``origin`` unset — nothing to re-resolve.""" + with caplog.at_level(logging.WARNING): + builder.note_on("cymbal", beat=0) - pattern, builder = _make_builder() + assert pattern.raw_note_events == [] + assert any("cymbal" in r.message and "dropped" in r.message for r in caplog.records) - builder.note(60, beat=0) - assert pattern.steps[0].notes[0].origin is None +def test_note_on_unknown_drum_without_map_raises() -> None: + """With no drum_note_map at all, a string pitch is still a configuration error.""" + pattern, builder = _make_builder(drum_note_map=None) -def test_add_note_origin_passthrough () -> None: + with pytest.raises(ValueError, match="requires a drum_note_map"): + builder.note_on("kick", beat=0) - """``Pattern.add_note`` forwards ``origin`` to the Note; default is None.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) +def test_arpeggio_drops_unknown_voice_keeps_known( + caplog: pytest.LogCaptureFixture, +) -> None: + """arpeggio drops voices the map lacks (warned once) and keeps the rest.""" - pattern.add_note(position=0, pitch=44, velocity=100, duration=6, origin="hi_hat_closed") - pattern.add_note(position=6, pitch=60, velocity=100, duration=6) + import logging - assert pattern.steps[0].notes[0].origin == "hi_hat_closed" - assert pattern.steps[6].notes[0].origin is None + pattern, builder = _make_builder(drum_note_map={"kick": 36}) + with caplog.at_level(logging.WARNING): + builder.arpeggio(["kick", "cymbal"], spacing=0.5, duration=0.25) -def test_note_primary_unmapped_when_only_mirror_has_it () -> None: + pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} + assert pitches == {36} + assert any("cymbal" in r.message for r in caplog.records) - """A name absent from the primary map but present in a mirror's is placed as primary_unmapped.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4, mirrors=[(1, 5, {"crash": 49})]) - builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0, drum_note_map={"kick": 36}, default_grid=16) +def test_arpeggio_all_unknown_is_noop() -> None: + """If every arpeggio voice is unmapped, nothing is placed (no error).""" - builder.note("crash", beat=0) + pattern, builder = _make_builder(drum_note_map={"kick": 36}) - note = pattern.steps[0].notes[0] - assert note.origin == "crash" - assert note.primary_unmapped is True - assert note.pitch == 49 # placeholder from the mirror map — used only by transforms/display + builder.arpeggio(["cymbal", "triangle"], spacing=0.5) + assert pattern.steps == {} -def test_note_on_unknown_drum_warns_and_drops (caplog: pytest.LogCaptureFixture) -> None: - """A drone / note_on for a voice this device's map lacks is dropped and - warned once — not raised — matching the step-note methods (unified rule).""" +def test_note_on_adds_raw_event() -> None: + """ + p.note_on() should append to pattern.raw_note_events without creating steps. + """ - import logging + pattern, builder = _make_builder() - pattern, builder = _make_builder(drum_note_map={"kick": 36}) + builder.note_on(60, beat=1.0, velocity=95) - with caplog.at_level(logging.WARNING): - builder.note_on("cymbal", beat=0) + assert not pattern.steps + assert len(pattern.raw_note_events) == 1 + event = pattern.raw_note_events[0] + assert event.message_type == "note_on" + assert event.pulse == int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + assert event.pitch == 60 + assert event.velocity == 95 - assert pattern.raw_note_events == [] - assert any("cymbal" in r.message and "dropped" in r.message for r in caplog.records) +def test_note_off_adds_raw_event() -> None: + """ + p.note_off() should append to pattern.raw_note_events without creating steps. + """ -def test_note_on_unknown_drum_without_map_raises () -> None: + pattern, builder = _make_builder() - """With no drum_note_map at all, a string pitch is still a configuration error.""" + builder.note_off(60, beat=2.0) - pattern, builder = _make_builder(drum_note_map=None) + assert not pattern.steps + assert len(pattern.raw_note_events) == 1 + event = pattern.raw_note_events[0] + assert event.message_type == "note_off" + assert event.pulse == int(2.0 * subsequence.constants.MIDI_QUARTER_NOTE) + assert event.pitch == 60 + assert event.velocity == 0 - with pytest.raises(ValueError, match="requires a drum_note_map"): - builder.note_on("kick", beat=0) +def test_drone_adds_raw_event_at_zero() -> None: + """ + p.drone() should alias p.note_on() at beat 0.0 by default. + """ -def test_arpeggio_drops_unknown_voice_keeps_known (caplog: pytest.LogCaptureFixture) -> None: + pattern, builder = _make_builder() + builder.drone(36, velocity=85) - """arpeggio drops voices the map lacks (warned once) and keeps the rest.""" + assert len(pattern.raw_note_events) == 1 + event = pattern.raw_note_events[0] + assert event.message_type == "note_on" + assert event.pulse == 0 + assert event.pitch == 36 + assert event.velocity == 85 - import logging - pattern, builder = _make_builder(drum_note_map={"kick": 36}) +def test_drone_off_adds_raw_event_at_zero() -> None: + """ + p.drone_off() should alias p.note_off() at beat 0.0. + """ - with caplog.at_level(logging.WARNING): - builder.arpeggio(["kick", "cymbal"], spacing=0.5, duration=0.25) + pattern, builder = _make_builder() + builder.drone_off(36) - pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} - assert pitches == {36} - assert any("cymbal" in r.message for r in caplog.records) + assert len(pattern.raw_note_events) == 1 + event = pattern.raw_note_events[0] + assert event.message_type == "note_off" + assert event.pulse == 0 + assert event.pitch == 36 + assert event.velocity == 0 -def test_arpeggio_all_unknown_is_noop () -> None: +def test_silence_adds_all_notes_sound_off() -> None: + """ + p.silence() should append CC 123 and 120 messages at beat 0.0 by default. + """ - """If every arpeggio voice is unmapped, nothing is placed (no error).""" + pattern, builder = _make_builder() + builder.silence(beat=1.0) - pattern, builder = _make_builder(drum_note_map={"kick": 36}) + assert len(pattern.cc_events) == 2 - builder.arpeggio(["cymbal", "triangle"], spacing=0.5) + cc123_event = pattern.cc_events[0] + assert cc123_event.message_type == "control_change" + assert cc123_event.control == 123 + assert cc123_event.value == 0 + assert cc123_event.pulse == int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert pattern.steps == {} + cc120_event = pattern.cc_events[1] + assert cc120_event.message_type == "control_change" + assert cc120_event.control == 120 + assert cc120_event.value == 0 + assert cc120_event.pulse == int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) -def test_note_on_adds_raw_event () -> None: +def test_chord_tones_method() -> None: + """ + Chord.tones() should return MIDI note numbers for all chord tones. + """ - """ - p.note_on() should append to pattern.raw_note_events without creating steps. - """ + chord = subsequence.chords.Chord(root_pc=4, quality="major") - pattern, builder = _make_builder() + tones = chord.tones(root=76) - builder.note_on(60, beat=1.0, velocity=95) + assert tones == [76, 80, 83] - assert not pattern.steps - assert len(pattern.raw_note_events) == 1 - event = pattern.raw_note_events[0] - assert event.message_type == 'note_on' - assert event.pulse == int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert event.pitch == 60 - assert event.velocity == 95 +def test_chord_tones_seventh() -> None: + """ + Chord.tones() on a dominant 7th should return 4 notes. + """ -def test_note_off_adds_raw_event () -> None: + chord = subsequence.chords.Chord(root_pc=7, quality="dominant_7th") - """ - p.note_off() should append to pattern.raw_note_events without creating steps. - """ + tones = chord.tones(root=55) - pattern, builder = _make_builder() - - builder.note_off(60, beat=2.0) - - assert not pattern.steps - assert len(pattern.raw_note_events) == 1 - event = pattern.raw_note_events[0] - assert event.message_type == 'note_off' - assert event.pulse == int(2.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert event.pitch == 60 - assert event.velocity == 0 - - -def test_drone_adds_raw_event_at_zero () -> None: - - """ - p.drone() should alias p.note_on() at beat 0.0 by default. - """ - - pattern, builder = _make_builder() - builder.drone(36, velocity=85) - - assert len(pattern.raw_note_events) == 1 - event = pattern.raw_note_events[0] - assert event.message_type == 'note_on' - assert event.pulse == 0 - assert event.pitch == 36 - assert event.velocity == 85 - - -def test_drone_off_adds_raw_event_at_zero () -> None: - - """ - p.drone_off() should alias p.note_off() at beat 0.0. - """ - - pattern, builder = _make_builder() - builder.drone_off(36) - - assert len(pattern.raw_note_events) == 1 - event = pattern.raw_note_events[0] - assert event.message_type == 'note_off' - assert event.pulse == 0 - assert event.pitch == 36 - assert event.velocity == 0 - - -def test_silence_adds_all_notes_sound_off () -> None: - - """ - p.silence() should append CC 123 and 120 messages at beat 0.0 by default. - """ - - pattern, builder = _make_builder() - builder.silence(beat=1.0) - - assert len(pattern.cc_events) == 2 - - cc123_event = pattern.cc_events[0] - assert cc123_event.message_type == 'control_change' - assert cc123_event.control == 123 - assert cc123_event.value == 0 - assert cc123_event.pulse == int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - - cc120_event = pattern.cc_events[1] - assert cc120_event.message_type == 'control_change' - assert cc120_event.control == 120 - assert cc120_event.value == 0 - assert cc120_event.pulse == int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - - -def test_chord_tones_method () -> None: - - """ - Chord.tones() should return MIDI note numbers for all chord tones. - """ - - chord = subsequence.chords.Chord(root_pc=4, quality="major") - - tones = chord.tones(root=76) - - assert tones == [76, 80, 83] - - -def test_chord_tones_seventh () -> None: - - """ - Chord.tones() on a dominant 7th should return 4 notes. - """ - - chord = subsequence.chords.Chord(root_pc=7, quality="dominant_7th") - - tones = chord.tones(root=55) - - assert tones == [55, 59, 62, 65] + assert tones == [55, 59, 62, 65] # --- Phase 3: Rhythm Helpers --- -def test_euclidean_generates_rhythm () -> None: - - """ - Euclidean with 4 pulses over 16 steps should place exactly 4 hits. - """ - - drum_map = {"kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - - builder.euclidean("kick", pulses=4, velocity=100) - - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - - assert total_notes == 4 - - -def test_bresenham_generates_rhythm () -> None: - - """ - Bresenham with 3 pulses over 16 steps should place exactly 3 hits. - """ - - drum_map = {"snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - - builder.bresenham("snare", pulses=3, velocity=110) - - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - - assert total_notes == 3 +def test_euclidean_generates_rhythm() -> None: + """ + Euclidean with 4 pulses over 16 steps should place exactly 4 hits. + """ + drum_map = {"kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) -def test_dropout_removes_some_hits () -> None: + builder.euclidean("kick", pulses=4, velocity=100) - """ - Dropout at 0.5 probability with a seeded RNG should remove approximately half. - """ + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - import random + assert total_notes == 4 - pattern, builder = _make_builder(length=4) - builder.repeat(60, spacing=0.25, velocity=100) +def test_bresenham_generates_rhythm() -> None: + """ + Bresenham with 3 pulses over 16 steps should place exactly 3 hits. + """ - total_before = sum(len(step.notes) for step in pattern.steps.values()) + drum_map = {"snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - assert total_before == 16 + builder.bresenham("snare", pulses=3, velocity=110) - seeded_rng = random.Random(42) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - builder.dropout(probability=0.5, rng=seeded_rng) + assert total_notes == 3 - total_after = sum(len(step.notes) for step in pattern.steps.values()) - assert 0 < total_after < total_before +def test_dropout_removes_some_hits() -> None: + """ + Dropout at 0.5 probability with a seeded RNG should remove approximately half. + """ + import random -def test_velocity_shape_applies () -> None: + pattern, builder = _make_builder(length=4) - """ - Velocity shaping should change note velocities to non-uniform values. - """ + builder.repeat(60, spacing=0.25, velocity=100) - pattern, builder = _make_builder(length=2) + total_before = sum(len(step.notes) for step in pattern.steps.values()) - builder.repeat(60, spacing=0.5, velocity=100) + assert total_before == 16 - builder.velocity_shape(low=40, high=120) + seeded_rng = random.Random(42) - velocities = [] + builder.dropout(probability=0.5, rng=seeded_rng) - for step in pattern.steps.values(): - for note in step.notes: - velocities.append(note.velocity) + total_after = sum(len(step.notes) for step in pattern.steps.values()) - # All velocities should be within range - assert all(40 <= v <= 120 for v in velocities) + assert 0 < total_after < total_before - # Velocities should not all be the same (van der Corput is non-uniform) - assert len(set(velocities)) > 1 +def test_velocity_shape_applies() -> None: + """ + Velocity shaping should change note velocities to non-uniform values. + """ -def test_arpeggio_cycles_pitches () -> None: + pattern, builder = _make_builder(length=2) - """ - Arpeggio with 3 pitches over 4 beats at spacing=0.5 should produce 8 notes cycling through the pitches. - """ + builder.repeat(60, spacing=0.5, velocity=100) - pattern, builder = _make_builder(length=4) + builder.velocity_shape(low=40, high=120) - builder.arpeggio([60, 64, 67], spacing=0.5, velocity=90) + velocities = [] - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + for step in pattern.steps.values(): + for note in step.notes: + velocities.append(note.velocity) - assert total_notes == 8 + # All velocities should be within range + assert all(40 <= v <= 120 for v in velocities) - # Verify pitch cycling - positions = sorted(pattern.steps.keys()) - pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] + # Velocities should not all be the same (van der Corput is non-uniform) + assert len(set(velocities)) > 1 - expected_pitches = [60, 64, 67, 60, 64, 67, 60, 64] - assert pitches == expected_pitches +def test_arpeggio_cycles_pitches() -> None: + """ + Arpeggio with 3 pitches over 4 beats at spacing=0.5 should produce 8 notes cycling through the pitches. + """ + pattern, builder = _make_builder(length=4) -def test_arpeggio_fills_pattern () -> None: + builder.arpeggio([60, 64, 67], spacing=0.5, velocity=90) - """ - Arpeggio with spacing=0.25 over 4 beats should produce 16 notes. - """ + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - pattern, builder = _make_builder(length=4) + assert total_notes == 8 - builder.arpeggio([60, 64, 67], spacing=0.25, velocity=90) + # Verify pitch cycling + positions = sorted(pattern.steps.keys()) + pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + expected_pitches = [60, 64, 67, 60, 64, 67, 60, 64] - assert total_notes == 16 + assert pitches == expected_pitches -def test_arpeggio_empty_pitches_rests () -> None: +def test_arpeggio_fills_pattern() -> None: + """ + Arpeggio with spacing=0.25 over 4 beats should produce 16 notes. + """ - """ - Arpeggio with an empty pitches list rests (places nothing, returns self) - so ``p.arpeggio(p.held_notes())`` is silent when no keys are held. - """ + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder() + builder.arpeggio([60, 64, 67], spacing=0.25, velocity=90) - result = builder.arpeggio([], spacing=0.25) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert result is builder - assert pattern.steps == {} + assert total_notes == 16 -def test_arpeggio_invalid_step_raises () -> None: +def test_arpeggio_empty_pitches_rests() -> None: + """ + Arpeggio with an empty pitches list rests (places nothing, returns self) + so ``p.arpeggio(p.held_notes())`` is silent when no keys are held. + """ - """ - Arpeggio with non-positive step should raise ValueError. - """ + pattern, builder = _make_builder() - pattern, builder = _make_builder() + result = builder.arpeggio([], spacing=0.25) - with pytest.raises(ValueError, match="Spacing must be positive"): - builder.arpeggio([60, 64, 67], spacing=0) + assert result is builder + assert pattern.steps == {} - with pytest.raises(ValueError, match="Spacing must be positive"): - builder.arpeggio([60, 64, 67], spacing=-1) +def test_arpeggio_invalid_step_raises() -> None: + """ + Arpeggio with non-positive step should raise ValueError. + """ -def test_arpeggio_positional_list_unchanged () -> None: + pattern, builder = _make_builder() - """A positional pitch list (no ``notes=`` keyword) behaves exactly as before the rename.""" + with pytest.raises(ValueError, match="Spacing must be positive"): + builder.arpeggio([60, 64, 67], spacing=0) - pattern, builder = _make_builder(length=4) + with pytest.raises(ValueError, match="Spacing must be positive"): + builder.arpeggio([60, 64, 67], spacing=-1) - builder.arpeggio([60, 64, 67], spacing=0.5, velocity=90) - positions = sorted(pattern.steps.keys()) - pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] +def test_arpeggio_positional_list_unchanged() -> None: + """A positional pitch list (no ``notes=`` keyword) behaves exactly as before the rename.""" - assert pitches == [60, 64, 67, 60, 64, 67, 60, 64] + pattern, builder = _make_builder(length=4) + builder.arpeggio([60, 64, 67], spacing=0.5, velocity=90) -def test_arpeggio_chord_form_matches_tones () -> None: + positions = sorted(pattern.steps.keys()) + pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] - """Arpeggiating a chord cycles its tones — identical to passing chord.tones() as a list.""" + assert pitches == [60, 64, 67, 60, 64, 67, 60, 64] - pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.arpeggio(chord, root=60, spacing=0.5) +def test_arpeggio_chord_form_matches_tones() -> None: + """Arpeggiating a chord cycles its tones — identical to passing chord.tones() as a list.""" - positions = sorted(pattern.steps.keys()) - pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] + pattern, builder = _make_builder(length=4) - assert pitches == [60, 64, 67, 60, 64, 67, 60, 64] + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.arpeggio(chord, root=60, spacing=0.5) + positions = sorted(pattern.steps.keys()) + pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] -def test_arpeggio_chord_form_count_voices () -> None: + assert pitches == [60, 64, 67, 60, 64, 67, 60, 64] - """count= voices the chord like chord(); the cycle matches chord.tones(count=...).""" - pattern, builder = _make_builder(length=4) +def test_arpeggio_chord_form_count_voices() -> None: + """count= voices the chord like chord(); the cycle matches chord.tones(count=...).""" - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.arpeggio(chord, root=60, count=4, spacing=0.5) + pattern, builder = _make_builder(length=4) - expected = chord.tones(root=60, count=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.arpeggio(chord, root=60, count=4, spacing=0.5) - positions = sorted(pattern.steps.keys()) - pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] + expected = chord.tones(root=60, count=4) - assert pitches == expected + expected + positions = sorted(pattern.steps.keys()) + pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] + assert pitches == expected + expected -def test_arpeggio_chord_form_inversion () -> None: - """inversion= is applied to the chord form, matching chord.tones(inversion=...).""" +def test_arpeggio_chord_form_inversion() -> None: + """inversion= is applied to the chord form, matching chord.tones(inversion=...).""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.arpeggio(chord, root=60, inversion=1, spacing=0.5) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.arpeggio(chord, root=60, inversion=1, spacing=0.5) - expected = chord.tones(root=60, inversion=1) + expected = chord.tones(root=60, inversion=1) - positions = sorted(pattern.steps.keys()) - first_three = [pattern.steps[pos].notes[0].pitch for pos in positions[:3]] + positions = sorted(pattern.steps.keys()) + first_three = [pattern.steps[pos].notes[0].pitch for pos in positions[:3]] - assert first_three == expected[:3] + assert first_three == expected[:3] -def test_arpeggio_chord_form_direction_down () -> None: +def test_arpeggio_chord_form_direction_down() -> None: + """direction='down' on a chord starts from the highest tone.""" - """direction='down' on a chord starts from the highest tone.""" + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.arpeggio(chord, root=60, spacing=0.5, direction="down") - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.arpeggio(chord, root=60, spacing=0.5, direction="down") + positions = sorted(pattern.steps.keys()) + first_pitch = pattern.steps[positions[0]].notes[0].pitch - positions = sorted(pattern.steps.keys()) - first_pitch = pattern.steps[positions[0]].notes[0].pitch + assert first_pitch == 67 - assert first_pitch == 67 +def test_arpeggio_duck_typed_accepts_non_chord_with_tones() -> None: + """Any object with a .tones() method routes through the chord branch. -def test_arpeggio_duck_typed_accepts_non_chord_with_tones () -> None: + Guards the duck-typing decision: the chord injected into a pattern is an + _InjectedChord, not a subsequence.chords.Chord, so an isinstance() gate would + silently break the verb swap. + """ - """Any object with a .tones() method routes through the chord branch. + class _StandIn: + def tones( + self, root: int, inversion: int = 0, count: typing.Optional[int] = None + ) -> typing.List[int]: + return [root, root + 4, root + 7] - Guards the duck-typing decision: the chord injected into a pattern is an - _InjectedChord, not a subsequence.chords.Chord, so an isinstance() gate would - silently break the verb swap. - """ + pattern, builder = _make_builder(length=4) - class _StandIn: + builder.arpeggio(_StandIn(), root=60, spacing=0.5) - def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: - return [root, root + 4, root + 7] + positions = sorted(pattern.steps.keys()) + pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] - pattern, builder = _make_builder(length=4) + assert pitches == [60, 64, 67, 60, 64, 67, 60, 64] - builder.arpeggio(_StandIn(), root=60, spacing=0.5) - positions = sorted(pattern.steps.keys()) - pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] +def test_arpeggio_beat_offsets_start() -> None: + """beat= shifts the figure's start; nothing is placed before it.""" - assert pitches == [60, 64, 67, 60, 64, 67, 60, 64] + pattern, builder = _make_builder(length=4) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + builder.arpeggio([60, 64, 67], beat=1.0, spacing=0.5) -def test_arpeggio_beat_offsets_start () -> None: + positions = sorted(pattern.steps.keys()) - """beat= shifts the figure's start; nothing is placed before it.""" + assert positions[0] == int(1.0 * ppq) + assert all(pos >= int(1.0 * ppq) for pos in positions) + assert sum(len(step.notes) for step in pattern.steps.values()) == 6 - pattern, builder = _make_builder(length=4) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - builder.arpeggio([60, 64, 67], beat=1.0, spacing=0.5) +def test_arpeggio_span_bounds_window() -> None: + """span= confines the figure to [beat, beat + span).""" - positions = sorted(pattern.steps.keys()) + pattern, builder = _make_builder(length=4) + ppq = subsequence.constants.MIDI_QUARTER_NOTE - assert positions[0] == int(1.0 * ppq) - assert all(pos >= int(1.0 * ppq) for pos in positions) - assert sum(len(step.notes) for step in pattern.steps.values()) == 6 + builder.arpeggio([60, 64, 67], beat=1.0, span=1.0, spacing=0.5, duration=0.25) + positions = sorted(pattern.steps.keys()) -def test_arpeggio_span_bounds_window () -> None: + assert positions == [int(1.0 * ppq), int(1.5 * ppq)] - """span= confines the figure to [beat, beat + span).""" - pattern, builder = _make_builder(length=4) - ppq = subsequence.constants.MIDI_QUARTER_NOTE +def test_arpeggio_span_clamps_to_pattern_end() -> None: + """A span that overshoots the bar is clamped — nothing is written past the end.""" - builder.arpeggio([60, 64, 67], beat=1.0, span=1.0, spacing=0.5, duration=0.25) + pattern, builder = _make_builder(length=4) + ppq = subsequence.constants.MIDI_QUARTER_NOTE - positions = sorted(pattern.steps.keys()) + builder.arpeggio([60, 64, 67], beat=3.0, span=10.0, spacing=0.5) - assert positions == [int(1.0 * ppq), int(1.5 * ppq)] + positions = sorted(pattern.steps.keys()) + assert positions + assert max(positions) < int(4.0 * ppq) -def test_arpeggio_span_clamps_to_pattern_end () -> None: - """A span that overshoots the bar is clamped — nothing is written past the end.""" +def test_arpeggio_start_beyond_length_places_nothing() -> None: + """A start at or past the pattern end places nothing (the slot is outside the bar).""" - pattern, builder = _make_builder(length=4) - ppq = subsequence.constants.MIDI_QUARTER_NOTE + pattern, builder = _make_builder(length=4) - builder.arpeggio([60, 64, 67], beat=3.0, span=10.0, spacing=0.5) + builder.arpeggio([60, 64, 67], beat=4.0, spacing=0.5) - positions = sorted(pattern.steps.keys()) + assert sum(len(step.notes) for step in pattern.steps.values()) == 0 - assert positions - assert max(positions) < int(4.0 * ppq) +def test_arpeggio_span_non_positive_raises() -> None: + """A zero or negative span is a misuse and raises.""" -def test_arpeggio_start_beyond_length_places_nothing () -> None: + pattern, builder = _make_builder(length=4) - """A start at or past the pattern end places nothing (the slot is outside the bar).""" + with pytest.raises(ValueError, match="span must be positive"): + builder.arpeggio([60, 64, 67], span=0, spacing=0.5) - pattern, builder = _make_builder(length=4) + with pytest.raises(ValueError, match="span must be positive"): + builder.arpeggio([60, 64, 67], span=-2, spacing=0.5) - builder.arpeggio([60, 64, 67], beat=4.0, spacing=0.5) - assert sum(len(step.notes) for step in pattern.steps.values()) == 0 +def test_arpeggio_negative_beat_raises() -> None: + """A negative start beat raises rather than wrapping into a wall of notes.""" + pattern, builder = _make_builder(length=4) -def test_arpeggio_span_non_positive_raises () -> None: + with pytest.raises(ValueError, match="beat must be >= 0"): + builder.arpeggio([60, 64, 67], beat=-1.0, spacing=0.5) - """A zero or negative span is a misuse and raises.""" - pattern, builder = _make_builder(length=4) +def test_arpeggio_chord_without_root_raises() -> None: + """The chord form needs a root, just like chord()/strum().""" - with pytest.raises(ValueError, match="span must be positive"): - builder.arpeggio([60, 64, 67], span=0, spacing=0.5) + pattern, builder = _make_builder(length=4) - with pytest.raises(ValueError, match="span must be positive"): - builder.arpeggio([60, 64, 67], span=-2, spacing=0.5) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + with pytest.raises(ValueError, match="needs a root"): + builder.arpeggio(chord, spacing=0.5) -def test_arpeggio_negative_beat_raises () -> None: - """A negative start beat raises rather than wrapping into a wall of notes.""" +def test_arpeggio_list_with_chord_args_raises() -> None: + """root=/count=/inversion= only make sense for the chord form — a list with them raises.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - with pytest.raises(ValueError, match="beat must be >= 0"): - builder.arpeggio([60, 64, 67], beat=-1.0, spacing=0.5) + with pytest.raises(ValueError, match="only apply to the chord form"): + builder.arpeggio([60, 64, 67], root=48, spacing=0.5) + with pytest.raises(ValueError, match="only apply to the chord form"): + builder.arpeggio([60, 64, 67], count=4, spacing=0.5) -def test_arpeggio_chord_without_root_raises () -> None: - - """The chord form needs a root, just like chord()/strum().""" - - pattern, builder = _make_builder(length=4) - - chord = subsequence.chords.Chord(root_pc=0, quality="major") - - with pytest.raises(ValueError, match="needs a root"): - builder.arpeggio(chord, spacing=0.5) - - -def test_arpeggio_list_with_chord_args_raises () -> None: - - """root=/count=/inversion= only make sense for the chord form — a list with them raises.""" - - pattern, builder = _make_builder(length=4) - - with pytest.raises(ValueError, match="only apply to the chord form"): - builder.arpeggio([60, 64, 67], root=48, spacing=0.5) - - with pytest.raises(ValueError, match="only apply to the chord form"): - builder.arpeggio([60, 64, 67], count=4, spacing=0.5) - - with pytest.raises(ValueError, match="only apply to the chord form"): - builder.arpeggio([60, 64, 67], inversion=1, spacing=0.5) + with pytest.raises(ValueError, match="only apply to the chord form"): + builder.arpeggio([60, 64, 67], inversion=1, spacing=0.5) # --- Phase 4: Step-Based Hits --- -def test_hit_steps_places_at_correct_pulses () -> None: +def test_hit_steps_places_at_correct_pulses() -> None: + """Steps 0, 4, 8, 12 on a 16-step / 4-beat grid should map to beats 0, 1, 2, 3.""" - """Steps 0, 4, 8, 12 on a 16-step / 4-beat grid should map to beats 0, 1, 2, 3.""" + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder(length=4) + builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=127) - builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=127) + expected_pulses = [ + int(0 * subsequence.constants.MIDI_QUARTER_NOTE), + int(1 * subsequence.constants.MIDI_QUARTER_NOTE), + int(2 * subsequence.constants.MIDI_QUARTER_NOTE), + int(3 * subsequence.constants.MIDI_QUARTER_NOTE), + ] - expected_pulses = [ - int(0 * subsequence.constants.MIDI_QUARTER_NOTE), - int(1 * subsequence.constants.MIDI_QUARTER_NOTE), - int(2 * subsequence.constants.MIDI_QUARTER_NOTE), - int(3 * subsequence.constants.MIDI_QUARTER_NOTE), - ] + assert sorted(pattern.steps.keys()) == expected_pulses - assert sorted(pattern.steps.keys()) == expected_pulses + for pulse in expected_pulses: + assert pattern.steps[pulse].notes[0].pitch == 60 + assert pattern.steps[pulse].notes[0].velocity == 127 - for pulse in expected_pulses: - assert pattern.steps[pulse].notes[0].pitch == 60 - assert pattern.steps[pulse].notes[0].velocity == 127 +def test_hit_steps_with_drum_note_map() -> None: + """String pitches should resolve via the drum note map in hit_steps.""" -def test_hit_steps_with_drum_note_map () -> None: + drum_map = {"kick": 36, "snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - """String pitches should resolve via the drum note map in hit_steps.""" + builder.hit_steps("kick", steps=[0, 4, 8, 12], velocity=127) - drum_map = {"kick": 36, "snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + assert 0 in pattern.steps + assert pattern.steps[0].notes[0].pitch == 36 - builder.hit_steps("kick", steps=[0, 4, 8, 12], velocity=127) - assert 0 in pattern.steps - assert pattern.steps[0].notes[0].pitch == 36 +def test_hit_steps_custom_grid() -> None: + """A custom grid of 8 on a 4-beat pattern should place steps at half-beat intervals.""" + pattern, builder = _make_builder(length=4) -def test_hit_steps_custom_grid () -> None: + builder.hit_steps(60, steps=[0, 2, 4, 6], velocity=100, grid=8) - """A custom grid of 8 on a 4-beat pattern should place steps at half-beat intervals.""" + # step_duration = 4 / 8 = 0.5 beats per step + expected_pulses = [ + int(0.0 * subsequence.constants.MIDI_QUARTER_NOTE), + int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE), + int(2.0 * subsequence.constants.MIDI_QUARTER_NOTE), + int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE), + ] - pattern, builder = _make_builder(length=4) + assert sorted(pattern.steps.keys()) == expected_pulses - builder.hit_steps(60, steps=[0, 2, 4, 6], velocity=100, grid=8) - # step_duration = 4 / 8 = 0.5 beats per step - expected_pulses = [ - int(0.0 * subsequence.constants.MIDI_QUARTER_NOTE), - int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE), - int(2.0 * subsequence.constants.MIDI_QUARTER_NOTE), - int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE), - ] +def test_hit_steps_default_grid_from_length() -> None: + """Without an explicit grid, a 6-sixteenth-note pattern should auto-derive grid=6.""" - assert sorted(pattern.steps.keys()) == expected_pulses + pattern, builder = _make_builder(length=1.5) # 6 * dur.SIXTEENTH = 1.5 beats + builder.hit_steps(60, steps=[0, 3], velocity=100) -def test_hit_steps_default_grid_from_length () -> None: + # step_duration = 1.5 / 6 = 0.25 beats per step (sixteenth notes) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected_pulses = [ + int(0.0 * ppq), # step 0 → beat 0.0 + int(0.75 * ppq), # step 3 → beat 0.75 + ] - """Without an explicit grid, a 6-sixteenth-note pattern should auto-derive grid=6.""" + assert sorted(pattern.steps.keys()) == expected_pulses - pattern, builder = _make_builder(length=1.5) # 6 * dur.SIXTEENTH = 1.5 beats - builder.hit_steps(60, steps=[0, 3], velocity=100) +def test_hit_steps_default_grid_from_unit() -> None: + """When default_grid is set explicitly (as the decorator does with unit), the grid matches.""" - # step_duration = 1.5 / 6 = 0.25 beats per step (sixteenth notes) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - expected_pulses = [ - int(0.0 * ppq), # step 0 → beat 0.0 - int(0.75 * ppq), # step 3 → beat 0.75 - ] + dur = subsequence.constants.durations + beat_length = 6 * dur.SIXTEENTH # 1.5 beats + pattern, builder = _make_builder(length=beat_length, default_grid=6) - assert sorted(pattern.steps.keys()) == expected_pulses + builder.hit_steps(60, steps=[0, 3], velocity=100) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected_pulses = [ + int(0.0 * ppq), + int(0.75 * ppq), + ] -def test_hit_steps_default_grid_from_unit () -> None: + assert sorted(pattern.steps.keys()) == expected_pulses - """When default_grid is set explicitly (as the decorator does with unit), the grid matches.""" - dur = subsequence.constants.durations - beat_length = 6 * dur.SIXTEENTH # 1.5 beats - pattern, builder = _make_builder(length=beat_length, default_grid=6) +def test_hit_steps_triplet_default_grid() -> None: + """A triplet-based pattern with explicit default_grid avoids the SIXTEENTH derivation bug.""" - builder.hit_steps(60, steps=[0, 3], velocity=100) + dur = subsequence.constants.durations + beat_length = 4 * dur.TRIPLET_EIGHTH # ~2.667 beats + pattern, builder = _make_builder(length=beat_length, default_grid=4) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - expected_pulses = [ - int(0.0 * ppq), - int(0.75 * ppq), - ] + builder.hit_steps(60, steps=[0, 2], velocity=100) - assert sorted(pattern.steps.keys()) == expected_pulses + ppq = subsequence.constants.MIDI_QUARTER_NOTE + step_dur = beat_length / 4 # one triplet eighth per step + expected_pulses = [ + int(0 * step_dur * ppq), + int(2 * step_dur * ppq), + ] + assert sorted(pattern.steps.keys()) == expected_pulses -def test_hit_steps_triplet_default_grid () -> None: - """A triplet-based pattern with explicit default_grid avoids the SIXTEENTH derivation bug.""" +def test_hit_steps_backbeat_positions() -> None: + """Steps 4 and 12 on a 16-step grid should place notes at beats 1 and 3.""" - dur = subsequence.constants.durations - beat_length = 4 * dur.TRIPLET_EIGHTH # ~2.667 beats - pattern, builder = _make_builder(length=beat_length, default_grid=4) + pattern, builder = _make_builder(length=4) - builder.hit_steps(60, steps=[0, 2], velocity=100) + builder.hit_steps(38, steps=[4, 12], velocity=100) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - step_dur = beat_length / 4 # one triplet eighth per step - expected_pulses = [ - int(0 * step_dur * ppq), - int(2 * step_dur * ppq), - ] + pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + pulse_3 = int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert sorted(pattern.steps.keys()) == expected_pulses - - -def test_hit_steps_backbeat_positions () -> None: - - """Steps 4 and 12 on a 16-step grid should place notes at beats 1 and 3.""" - - pattern, builder = _make_builder(length=4) - - builder.hit_steps(38, steps=[4, 12], velocity=100) - - pulse_1 = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - pulse_3 = int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE) - - assert sorted(pattern.steps.keys()) == [pulse_1, pulse_3] + assert sorted(pattern.steps.keys()) == [pulse_1, pulse_3] # --- Probability and RNG --- -def test_hit_steps_probability_one_places_all () -> None: - - """probability=1.0 should place all hits (default behaviour).""" - - pattern, builder = _make_builder(length=4) - - builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100, probability=1.0) +def test_hit_steps_probability_one_places_all() -> None: + """probability=1.0 should place all hits (default behaviour).""" - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + pattern, builder = _make_builder(length=4) - assert total_notes == 4 + builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100, probability=1.0) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) -def test_hit_steps_probability_zero_places_none () -> None: + assert total_notes == 4 - """probability=0.0 should place no hits.""" - pattern, builder = _make_builder(length=4) +def test_hit_steps_probability_zero_places_none() -> None: + """probability=0.0 should place no hits.""" - builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100, probability=0.0) + pattern, builder = _make_builder(length=4) - assert len(pattern.steps) == 0 + builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100, probability=0.0) + assert len(pattern.steps) == 0 -def test_hit_steps_probability_partial () -> None: - """Intermediate probability should place some but not all hits.""" +def test_hit_steps_probability_partial() -> None: + """Intermediate probability should place some but not all hits.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - rng = random.Random(42) - ) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, rng=random.Random(42) + ) - builder.hit_steps(60, steps=list(range(16)), velocity=100, probability=0.5) + builder.hit_steps(60, steps=list(range(16)), velocity=100, probability=0.5) - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert 0 < total_notes < 16 + assert 0 < total_notes < 16 -def test_euclidean_with_rng_deterministic () -> None: +def test_euclidean_with_rng_deterministic() -> None: + """Euclidean with dropout and a seeded rng should be deterministic.""" - """Euclidean with dropout and a seeded rng should be deterministic.""" + def build_with_seed(seed: int) -> set: + pattern = subsequence.pattern.Pattern(channel=0, length=4) - def build_with_seed (seed: int) -> set: + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, rng=random.Random(seed) + ) - pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder.euclidean(60, pulses=8, probability=0.7) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - rng = random.Random(seed) - ) + return set(pattern.steps.keys()) - builder.euclidean(60, pulses=8, probability=0.7) + run_1 = build_with_seed(42) + run_2 = build_with_seed(42) + run_3 = build_with_seed(99) - return set(pattern.steps.keys()) + assert run_1 == run_2 + assert run_1 != run_3 # different seed should (almost certainly) differ - run_1 = build_with_seed(42) - run_2 = build_with_seed(42) - run_3 = build_with_seed(99) - assert run_1 == run_2 - assert run_1 != run_3 # different seed should (almost certainly) differ +def test_bresenham_with_rng_deterministic() -> None: + """Bresenham with dropout and a seeded rng should be deterministic.""" + def build_with_seed(seed: int) -> set: + pattern = subsequence.pattern.Pattern(channel=0, length=4) -def test_bresenham_with_rng_deterministic () -> None: + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, rng=random.Random(seed) + ) - """Bresenham with dropout and a seeded rng should be deterministic.""" + builder.bresenham(60, pulses=8, probability=0.7) - def build_with_seed (seed: int) -> set: + return set(pattern.steps.keys()) - pattern = subsequence.pattern.Pattern(channel=0, length=4) + run_1 = build_with_seed(42) + run_2 = build_with_seed(42) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - rng = random.Random(seed) - ) + assert run_1 == run_2 - builder.bresenham(60, pulses=8, probability=0.7) - return set(pattern.steps.keys()) +def test_builder_rng_available() -> None: + """Builder should expose an rng attribute.""" - run_1 = build_with_seed(42) - run_2 = build_with_seed(42) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - assert run_1 == run_2 + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, rng=random.Random(42) + ) + assert isinstance(builder.rng, random.Random) -def test_builder_rng_available () -> None: + # Should produce deterministic values. + val = builder.rng.random() + expected = random.Random(42).random() - """Builder should expose an rng attribute.""" + assert val == expected - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - rng = random.Random(42) - ) +def test_builder_rng_default_unseeded() -> None: + """When no rng is provided, builder should create a fresh Random.""" - assert isinstance(builder.rng, random.Random) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - # Should produce deterministic values. - val = builder.rng.random() - expected = random.Random(42).random() + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) - assert val == expected + assert isinstance(builder.rng, random.Random) -def test_builder_rng_default_unseeded () -> None: +def test_dropout_uses_builder_rng() -> None: + """dropout() without explicit rng should use self.rng for determinism.""" - """When no rng is provided, builder should create a fresh Random.""" + def build_with_seed(seed: int) -> int: + pattern = subsequence.pattern.Pattern(channel=0, length=4) - pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, rng=random.Random(seed) + ) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) + builder.repeat(60, spacing=0.25, velocity=100) + builder.dropout(probability=0.5) - assert isinstance(builder.rng, random.Random) + return sum(len(step.notes) for step in pattern.steps.values()) + run_1 = build_with_seed(42) + run_2 = build_with_seed(42) -def test_dropout_uses_builder_rng () -> None: - - """dropout() without explicit rng should use self.rng for determinism.""" - - def build_with_seed (seed: int) -> int: - - pattern = subsequence.pattern.Pattern(channel=0, length=4) - - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - rng = random.Random(seed) - ) - - builder.repeat(60, spacing=0.25, velocity=100) - builder.dropout(probability=0.5) - - return sum(len(step.notes) for step in pattern.steps.values()) - - run_1 = build_with_seed(42) - run_2 = build_with_seed(42) - - assert run_1 == run_2 + assert run_1 == run_2 # --- p.grid property --- -def test_grid_property_default () -> None: - - """p.grid returns 16 for a standard 4-beat pattern.""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - default_grid = 16 - ) - - assert builder.grid == 16 +def test_grid_property_default() -> None: + """p.grid returns 16 for a standard 4-beat pattern.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, default_grid=16 + ) -def test_grid_property_custom () -> None: + assert builder.grid == 16 - """p.grid returns the grid set by a unit-based pattern.""" - pattern = subsequence.pattern.Pattern(channel=0, length=1.5) # 6 * SIXTEENTH - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - default_grid = 6 - ) +def test_grid_property_custom() -> None: + """p.grid returns the grid set by a unit-based pattern.""" - assert builder.grid == 6 + pattern = subsequence.pattern.Pattern(channel=0, length=1.5) # 6 * SIXTEENTH + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, default_grid=6 + ) + assert builder.grid == 6 -def test_float_length_hit_steps () -> None: +def test_float_length_hit_steps() -> None: + """hit_steps should work with a float pattern length.""" - """hit_steps should work with a float pattern length.""" + pattern = subsequence.pattern.Pattern(channel=0, length=10.5) - pattern = subsequence.pattern.Pattern(channel=0, length=10.5) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) + # 16-step grid over 10.5 beats: step_duration = 10.5/16 = 0.65625 + builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100) - # 16-step grid over 10.5 beats: step_duration = 10.5/16 = 0.65625 - builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + assert total_notes == 4 - assert total_notes == 4 +def test_float_length_repeat() -> None: + """fill should work with a float pattern length.""" -def test_float_length_repeat () -> None: + pattern = subsequence.pattern.Pattern(channel=0, length=3.5) - """fill should work with a float pattern length.""" + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) - pattern = subsequence.pattern.Pattern(channel=0, length=3.5) + # spacing=0.5 over 3.5 beats = 7 notes (0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0) + builder.repeat(60, spacing=0.5, velocity=100) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - # spacing=0.5 over 3.5 beats = 7 notes (0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0) - builder.repeat(60, spacing=0.5, velocity=100) + assert total_notes == 7 - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert total_notes == 7 +def test_float_length_euclidean() -> None: + """euclidean should work with a float pattern length.""" + pattern = subsequence.pattern.Pattern(channel=0, length=10.5) -def test_float_length_euclidean () -> None: + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) - """euclidean should work with a float pattern length.""" + # int(10.5 * 4) = 42 steps, 5 pulses + builder.euclidean(60, pulses=5, velocity=100) - pattern = subsequence.pattern.Pattern(channel=0, length=10.5) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) + assert total_notes == 5 - # int(10.5 * 4) = 42 steps, 5 pulses - builder.euclidean(60, pulses=5, velocity=100) - total_notes = sum(len(step.notes) for step in pattern.steps.values()) +def test_float_length_bresenham() -> None: + """bresenham should work with a float pattern length.""" - assert total_notes == 5 + pattern = subsequence.pattern.Pattern(channel=0, length=5.5) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) -def test_float_length_bresenham () -> None: + # int(5.5 * 4) = 22 steps, 3 pulses + builder.bresenham(60, pulses=3, velocity=100) - """bresenham should work with a float pattern length.""" + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - pattern = subsequence.pattern.Pattern(channel=0, length=5.5) + assert total_notes == 3 - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) - # int(5.5 * 4) = 22 steps, 3 pulses - builder.bresenham(60, pulses=3, velocity=100) +def test_set_length_updates_pattern() -> None: + """set_length should change the underlying pattern's length.""" - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - assert total_notes == 3 + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) + assert pattern.length == 4 -def test_set_length_updates_pattern () -> None: + builder.set_length(8) - """set_length should change the underlying pattern's length.""" + assert pattern.length == 8 - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) +def test_set_length_affects_repeat() -> None: + """After set_length, fill should use the new length.""" - assert pattern.length == 4 + pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder.set_length(8) + builder = subsequence.pattern_builder.PatternBuilder(pattern=pattern, cycle=0) - assert pattern.length == 8 + builder.set_length(2) + builder.repeat(60, spacing=0.5, velocity=100) + # 2 beats / 0.5 step = 4 notes + total_notes = sum(len(step.notes) for step in pattern.steps.values()) -def test_set_length_affects_repeat () -> None: - - """After set_length, fill should use the new length.""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4) - - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0 - ) - - builder.set_length(2) - builder.repeat(60, spacing=0.5, velocity=100) - - # 2 beats / 0.5 step = 4 notes - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - - assert total_notes == 4 + assert total_notes == 4 # --- Pattern Transforms --- -def test_reverse_is_a_grid_preserving_retrograde () -> None: - - """reverse() reflects onsets around the bar, keeping them on the grid. - - A true retrograde reverses the inter-onset intervals with the downbeat - fixed, so an asymmetric figure flips order without drifting off-grid. - """ - - pattern, builder = _make_builder(length=4) - - # Beats 0 and 1 (pulses 0, 24) — asymmetric, so order genuinely changes. - builder.hit(60, beats=[0, 1], velocity=100) +def test_reverse_is_a_grid_preserving_retrograde() -> None: + """reverse() reflects onsets around the bar, keeping them on the grid. - builder.reverse() + A true retrograde reverses the inter-onset intervals with the downbeat + fixed, so an asymmetric figure flips order without drifting off-grid. + """ - # pulse 0 → 0 (downbeat fixed), pulse 24 → 72 — both still on the 24-pulse grid. - assert sorted(pattern.steps.keys()) == [0, 72] + pattern, builder = _make_builder(length=4) + # Beats 0 and 1 (pulses 0, 24) — asymmetric, so order genuinely changes. + builder.hit(60, beats=[0, 1], velocity=100) -def test_reverse_of_a_symmetric_pattern_is_itself () -> None: + builder.reverse() - """Four-on-the-floor is its own retrograde — and stays on the grid.""" + # pulse 0 → 0 (downbeat fixed), pulse 24 → 72 — both still on the 24-pulse grid. + assert sorted(pattern.steps.keys()) == [0, 72] - pattern, builder = _make_builder(length=4) - builder.hit(60, beats=[0, 1, 2, 3], velocity=100) +def test_reverse_of_a_symmetric_pattern_is_itself() -> None: + """Four-on-the-floor is its own retrograde — and stays on the grid.""" - builder.reverse() + pattern, builder = _make_builder(length=4) - assert sorted(pattern.steps.keys()) == [0, 24, 48, 72] + builder.hit(60, beats=[0, 1, 2, 3], velocity=100) + builder.reverse() -def test_reverse_empty_pattern () -> None: + assert sorted(pattern.steps.keys()) == [0, 24, 48, 72] - """reverse() on an empty pattern should be a no-op.""" - pattern, builder = _make_builder(length=4) +def test_reverse_empty_pattern() -> None: + """reverse() on an empty pattern should be a no-op.""" - builder.reverse() + pattern, builder = _make_builder(length=4) - assert len(pattern.steps) == 0 + builder.reverse() + assert len(pattern.steps) == 0 -def test_stretch_compress_halves_positions () -> None: - """stretch(0.5) should compress notes into the first half with halved durations.""" +def test_stretch_compress_halves_positions() -> None: + """stretch(0.5) should compress notes into the first half with halved durations.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - # Place notes at beats 0, 1, 2, 3 (pulses 0, 24, 48, 72). - builder.hit(60, beats=[0, 1, 2, 3], velocity=100, duration=0.5) + # Place notes at beats 0, 1, 2, 3 (pulses 0, 24, 48, 72). + builder.hit(60, beats=[0, 1, 2, 3], velocity=100, duration=0.5) - builder.stretch(0.5) + builder.stretch(0.5) - positions = sorted(pattern.steps.keys()) + positions = sorted(pattern.steps.keys()) - # Positions should be halved: 0, 12, 24, 36. - assert positions == [0, 12, 24, 36] + # Positions should be halved: 0, 12, 24, 36. + assert positions == [0, 12, 24, 36] - # Durations should be halved too. - original_duration = int(0.5 * subsequence.constants.MIDI_QUARTER_NOTE) # 12 + # Durations should be halved too. + original_duration = int(0.5 * subsequence.constants.MIDI_QUARTER_NOTE) # 12 - for pos in positions: - for note in pattern.steps[pos].notes: - assert note.duration == original_duration // 2 + for pos in positions: + for note in pattern.steps[pos].notes: + assert note.duration == original_duration // 2 -def test_stretch_expand_doubles_positions () -> None: +def test_stretch_expand_doubles_positions() -> None: + """stretch(2.0) should expand notes and drop those that exceed the pattern boundary.""" - """stretch(2.0) should expand notes and drop those that exceed the pattern boundary.""" + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder(length=4) + # Place notes at beats 0, 1, 2, 3 (pulses 0, 24, 48, 72). + builder.hit(60, beats=[0, 1, 2, 3], velocity=100, duration=0.25) - # Place notes at beats 0, 1, 2, 3 (pulses 0, 24, 48, 72). - builder.hit(60, beats=[0, 1, 2, 3], velocity=100, duration=0.25) + builder.stretch(2.0) - builder.stretch(2.0) + total_pulses = int(4 * subsequence.constants.MIDI_QUARTER_NOTE) # 96 + positions = sorted(pattern.steps.keys()) - total_pulses = int(4 * subsequence.constants.MIDI_QUARTER_NOTE) # 96 - positions = sorted(pattern.steps.keys()) + # Doubled: 0, 48, 96, 144 - but 96 and 144 >= total_pulses, so dropped. + assert positions == [0, 48] - # Doubled: 0, 48, 96, 144 - but 96 and 144 >= total_pulses, so dropped. - assert positions == [0, 48] + # Durations should be doubled. + original_duration = int(0.25 * subsequence.constants.MIDI_QUARTER_NOTE) # 6 - # Durations should be doubled. - original_duration = int(0.25 * subsequence.constants.MIDI_QUARTER_NOTE) # 6 + for pos in positions: + for note in pattern.steps[pos].notes: + assert note.duration == original_duration * 2 - for pos in positions: - for note in pattern.steps[pos].notes: - assert note.duration == original_duration * 2 +def test_stretch_fractional_factor() -> None: + """A non-×2 factor places notes at proportionally scaled positions.""" -def test_stretch_fractional_factor () -> None: + pattern, builder = _make_builder(length=4) - """A non-×2 factor places notes at proportionally scaled positions.""" + # Notes at beats 0 and 2 (pulses 0 and 48). + builder.hit(60, beats=[0, 2], velocity=100, duration=0.5) - pattern, builder = _make_builder(length=4) + builder.stretch(1.5) - # Notes at beats 0 and 2 (pulses 0 and 48). - builder.hit(60, beats=[0, 2], velocity=100, duration=0.5) + # 0 stays, 48 × 1.5 = 72. + assert sorted(pattern.steps.keys()) == [0, 72] - builder.stretch(1.5) - # 0 stays, 48 × 1.5 = 72. - assert sorted(pattern.steps.keys()) == [0, 72] +def test_stretch_invalid_factor_raises() -> None: + """Zero or negative stretch factors raise ValueError.""" + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0) -def test_stretch_invalid_factor_raises () -> None: + with pytest.raises(ValueError): + builder.stretch(0) - """Zero or negative stretch factors raise ValueError.""" + with pytest.raises(ValueError): + builder.stretch(-1.0) - pattern, builder = _make_builder(length=4) - builder.note(60, beat=0) - with pytest.raises(ValueError): - builder.stretch(0) +def test_stretch_preserves_drum_origin() -> None: + """stretch() must keep Note.origin so mirror destinations can re-resolve drum names.""" - with pytest.raises(ValueError): - builder.stretch(-1.0) + pattern, builder = _make_builder(length=4) + builder.note(60, beat=0, velocity=100) -def test_stretch_preserves_drum_origin () -> None: + for step in pattern.steps.values(): + for note in step.notes: + note.origin = "kick_1" - """stretch() must keep Note.origin so mirror destinations can re-resolve drum names.""" + builder.stretch(2.0) - pattern, builder = _make_builder(length=4) + assert pattern.steps[0].notes[0].origin == "kick_1" - builder.note(60, beat=0, velocity=100) - for step in pattern.steps.values(): - for note in step.notes: - note.origin = "kick_1" +def test_rotate_wraps_around() -> None: + """rotate() should move note positions and wrap past the end back to the start.""" - builder.stretch(2.0) + pattern, builder = _make_builder(length=4) - assert pattern.steps[0].notes[0].origin == "kick_1" + # Place notes at steps 0, 4, 8, 12 on a 16-step grid. + builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100) + # Rotate by 4 steps = 1 beat = 24 pulses. + builder.rotate(4) -def test_rotate_wraps_around () -> None: + positions = sorted(pattern.steps.keys()) - """rotate() should move note positions and wrap past the end back to the start.""" + # Original: 0, 24, 48, 72 → rotated: 24, 48, 72, 0 (96 wraps to 0). + assert positions == [0, 24, 48, 72] - pattern, builder = _make_builder(length=4) - # Place notes at steps 0, 4, 8, 12 on a 16-step grid. - builder.hit_steps(60, steps=[0, 4, 8, 12], velocity=100) +def test_rotate_negative() -> None: + """Negative rotation should move notes earlier, wrapping from start to end.""" - # Rotate by 4 steps = 1 beat = 24 pulses. - builder.rotate(4) + pattern, builder = _make_builder(length=4) - positions = sorted(pattern.steps.keys()) + # Place a single note at beat 0 (pulse 0). + builder.note(60, beat=0, velocity=100) - # Original: 0, 24, 48, 72 → rotated: 24, 48, 72, 0 (96 wraps to 0). - assert positions == [0, 24, 48, 72] + # Rotate by -4 steps = -24 pulses → wraps to 72. + builder.rotate(-4) + assert 72 in pattern.steps + assert 0 not in pattern.steps -def test_rotate_negative () -> None: - """Negative rotation should move notes earlier, wrapping from start to end.""" +def test_transpose_shifts_pitches() -> None: + """transpose() should shift all pitches by the given number of semitones.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - # Place a single note at beat 0 (pulse 0). - builder.note(60, beat=0, velocity=100) + builder.note(60, beat=0, velocity=100) + builder.note(64, beat=1, velocity=100) + builder.note(67, beat=2, velocity=100) - # Rotate by -4 steps = -24 pulses → wraps to 72. - builder.rotate(-4) + builder.transpose(12) - assert 72 in pattern.steps - assert 0 not in pattern.steps + pitches = sorted( + note.pitch for step in pattern.steps.values() for note in step.notes + ) + assert pitches == [72, 76, 79] -def test_transpose_shifts_pitches () -> None: - """transpose() should shift all pitches by the given number of semitones.""" +def test_transpose_clamps_to_midi_range() -> None: + """transpose() should clamp pitches to 0-127.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - builder.note(60, beat=0, velocity=100) - builder.note(64, beat=1, velocity=100) - builder.note(67, beat=2, velocity=100) + # +20 would take 120 → 140, but should clamp to 127. + builder.note(120, beat=0, velocity=100) + builder.transpose(20) - builder.transpose(12) + assert pattern.steps[0].notes[0].pitch == 127 - pitches = sorted( - note.pitch - for step in pattern.steps.values() - for note in step.notes - ) + # Test downward clamping separately. + pattern2, builder2 = _make_builder(length=4) + builder2.note(5, beat=0, velocity=100) + builder2.transpose(-20) - assert pitches == [72, 76, 79] + assert pattern2.steps[0].notes[0].pitch == 0 -def test_transpose_clamps_to_midi_range () -> None: +def test_transpose_negative() -> None: + """transpose() with negative semitones should shift down.""" - """transpose() should clamp pitches to 0-127.""" + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder(length=4) + builder.note(72, beat=0, velocity=100) - # +20 would take 120 → 140, but should clamp to 127. - builder.note(120, beat=0, velocity=100) - builder.transpose(20) + builder.transpose(-12) - assert pattern.steps[0].notes[0].pitch == 127 + assert pattern.steps[0].notes[0].pitch == 60 - # Test downward clamping separately. - pattern2, builder2 = _make_builder(length=4) - builder2.note(5, beat=0, velocity=100) - builder2.transpose(-20) - assert pattern2.steps[0].notes[0].pitch == 0 +def test_invert_mirrors_around_pivot() -> None: + """invert() should mirror pitches around the pivot note.""" + pattern, builder = _make_builder(length=4) -def test_transpose_negative () -> None: + # C=60, E=64, G=67 + builder.note(60, beat=0, velocity=100) + builder.note(64, beat=1, velocity=100) + builder.note(67, beat=2, velocity=100) - """transpose() with negative semitones should shift down.""" + # Invert around E (64): 60→68, 64→64, 67→61 + builder.invert(pivot=64) - pattern, builder = _make_builder(length=4) + pitches = [] - builder.note(72, beat=0, velocity=100) + for pos in sorted(pattern.steps.keys()): + pitches.append(pattern.steps[pos].notes[0].pitch) - builder.transpose(-12) + assert pitches == [68, 64, 61] - assert pattern.steps[0].notes[0].pitch == 60 +def test_invert_clamps_to_midi_range() -> None: + """invert() should clamp pitches to 0-127.""" -def test_invert_mirrors_around_pivot () -> None: + pattern, builder = _make_builder(length=4) - """invert() should mirror pitches around the pivot note.""" + builder.note(10, beat=0, velocity=100) - pattern, builder = _make_builder(length=4) + # Invert around 120: 120 + (120 - 10) = 230 → clamp to 127. + builder.invert(pivot=120) - # C=60, E=64, G=67 - builder.note(60, beat=0, velocity=100) - builder.note(64, beat=1, velocity=100) - builder.note(67, beat=2, velocity=100) + assert pattern.steps[0].notes[0].pitch == 127 - # Invert around E (64): 60→68, 64→64, 67→61 - builder.invert(pivot=64) - pitches = [] +def test_every_fires_on_matching_cycle() -> None: + """every() should apply the transform when cycle is a multiple of n.""" - for pos in sorted(pattern.steps.keys()): - pitches.append(pattern.steps[pos].notes[0].pitch) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - assert pitches == [68, 64, 61] + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=4, + ) + builder.note(60, beat=0, velocity=100) -def test_invert_clamps_to_midi_range () -> None: + # Cycle 4 % 4 == 0, so this should fire. + builder.every(4, lambda p: p.transpose(12)) - """invert() should clamp pitches to 0-127.""" + assert pattern.steps[0].notes[0].pitch == 72 - pattern, builder = _make_builder(length=4) - builder.note(10, beat=0, velocity=100) +def test_every_skips_non_matching_cycle() -> None: + """every() should skip the transform when cycle is not a multiple of n.""" - # Invert around 120: 120 + (120 - 10) = 230 → clamp to 127. - builder.invert(pivot=120) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - assert pattern.steps[0].notes[0].pitch == 127 + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=3, + ) + builder.note(60, beat=0, velocity=100) -def test_every_fires_on_matching_cycle () -> None: + # Cycle 3 % 4 != 0, so this should NOT fire. + builder.every(4, lambda p: p.transpose(12)) - """every() should apply the transform when cycle is a multiple of n.""" + assert pattern.steps[0].notes[0].pitch == 60 - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 4, - ) +def test_every_fires_on_cycle_zero() -> None: + """every() should fire on cycle 0 (the first cycle).""" - builder.note(60, beat=0, velocity=100) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - # Cycle 4 % 4 == 0, so this should fire. - builder.every(4, lambda p: p.transpose(12)) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + ) - assert pattern.steps[0].notes[0].pitch == 72 + builder.note( + 60, beat=1, velocity=100 + ) # pulse 24 — moves under reverse (0 is the fixed point) + builder.every(8, lambda p: p.reverse()) -def test_every_skips_non_matching_cycle () -> None: - - """every() should skip the transform when cycle is not a multiple of n.""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4) - - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 3, - ) - - builder.note(60, beat=0, velocity=100) - - # Cycle 3 % 4 != 0, so this should NOT fire. - builder.every(4, lambda p: p.transpose(12)) - - assert pattern.steps[0].notes[0].pitch == 60 - - -def test_every_fires_on_cycle_zero () -> None: - - """every() should fire on cycle 0 (the first cycle).""" - - pattern = subsequence.pattern.Pattern(channel=0, length=4) - - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - ) - - builder.note(60, beat=1, velocity=100) # pulse 24 — moves under reverse (0 is the fixed point) - - builder.every(8, lambda p: p.reverse()) - - # Reverse should have fired — the grid-preserving retrograde sends pulse 24 to 72. - assert 72 in pattern.steps - assert 24 not in pattern.steps + # Reverse should have fired — the grid-preserving retrograde sends pulse 24 to 72. + assert 72 in pattern.steps + assert 24 not in pattern.steps # --- sequence() --- -def test_sequence_places_per_step_pitches () -> None: - - """sequence() should place different pitches at each step position.""" - - pattern, builder = _make_builder(length=4) - - builder.sequence( - steps=[0, 4, 8, 12], - pitches=[60, 64, 67, 72], - ) - - ppq = subsequence.constants.MIDI_QUARTER_NOTE - expected = {0: 60, ppq: 64, ppq * 2: 67, ppq * 3: 72} - - for pulse, expected_pitch in expected.items(): - assert pulse in pattern.steps - assert pattern.steps[pulse].notes[0].pitch == expected_pitch - - -def test_sequence_scalar_pitch_expands () -> None: +def test_sequence_places_per_step_pitches() -> None: + """sequence() should place different pitches at each step position.""" - """A single int pitch should be applied to all steps.""" + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder(length=4) + builder.sequence( + steps=[0, 4, 8, 12], + pitches=[60, 64, 67, 72], + ) - builder.sequence([0, 4, 8, 12], pitches=60) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected = {0: 60, ppq: 64, ppq * 2: 67, ppq * 3: 72} - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + for pulse, expected_pitch in expected.items(): + assert pulse in pattern.steps + assert pattern.steps[pulse].notes[0].pitch == expected_pitch - assert total_notes == 4 - for step in pattern.steps.values(): - assert step.notes[0].pitch == 60 +def test_sequence_scalar_pitch_expands() -> None: + """A single int pitch should be applied to all steps.""" + pattern, builder = _make_builder(length=4) -def test_sequence_scalar_velocity_expands () -> None: + builder.sequence([0, 4, 8, 12], pitches=60) - """Default velocity=100 should apply to all steps.""" + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - pattern, builder = _make_builder(length=4) + assert total_notes == 4 - builder.sequence([0, 4, 8, 12], pitches=[60, 64, 67, 72]) + for step in pattern.steps.values(): + assert step.notes[0].pitch == 60 - for step in pattern.steps.values(): - assert step.notes[0].velocity == 100 +def test_sequence_scalar_velocity_expands() -> None: + """Default velocity=100 should apply to all steps.""" -def test_sequence_scalar_duration_expands () -> None: + pattern, builder = _make_builder(length=4) - """Default duration=0.1 should apply to all steps.""" + builder.sequence([0, 4, 8, 12], pitches=[60, 64, 67, 72]) - pattern, builder = _make_builder(length=4) + for step in pattern.steps.values(): + assert step.notes[0].velocity == 100 - builder.sequence([0, 4, 8, 12], pitches=[60, 64, 67, 72]) - expected_dur = int(0.1 * subsequence.constants.MIDI_QUARTER_NOTE) +def test_sequence_scalar_duration_expands() -> None: + """Default duration=0.1 should apply to all steps.""" - for step in pattern.steps.values(): - assert step.notes[0].duration == expected_dur + pattern, builder = _make_builder(length=4) + builder.sequence([0, 4, 8, 12], pitches=[60, 64, 67, 72]) -def test_sequence_per_step_velocities () -> None: + expected_dur = int(0.1 * subsequence.constants.MIDI_QUARTER_NOTE) - """Per-step velocity lists should set different velocities at each position.""" + for step in pattern.steps.values(): + assert step.notes[0].duration == expected_dur - pattern, builder = _make_builder(length=4) - builder.sequence( - steps=[0, 4, 8, 12], - pitches=60, - velocities=[127, 90, 110, 80], - ) +def test_sequence_per_step_velocities() -> None: + """Per-step velocity lists should set different velocities at each position.""" - ppq = subsequence.constants.MIDI_QUARTER_NOTE - expected = {0: 127, ppq: 90, ppq * 2: 110, ppq * 3: 80} + pattern, builder = _make_builder(length=4) - for pulse, expected_vel in expected.items(): - assert pattern.steps[pulse].notes[0].velocity == expected_vel + builder.sequence( + steps=[0, 4, 8, 12], + pitches=60, + velocities=[127, 90, 110, 80], + ) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected = {0: 127, ppq: 90, ppq * 2: 110, ppq * 3: 80} -def test_sequence_list_longer_truncates () -> None: + for pulse, expected_vel in expected.items(): + assert pattern.steps[pulse].notes[0].velocity == expected_vel - """A pitches list longer than steps should be truncated.""" - pattern, builder = _make_builder(length=4) +def test_sequence_list_longer_truncates() -> None: + """A pitches list longer than steps should be truncated.""" - # 2 steps but 4 pitches - extra values should be ignored. - builder.sequence([0, 4], pitches=[60, 64, 67, 72]) + pattern, builder = _make_builder(length=4) - total_notes = sum(len(step.notes) for step in pattern.steps.values()) + # 2 steps but 4 pitches - extra values should be ignored. + builder.sequence([0, 4], pitches=[60, 64, 67, 72]) - assert total_notes == 2 + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - assert pattern.steps[0].notes[0].pitch == 60 - assert pattern.steps[ppq].notes[0].pitch == 64 + assert total_notes == 2 + ppq = subsequence.constants.MIDI_QUARTER_NOTE + assert pattern.steps[0].notes[0].pitch == 60 + assert pattern.steps[ppq].notes[0].pitch == 64 -def test_sequence_list_shorter_repeats_last () -> None: - """A pitches list shorter than steps should repeat the last value.""" +def test_sequence_list_shorter_repeats_last() -> None: + """A pitches list shorter than steps should repeat the last value.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - # 4 steps but only 2 pitches - last value (64) fills remaining. - builder.sequence([0, 4, 8, 12], pitches=[60, 64]) + # 4 steps but only 2 pitches - last value (64) fills remaining. + builder.sequence([0, 4, 8, 12], pitches=[60, 64]) - ppq = subsequence.constants.MIDI_QUARTER_NOTE + ppq = subsequence.constants.MIDI_QUARTER_NOTE - assert pattern.steps[0].notes[0].pitch == 60 - assert pattern.steps[ppq].notes[0].pitch == 64 - assert pattern.steps[ppq * 2].notes[0].pitch == 64 - assert pattern.steps[ppq * 3].notes[0].pitch == 64 + assert pattern.steps[0].notes[0].pitch == 60 + assert pattern.steps[ppq].notes[0].pitch == 64 + assert pattern.steps[ppq * 2].notes[0].pitch == 64 + assert pattern.steps[ppq * 3].notes[0].pitch == 64 -def test_sequence_empty_steps_is_noop () -> None: +def test_sequence_empty_steps_is_noop() -> None: + """An empty steps list is a no-op: nothing is placed and the builder is returned.""" - """An empty steps list is a no-op: nothing is placed and the builder is returned.""" + pattern, builder = _make_builder() - pattern, builder = _make_builder() + assert builder.sequence([], pitches=60) is builder + assert pattern.steps == {} - assert builder.sequence([], pitches=60) is builder - assert pattern.steps == {} +def test_sequence_empty_pitches_list_raises() -> None: + """An empty pitches list should raise ValueError.""" -def test_sequence_empty_pitches_list_raises () -> None: + pattern, builder = _make_builder() - """An empty pitches list should raise ValueError.""" + with pytest.raises(ValueError, match="pitches list cannot be empty"): + builder.sequence([0, 4], pitches=[]) - pattern, builder = _make_builder() - with pytest.raises(ValueError, match="pitches list cannot be empty"): - builder.sequence([0, 4], pitches=[]) +def test_sequence_custom_grid() -> None: + """grid=8 should map steps to correct beat positions.""" + pattern, builder = _make_builder(length=4) -def test_sequence_custom_grid () -> None: + # grid=8 over 4 beats = 0.5 beats per step. + builder.sequence([0, 2, 4, 6], pitches=60, grid=8) - """grid=8 should map steps to correct beat positions.""" + ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected_pulses = [0, ppq, ppq * 2, ppq * 3] - pattern, builder = _make_builder(length=4) + assert sorted(pattern.steps.keys()) == expected_pulses - # grid=8 over 4 beats = 0.5 beats per step. - builder.sequence([0, 2, 4, 6], pitches=60, grid=8) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - expected_pulses = [0, ppq, ppq * 2, ppq * 3] +def test_sequence_default_grid_from_length() -> None: + """Without an explicit grid, a 6-sixteenth-note pattern should auto-derive grid=6.""" - assert sorted(pattern.steps.keys()) == expected_pulses + pattern, builder = _make_builder(length=1.5) # 6 * dur.SIXTEENTH = 1.5 beats + builder.sequence([0, 3, 5], pitches=60) -def test_sequence_default_grid_from_length () -> None: + # step_duration = 1.5 / 6 = 0.25 beats per step (sixteenth notes) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected_pulses = [ + int(0.0 * ppq), # step 0 → beat 0.0 + int(0.75 * ppq), # step 3 → beat 0.75 + int(1.25 * ppq), # step 5 → beat 1.25 + ] - """Without an explicit grid, a 6-sixteenth-note pattern should auto-derive grid=6.""" + assert sorted(pattern.steps.keys()) == expected_pulses - pattern, builder = _make_builder(length=1.5) # 6 * dur.SIXTEENTH = 1.5 beats - builder.sequence([0, 3, 5], pitches=60) +def test_sequence_default_grid_from_unit() -> None: + """When default_grid is set explicitly (as the decorator does with unit), the grid matches.""" - # step_duration = 1.5 / 6 = 0.25 beats per step (sixteenth notes) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - expected_pulses = [ - int(0.0 * ppq), # step 0 → beat 0.0 - int(0.75 * ppq), # step 3 → beat 0.75 - int(1.25 * ppq), # step 5 → beat 1.25 - ] + dur = subsequence.constants.durations + beat_length = 6 * dur.SIXTEENTH # 1.5 beats + pattern, builder = _make_builder(length=beat_length, default_grid=6) - assert sorted(pattern.steps.keys()) == expected_pulses + builder.sequence([0, 3, 5], pitches=60) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected_pulses = [ + int(0.0 * ppq), + int(0.75 * ppq), + int(1.25 * ppq), + ] -def test_sequence_default_grid_from_unit () -> None: + assert sorted(pattern.steps.keys()) == expected_pulses - """When default_grid is set explicitly (as the decorator does with unit), the grid matches.""" - dur = subsequence.constants.durations - beat_length = 6 * dur.SIXTEENTH # 1.5 beats - pattern, builder = _make_builder(length=beat_length, default_grid=6) +def test_sequence_triplet_default_grid() -> None: + """A triplet-based pattern with explicit default_grid avoids the SIXTEENTH derivation bug.""" - builder.sequence([0, 3, 5], pitches=60) + dur = subsequence.constants.durations + beat_length = 4 * dur.TRIPLET_EIGHTH # ~2.667 beats + pattern, builder = _make_builder(length=beat_length, default_grid=4) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - expected_pulses = [ - int(0.0 * ppq), - int(0.75 * ppq), - int(1.25 * ppq), - ] + builder.sequence([0, 1, 2, 3], pitches=60) - assert sorted(pattern.steps.keys()) == expected_pulses + ppq = subsequence.constants.MIDI_QUARTER_NOTE + step_dur = beat_length / 4 + expected_pulses = [int(i * step_dur * ppq) for i in range(4)] + assert sorted(pattern.steps.keys()) == expected_pulses -def test_sequence_triplet_default_grid () -> None: - """A triplet-based pattern with explicit default_grid avoids the SIXTEENTH derivation bug.""" +def test_sequence_probability_zero_places_none() -> None: + """probability=0.0 should place no notes.""" - dur = subsequence.constants.durations - beat_length = 4 * dur.TRIPLET_EIGHTH # ~2.667 beats - pattern, builder = _make_builder(length=beat_length, default_grid=4) + pattern, builder = _make_builder(length=4) - builder.sequence([0, 1, 2, 3], pitches=60) + builder.sequence([0, 4, 8, 12], pitches=60, probability=0.0) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - step_dur = beat_length / 4 - expected_pulses = [int(i * step_dur * ppq) for i in range(4)] + assert len(pattern.steps) == 0 - assert sorted(pattern.steps.keys()) == expected_pulses +def test_sequence_probability_one_places_all() -> None: + """probability=1.0 should place all notes.""" -def test_sequence_probability_zero_places_none () -> None: + pattern, builder = _make_builder(length=4) - """probability=0.0 should place no notes.""" + builder.sequence([0, 4, 8, 12], pitches=60, probability=1.0) - pattern, builder = _make_builder(length=4) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - builder.sequence([0, 4, 8, 12], pitches=60, probability=0.0) + assert total_notes == 4 - assert len(pattern.steps) == 0 +def test_sequence_with_drum_names() -> None: + """String pitches should resolve via the drum note map.""" -def test_sequence_probability_one_places_all () -> None: + drum_map = {"kick": 36, "snare": 38} + pattern, builder = _make_builder(drum_note_map=drum_map) - """probability=1.0 should place all notes.""" + builder.sequence([0, 4, 8, 12], pitches=["kick", "snare", "kick", "snare"]) - pattern, builder = _make_builder(length=4) + ppq = subsequence.constants.MIDI_QUARTER_NOTE - builder.sequence([0, 4, 8, 12], pitches=60, probability=1.0) + assert pattern.steps[0].notes[0].pitch == 36 + assert pattern.steps[ppq].notes[0].pitch == 38 + assert pattern.steps[ppq * 2].notes[0].pitch == 36 + assert pattern.steps[ppq * 3].notes[0].pitch == 38 - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert total_notes == 4 +def test_sequence_truncation_logs_warning(caplog) -> None: + """Truncating a longer list should log a warning.""" + import logging -def test_sequence_with_drum_names () -> None: + pattern, builder = _make_builder(length=4) - """String pitches should resolve via the drum note map.""" + with caplog.at_level(logging.WARNING, logger="subsequence.pattern_builder"): + builder.sequence([0, 4], pitches=[60, 64, 67, 72]) - drum_map = {"kick": 36, "snare": 38} - pattern, builder = _make_builder(drum_note_map=drum_map) + assert "truncating" in caplog.text - builder.sequence([0, 4, 8, 12], pitches=["kick", "snare", "kick", "snare"]) - ppq = subsequence.constants.MIDI_QUARTER_NOTE +def test_sequence_repeat_logs_warning(caplog) -> None: + """Repeating the last value for a shorter list should log a warning.""" - assert pattern.steps[0].notes[0].pitch == 36 - assert pattern.steps[ppq].notes[0].pitch == 38 - assert pattern.steps[ppq * 2].notes[0].pitch == 36 - assert pattern.steps[ppq * 3].notes[0].pitch == 38 + import logging + pattern, builder = _make_builder(length=4) -def test_sequence_truncation_logs_warning (caplog) -> None: + with caplog.at_level(logging.WARNING, logger="subsequence.pattern_builder"): + builder.sequence([0, 4, 8, 12], pitches=[60, 64]) - """Truncating a longer list should log a warning.""" - - import logging - - pattern, builder = _make_builder(length=4) - - with caplog.at_level(logging.WARNING, logger="subsequence.pattern_builder"): - builder.sequence([0, 4], pitches=[60, 64, 67, 72]) - - assert "truncating" in caplog.text - - -def test_sequence_repeat_logs_warning (caplog) -> None: - - """Repeating the last value for a shorter list should log a warning.""" - - import logging - - pattern, builder = _make_builder(length=4) - - with caplog.at_level(logging.WARNING, logger="subsequence.pattern_builder"): - builder.sequence([0, 4, 8, 12], pitches=[60, 64]) - - assert "repeating last value" in caplog.text + assert "repeating last value" in caplog.text # --- Strum --- -def test_strum_places_notes_with_offset () -> None: +def test_strum_places_notes_with_offset() -> None: + """Strum with spacing=0.1 should place notes at beats 0.0, 0.1, 0.2.""" - """Strum with spacing=0.1 should place notes at beats 0.0, 0.1, 0.2.""" + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=4, quality="major") - chord = subsequence.chords.Chord(root_pc=4, quality="major") + builder.strum(chord, root=52, velocity=90, spacing=0.1) - builder.strum(chord, root=52, velocity=90, spacing=0.1) + ppq = subsequence.constants.MIDI_QUARTER_NOTE - ppq = subsequence.constants.MIDI_QUARTER_NOTE + expected_pulses = [ + int(0.0 * ppq), + int(0.1 * ppq), + int(0.2 * ppq), + ] - expected_pulses = [ - int(0.0 * ppq), - int(0.1 * ppq), - int(0.2 * ppq), - ] + positions = sorted(pattern.steps.keys()) - positions = sorted(pattern.steps.keys()) + assert positions == expected_pulses + assert len(positions) == 3 - assert positions == expected_pulses - assert len(positions) == 3 + # All notes present. + pitches = sorted( + note.pitch for pos in positions for note in pattern.steps[pos].notes + ) - # All notes present. - pitches = sorted( - note.pitch - for pos in positions - for note in pattern.steps[pos].notes - ) + assert pitches == [52, 56, 59] - assert pitches == [52, 56, 59] +def test_strum_direction_down() -> None: + """direction='down' should reverse pitch order (highest first at beat 0).""" -def test_strum_direction_down () -> None: + pattern, builder = _make_builder(length=4) - """direction='down' should reverse pitch order (highest first at beat 0).""" + chord = subsequence.chords.Chord(root_pc=4, quality="major") - pattern, builder = _make_builder(length=4) + builder.strum(chord, root=52, velocity=90, spacing=0.1, direction="down") - chord = subsequence.chords.Chord(root_pc=4, quality="major") + positions = sorted(pattern.steps.keys()) - builder.strum(chord, root=52, velocity=90, spacing=0.1, direction="down") + # First note (beat 0) should be the highest pitch. + first_pitch = pattern.steps[positions[0]].notes[0].pitch + last_pitch = pattern.steps[positions[-1]].notes[0].pitch - positions = sorted(pattern.steps.keys()) + assert first_pitch > last_pitch - # First note (beat 0) should be the highest pitch. - first_pitch = pattern.steps[positions[0]].notes[0].pitch - last_pitch = pattern.steps[positions[-1]].notes[0].pitch - assert first_pitch > last_pitch +def test_strum_with_count() -> None: + """count=5 should produce 5 notes with extended octave tones.""" + pattern, builder = _make_builder(length=4) -def test_strum_with_count () -> None: + chord = subsequence.chords.Chord(root_pc=0, quality="major") - """count=5 should produce 5 notes with extended octave tones.""" + builder.strum(chord, root=60, velocity=90, spacing=0.1, count=5) - pattern, builder = _make_builder(length=4) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) - chord = subsequence.chords.Chord(root_pc=0, quality="major") + assert total_notes == 5 - builder.strum(chord, root=60, velocity=90, spacing=0.1, count=5) - total_notes = sum(len(step.notes) for step in pattern.steps.values()) +def test_strum_default_direction_is_up() -> None: + """Default direction should be 'up' (first note is lowest pitch).""" - assert total_notes == 5 + pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=4, quality="major") -def test_strum_default_direction_is_up () -> None: + builder.strum(chord, root=52, velocity=90, spacing=0.1) - """Default direction should be 'up' (first note is lowest pitch).""" + positions = sorted(pattern.steps.keys()) - pattern, builder = _make_builder(length=4) + first_pitch = pattern.steps[positions[0]].notes[0].pitch + last_pitch = pattern.steps[positions[-1]].notes[0].pitch - chord = subsequence.chords.Chord(root_pc=4, quality="major") + assert first_pitch < last_pitch - builder.strum(chord, root=52, velocity=90, spacing=0.1) - positions = sorted(pattern.steps.keys()) +def test_strum_invalid_offset() -> None: + """spacing=0 and offset<0 should raise ValueError.""" - first_pitch = pattern.steps[positions[0]].notes[0].pitch - last_pitch = pattern.steps[positions[-1]].notes[0].pitch + pattern, builder = _make_builder(length=4) - assert first_pitch < last_pitch + chord = subsequence.chords.Chord(root_pc=0, quality="major") + with pytest.raises(ValueError, match="spacing must be positive"): + builder.strum(chord, root=60, spacing=0) -def test_strum_invalid_offset () -> None: + with pytest.raises(ValueError, match="spacing must be positive"): + builder.strum(chord, root=60, spacing=-0.1) - """spacing=0 and offset<0 should raise ValueError.""" - pattern, builder = _make_builder(length=4) +def test_strum_invalid_direction() -> None: + """Invalid direction should raise ValueError.""" - chord = subsequence.chords.Chord(root_pc=0, quality="major") + pattern, builder = _make_builder(length=4) - with pytest.raises(ValueError, match="spacing must be positive"): - builder.strum(chord, root=60, spacing=0) + chord = subsequence.chords.Chord(root_pc=0, quality="major") - with pytest.raises(ValueError, match="spacing must be positive"): - builder.strum(chord, root=60, spacing=-0.1) - - -def test_strum_invalid_direction () -> None: - - """Invalid direction should raise ValueError.""" - - pattern, builder = _make_builder(length=4) - - chord = subsequence.chords.Chord(root_pc=0, quality="major") - - with pytest.raises(ValueError, match="direction must be"): - builder.strum(chord, root=60, direction="sideways") + with pytest.raises(ValueError, match="direction must be"): + builder.strum(chord, root=60, direction="sideways") # --- legato= shorthand on chord() and strum() --- -def test_chord_legato_reshapes_durations () -> None: - - """chord(legato=0.9) should call p.legato() — durations differ from the default.""" - - pattern, builder = _make_builder(length=4) - - chord = subsequence.chords.Chord(root_pc=0, quality="major") - - # Without legato, default duration is 1.0 beat = MIDI_QUARTER_NOTE pulses - builder.chord(chord, root=60, velocity=90, legato=0.9) +def test_chord_legato_reshapes_durations() -> None: + """chord(legato=0.9) should call p.legato() — durations differ from the default.""" - # legato() wraps around to the full pattern for a lone chord, so duration - # should be 0.9 × total_pulses, not the default 1.0-beat value. - total_pulses = int(4 * subsequence.constants.MIDI_QUARTER_NOTE) - expected_duration = max(1, int(total_pulses * 0.9)) + pattern, builder = _make_builder(length=4) - for note in pattern.steps[0].notes: - assert note.duration == expected_duration + chord = subsequence.chords.Chord(root_pc=0, quality="major") + # Without legato, default duration is 1.0 beat = MIDI_QUARTER_NOTE pulses + builder.chord(chord, root=60, velocity=90, legato=0.9) -def test_chord_legato_sustain_clash_raises () -> None: + # legato() wraps around to the full pattern for a lone chord, so duration + # should be 0.9 × total_pulses, not the default 1.0-beat value. + total_pulses = int(4 * subsequence.constants.MIDI_QUARTER_NOTE) + expected_duration = max(1, int(total_pulses * 0.9)) - """chord(sustain=True, legato=0.9) should raise ValueError.""" + for note in pattern.steps[0].notes: + assert note.duration == expected_duration - pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") +def test_chord_legato_sustain_clash_raises() -> None: + """chord(sustain=True, legato=0.9) should raise ValueError.""" - with pytest.raises(ValueError, match="mutually exclusive"): - builder.chord(chord, root=60, sustain=True, legato=0.9) + pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") -def test_chord_default_no_legato_unchanged () -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + builder.chord(chord, root=60, sustain=True, legato=0.9) - """chord() without legato= leaves note durations at the default value (regression).""" - pattern, builder = _make_builder(length=4) +def test_chord_default_no_legato_unchanged() -> None: + """chord() without legato= leaves note durations at the default value (regression).""" - chord = subsequence.chords.Chord(root_pc=0, quality="major") + pattern, builder = _make_builder(length=4) - builder.chord(chord, root=60, velocity=90) + chord = subsequence.chords.Chord(root_pc=0, quality="major") - expected_duration = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + builder.chord(chord, root=60, velocity=90) - for note in pattern.steps[0].notes: - assert note.duration == expected_duration + expected_duration = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + for note in pattern.steps[0].notes: + assert note.duration == expected_duration -def test_strum_legato_reshapes_durations () -> None: - """strum(legato=0.9) should call p.legato() after placing notes.""" +def test_strum_legato_reshapes_durations() -> None: + """strum(legato=0.9) should call p.legato() after placing notes.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") + chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.strum(chord, root=60, velocity=90, spacing=0.1, legato=0.9) + builder.strum(chord, root=60, velocity=90, spacing=0.1, legato=0.9) - # Each note is at a different pulse position due to strum offset; - # legato stretches each to fill the gap to the next. Verify that - # note durations are not the default 1.0-beat value (24 pulses). - default_duration = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + # Each note is at a different pulse position due to strum offset; + # legato stretches each to fill the gap to the next. Verify that + # note durations are not the default 1.0-beat value (24 pulses). + default_duration = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) - for step in pattern.steps.values(): - for note in step.notes: - assert note.duration != default_duration + for step in pattern.steps.values(): + for note in step.notes: + assert note.duration != default_duration -def test_strum_legato_sustain_clash_raises () -> None: +def test_strum_legato_sustain_clash_raises() -> None: + """strum(sustain=True, legato=0.9) should raise ValueError.""" - """strum(sustain=True, legato=0.9) should raise ValueError.""" + pattern, builder = _make_builder(length=4) - pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") - chord = subsequence.chords.Chord(root_pc=0, quality="major") - - with pytest.raises(ValueError, match="mutually exclusive"): - builder.strum(chord, root=60, sustain=True, legato=0.9) + with pytest.raises(ValueError, match="mutually exclusive"): + builder.strum(chord, root=60, sustain=True, legato=0.9) # --- detached= shorthand on chord() and strum() --- -def test_chord_detached_shortens_duration () -> None: - - """chord(detached=0.5) gives every chord note duration = (length - detached).""" - - pattern, builder = _make_builder(length=4) - - chord = subsequence.chords.Chord(root_pc=0, quality="major") - - builder.chord(chord, root=60, velocity=90, detached=0.5) - - # duration = 4.0 - 0.5 = 3.5 beats = 84 pulses - expected_duration = int(3.5 * subsequence.constants.MIDI_QUARTER_NOTE) +def test_chord_detached_shortens_duration() -> None: + """chord(detached=0.5) gives every chord note duration = (length - detached).""" - for note in pattern.steps[0].notes: - assert note.duration == expected_duration + pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") -def test_chord_detached_sustain_clash_raises () -> None: + builder.chord(chord, root=60, velocity=90, detached=0.5) - """chord(sustain=True, detached=0.5) should raise ValueError.""" + # duration = 4.0 - 0.5 = 3.5 beats = 84 pulses + expected_duration = int(3.5 * subsequence.constants.MIDI_QUARTER_NOTE) - pattern, builder = _make_builder(length=4) + for note in pattern.steps[0].notes: + assert note.duration == expected_duration - chord = subsequence.chords.Chord(root_pc=0, quality="major") - with pytest.raises(ValueError, match="mutually exclusive"): - builder.chord(chord, root=60, sustain=True, detached=0.5) +def test_chord_detached_sustain_clash_raises() -> None: + """chord(sustain=True, detached=0.5) should raise ValueError.""" + pattern, builder = _make_builder(length=4) -def test_chord_detached_legato_clash_raises () -> None: + chord = subsequence.chords.Chord(root_pc=0, quality="major") - """chord(legato=0.9, detached=0.5) should raise ValueError.""" + with pytest.raises(ValueError, match="mutually exclusive"): + builder.chord(chord, root=60, sustain=True, detached=0.5) - pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") +def test_chord_detached_legato_clash_raises() -> None: + """chord(legato=0.9, detached=0.5) should raise ValueError.""" - with pytest.raises(ValueError, match="mutually exclusive"): - builder.chord(chord, root=60, legato=0.9, detached=0.5) + pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") -def test_chord_default_no_detached_unchanged () -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + builder.chord(chord, root=60, legato=0.9, detached=0.5) - """chord() without detached= leaves note durations at the default 1-beat value (regression).""" - pattern, builder = _make_builder(length=4) +def test_chord_default_no_detached_unchanged() -> None: + """chord() without detached= leaves note durations at the default 1-beat value (regression).""" - chord = subsequence.chords.Chord(root_pc=0, quality="major") + pattern, builder = _make_builder(length=4) - builder.chord(chord, root=60, velocity=90) + chord = subsequence.chords.Chord(root_pc=0, quality="major") - expected_duration = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + builder.chord(chord, root=60, velocity=90) - for note in pattern.steps[0].notes: - assert note.duration == expected_duration + expected_duration = int(1.0 * subsequence.constants.MIDI_QUARTER_NOTE) + for note in pattern.steps[0].notes: + assert note.duration == expected_duration -def test_strum_detached_uniform_duration_with_stagger () -> None: - """strum(detached=0.25) gives uniform duration so the LAST note ends `detached` beats before cycle end.""" +def test_strum_detached_uniform_duration_with_stagger() -> None: + """strum(detached=0.25) gives uniform duration so the LAST note ends `detached` beats before cycle end.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") # 3 tones + chord = subsequence.chords.Chord(root_pc=0, quality="major") # 3 tones - builder.strum(chord, root=60, velocity=90, spacing=0.1, detached=0.25) + builder.strum(chord, root=60, velocity=90, spacing=0.1, detached=0.25) - # duration = 4.0 - 0.25 - (3 - 1) * 0.1 = 3.55 beats = 85 pulses - expected_duration = int(3.55 * subsequence.constants.MIDI_QUARTER_NOTE) + # duration = 4.0 - 0.25 - (3 - 1) * 0.1 = 3.55 beats = 85 pulses + expected_duration = int(3.55 * subsequence.constants.MIDI_QUARTER_NOTE) - # All strum notes share the same uniform duration; releases are staggered - # only because the onsets are staggered. - for step in pattern.steps.values(): - for note in step.notes: - assert note.duration == expected_duration + # All strum notes share the same uniform duration; releases are staggered + # only because the onsets are staggered. + for step in pattern.steps.values(): + for note in step.notes: + assert note.duration == expected_duration -def test_strum_detached_last_note_ends_before_cycle_boundary () -> None: +def test_strum_detached_last_note_ends_before_cycle_boundary() -> None: + """The last note of a detached strum must release before the cycle end.""" - """The last note of a detached strum must release before the cycle end.""" + pattern, builder = _make_builder(length=8) - pattern, builder = _make_builder(length=8) + chord = subsequence.chords.Chord(root_pc=0, quality="major") # 3 tones - chord = subsequence.chords.Chord(root_pc=0, quality="major") # 3 tones + builder.strum(chord, root=60, velocity=90, spacing=0.1, count=5, detached=0.25) - builder.strum(chord, root=60, velocity=90, spacing=0.1, count=5, detached=0.25) + # The last note onset is at (count-1) * offset = 0.4 beats; duration is + # (8.0 - 0.25 - 0.4) = 7.35 beats. End-of-note pulse = onset + duration. + # Cycle end pulse = 8 * MIDI_QUARTER_NOTE. + total_pulses = int(8 * subsequence.constants.MIDI_QUARTER_NOTE) + target_release_pulse = total_pulses - int( + 0.25 * subsequence.constants.MIDI_QUARTER_NOTE + ) - # The last note onset is at (count-1) * offset = 0.4 beats; duration is - # (8.0 - 0.25 - 0.4) = 7.35 beats. End-of-note pulse = onset + duration. - # Cycle end pulse = 8 * MIDI_QUARTER_NOTE. - total_pulses = int(8 * subsequence.constants.MIDI_QUARTER_NOTE) - target_release_pulse = total_pulses - int(0.25 * subsequence.constants.MIDI_QUARTER_NOTE) + # Find the last note (highest pulse position). + last_pulse = max(pattern.steps.keys()) + last_note = pattern.steps[last_pulse].notes[0] + last_release_pulse = last_pulse + last_note.duration - # Find the last note (highest pulse position). - last_pulse = max(pattern.steps.keys()) - last_note = pattern.steps[last_pulse].notes[0] - last_release_pulse = last_pulse + last_note.duration + # Within 1 pulse of the target boundary (allowing for int truncation). + assert abs(last_release_pulse - target_release_pulse) <= 1 - # Within 1 pulse of the target boundary (allowing for int truncation). - assert abs(last_release_pulse - target_release_pulse) <= 1 +def test_strum_detached_sustain_clash_raises() -> None: + """strum(sustain=True, detached=0.25) should raise ValueError.""" -def test_strum_detached_sustain_clash_raises () -> None: + pattern, builder = _make_builder(length=4) - """strum(sustain=True, detached=0.25) should raise ValueError.""" + chord = subsequence.chords.Chord(root_pc=0, quality="major") - pattern, builder = _make_builder(length=4) + with pytest.raises(ValueError, match="mutually exclusive"): + builder.strum(chord, root=60, sustain=True, detached=0.25) - chord = subsequence.chords.Chord(root_pc=0, quality="major") - with pytest.raises(ValueError, match="mutually exclusive"): - builder.strum(chord, root=60, sustain=True, detached=0.25) +def test_strum_detached_legato_clash_raises() -> None: + """strum(legato=0.9, detached=0.25) should raise ValueError.""" + pattern, builder = _make_builder(length=4) -def test_strum_detached_legato_clash_raises () -> None: + chord = subsequence.chords.Chord(root_pc=0, quality="major") - """strum(legato=0.9, detached=0.25) should raise ValueError.""" - - pattern, builder = _make_builder(length=4) - - chord = subsequence.chords.Chord(root_pc=0, quality="major") - - with pytest.raises(ValueError, match="mutually exclusive"): - builder.strum(chord, root=60, legato=0.9, detached=0.25) + with pytest.raises(ValueError, match="mutually exclusive"): + builder.strum(chord, root=60, legato=0.9, detached=0.25) # --- Broken Chord --- -def test_broken_chord_places_notes_in_order () -> None: - - """broken_chord should map order indices correctly.""" - - pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") +def test_broken_chord_places_notes_in_order() -> None: + """broken_chord should map order indices correctly.""" - builder.broken_chord(chord, root=60, order=[2, 0, 1], spacing=0.25) + pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") - ppq = subsequence.constants.MIDI_QUARTER_NOTE - positions = sorted(pattern.steps.keys()) - assert len(positions) == 16 # 4 beats / 0.25 step = 16 steps - assert positions[0] == 0 - assert positions[1] == int(0.25 * ppq) - assert positions[2] == int(0.50 * ppq) + builder.broken_chord(chord, root=60, order=[2, 0, 1], spacing=0.25) - # Verify the cycling pitch order: 67, 60, 64 - pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] - assert pitches[:6] == [67, 60, 64, 67, 60, 64] + ppq = subsequence.constants.MIDI_QUARTER_NOTE + positions = sorted(pattern.steps.keys()) + assert len(positions) == 16 # 4 beats / 0.25 step = 16 steps + assert positions[0] == 0 + assert positions[1] == int(0.25 * ppq) + assert positions[2] == int(0.50 * ppq) + # Verify the cycling pitch order: 67, 60, 64 + pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] + assert pitches[:6] == [67, 60, 64, 67, 60, 64] -def test_broken_chord_empty_order_raises () -> None: - """broken_chord with empty order should raise ValueError.""" +def test_broken_chord_empty_order_raises() -> None: + """broken_chord with empty order should raise ValueError.""" - pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") + pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") - with pytest.raises(ValueError, match="order list cannot be empty"): - builder.broken_chord(chord, root=60, order=[]) + with pytest.raises(ValueError, match="order list cannot be empty"): + builder.broken_chord(chord, root=60, order=[]) -def test_broken_chord_negative_index_raises () -> None: +def test_broken_chord_negative_index_raises() -> None: + """broken_chord with negative index should raise ValueError.""" - """broken_chord with negative index should raise ValueError.""" + pattern, builder = _make_builder(length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") - pattern, builder = _make_builder(length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") + with pytest.raises(ValueError, match="non-negative integers"): + builder.broken_chord(chord, root=60, order=[0, -1]) - with pytest.raises(ValueError, match="non-negative integers"): - builder.broken_chord(chord, root=60, order=[0, -1]) +def test_broken_chord_positioned_with_span() -> None: + """broken_chord forwards beat/span to arpeggio — the figure stays within [beat, beat + span).""" -def test_broken_chord_positioned_with_span () -> None: + pattern, builder = _make_builder(length=4) + ppq = subsequence.constants.MIDI_QUARTER_NOTE + chord = subsequence.chords.Chord(root_pc=0, quality="major") - """broken_chord forwards beat/span to arpeggio — the figure stays within [beat, beat + span).""" + builder.broken_chord( + chord, root=60, order=[2, 0, 1], beat=1.0, span=1.0, spacing=0.5 + ) - pattern, builder = _make_builder(length=4) - ppq = subsequence.constants.MIDI_QUARTER_NOTE - chord = subsequence.chords.Chord(root_pc=0, quality="major") + positions = sorted(pattern.steps.keys()) + assert positions == [int(1.0 * ppq), int(1.5 * ppq)] - builder.broken_chord(chord, root=60, order=[2, 0, 1], beat=1.0, span=1.0, spacing=0.5) - - positions = sorted(pattern.steps.keys()) - assert positions == [int(1.0 * ppq), int(1.5 * ppq)] - - # tones [60, 64, 67] reordered by [2, 0, 1] -> [67, 60, 64], cycled - pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] - assert pitches == [67, 60] + # tones [60, 64, 67] reordered by [2, 0, 1] -> [67, 60, 64], cycled + pitches = [pattern.steps[pos].notes[0].pitch for pos in positions] + assert pitches == [67, 60] # --- p.param() --- -def test_param_returns_default_when_no_tweak () -> None: - - """p.param() should return the default when no tweak is set.""" - - _, builder = _make_builder() - - assert builder.param("pitches", [60, 64]) == [60, 64] +def test_param_returns_default_when_no_tweak() -> None: + """p.param() should return the default when no tweak is set.""" + _, builder = _make_builder() -def test_param_returns_tweaked_value () -> None: + assert builder.param("pitches", [60, 64]) == [60, 64] - """p.param() should return the tweaked value when set.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) +def test_param_returns_tweaked_value() -> None: + """p.param() should return the tweaked value when set.""" - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - tweaks = {"pitches": [48, 52]} - ) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - assert builder.param("pitches", [60, 64]) == [48, 52] + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, tweaks={"pitches": [48, 52]} + ) + assert builder.param("pitches", [60, 64]) == [48, 52] -def test_param_returns_none_when_no_default () -> None: - """p.param() with no default should return None for missing keys.""" +def test_param_returns_none_when_no_default() -> None: + """p.param() with no default should return None for missing keys.""" - _, builder = _make_builder() + _, builder = _make_builder() - assert builder.param("missing") is None + assert builder.param("missing") is None # --- Randomize --- -def test_randomize_timing_shifts_notes () -> None: - - """randomize(timing=0.5) should shift at least one note from its original pulse.""" - - import random - - _, builder = _make_builder() - - # Place notes at all 16 sixteenth-note positions using MIDI note numbers. - builder.hit_steps(36, list(range(16)), velocity=100) - original_pulses = set(builder._pattern.steps.keys()) - - builder.randomize(timing=0.5, rng=random.Random(42)) +def test_randomize_timing_shifts_notes() -> None: + """randomize(timing=0.5) should shift at least one note from its original pulse.""" - new_pulses = set(builder._pattern.steps.keys()) + import random - # At least one note should have moved. - assert new_pulses != original_pulses + _, builder = _make_builder() + # Place notes at all 16 sixteenth-note positions using MIDI note numbers. + builder.hit_steps(36, list(range(16)), velocity=100) + original_pulses = set(builder._pattern.steps.keys()) -def test_randomize_timing_zero_no_change () -> None: + builder.randomize(timing=0.5, rng=random.Random(42)) - """randomize(timing=0.0) should leave all pulse positions unchanged.""" + new_pulses = set(builder._pattern.steps.keys()) - import random + # At least one note should have moved. + assert new_pulses != original_pulses - _, builder = _make_builder() - builder.hit_steps(36, list(range(16)), velocity=100) - original_pulses = set(builder._pattern.steps.keys()) - builder.randomize(timing=0.0, rng=random.Random(42)) +def test_randomize_timing_zero_no_change() -> None: + """randomize(timing=0.0) should leave all pulse positions unchanged.""" - assert set(builder._pattern.steps.keys()) == original_pulses + import random + _, builder = _make_builder() + builder.hit_steps(36, list(range(16)), velocity=100) + original_pulses = set(builder._pattern.steps.keys()) -def test_randomize_velocity_changes_velocity () -> None: + builder.randomize(timing=0.0, rng=random.Random(42)) - """randomize(velocity=0.5) should change at least one note's velocity.""" + assert set(builder._pattern.steps.keys()) == original_pulses - import random - _, builder = _make_builder() - builder.hit_steps(36, list(range(16)), velocity=100) +def test_randomize_velocity_changes_velocity() -> None: + """randomize(velocity=0.5) should change at least one note's velocity.""" - original_velocities = [ - note.velocity - for step in builder._pattern.steps.values() - for note in step.notes - ] + import random - builder.randomize(velocity=0.5, rng=random.Random(42)) + _, builder = _make_builder() + builder.hit_steps(36, list(range(16)), velocity=100) - new_velocities = [ - note.velocity - for step in builder._pattern.steps.values() - for note in step.notes - ] + original_velocities = [ + note.velocity for step in builder._pattern.steps.values() for note in step.notes + ] - assert new_velocities != original_velocities + builder.randomize(velocity=0.5, rng=random.Random(42)) + new_velocities = [ + note.velocity for step in builder._pattern.steps.values() for note in step.notes + ] -def test_randomize_velocity_zero_no_change () -> None: + assert new_velocities != original_velocities - """randomize(velocity=0.0) should leave all velocities unchanged.""" - import random +def test_randomize_velocity_zero_no_change() -> None: + """randomize(velocity=0.0) should leave all velocities unchanged.""" - _, builder = _make_builder() - builder.hit_steps(36, list(range(16)), velocity=100) + import random - original_velocities = [ - note.velocity - for step in builder._pattern.steps.values() - for note in step.notes - ] + _, builder = _make_builder() + builder.hit_steps(36, list(range(16)), velocity=100) - builder.randomize(velocity=0.0, rng=random.Random(42)) + original_velocities = [ + note.velocity for step in builder._pattern.steps.values() for note in step.notes + ] - new_velocities = [ - note.velocity - for step in builder._pattern.steps.values() - for note in step.notes - ] + builder.randomize(velocity=0.0, rng=random.Random(42)) - assert new_velocities == original_velocities + new_velocities = [ + note.velocity for step in builder._pattern.steps.values() for note in step.notes + ] + assert new_velocities == original_velocities -def test_randomize_deterministic_with_rng () -> None: - """Same RNG seed produces identical results; different seed produces different results.""" +def test_randomize_deterministic_with_rng() -> None: + """Same RNG seed produces identical results; different seed produces different results.""" - import random + import random - def build_with_seed (seed: int) -> set: - _, builder = _make_builder() - builder.hit_steps(36, list(range(16)), velocity=80) - builder.randomize(timing=0.5, velocity=0.3, rng=random.Random(seed)) - return set(builder._pattern.steps.keys()) + def build_with_seed(seed: int) -> set: + _, builder = _make_builder() + builder.hit_steps(36, list(range(16)), velocity=80) + builder.randomize(timing=0.5, velocity=0.3, rng=random.Random(seed)) + return set(builder._pattern.steps.keys()) - run_a = build_with_seed(1) - run_b = build_with_seed(1) - run_c = build_with_seed(99) + run_a = build_with_seed(1) + run_b = build_with_seed(1) + run_c = build_with_seed(99) - assert run_a == run_b - assert run_a != run_c + assert run_a == run_b + assert run_a != run_c -def test_randomize_velocity_stays_in_range () -> None: +def test_randomize_velocity_stays_in_range() -> None: + """All velocities must remain in the valid MIDI range (1–127) after randomize.""" - """All velocities must remain in the valid MIDI range (1–127) after randomize.""" + import random - import random + _, builder = _make_builder() - _, builder = _make_builder() + # Use edge-case velocities to stress-test the clamp. + builder.note(60, beat=0, velocity=1) + builder.note(60, beat=1, velocity=127) + builder.note(60, beat=2, velocity=64) - # Use edge-case velocities to stress-test the clamp. - builder.note(60, beat=0, velocity=1) - builder.note(60, beat=1, velocity=127) - builder.note(60, beat=2, velocity=64) + builder.randomize(velocity=1.0, rng=random.Random(42)) - builder.randomize(velocity=1.0, rng=random.Random(42)) - - for step in builder._pattern.steps.values(): - for note in step.notes: - assert 1 <= note.velocity <= 127 + for step in builder._pattern.steps.values(): + for note in step.notes: + assert 1 <= note.velocity <= 127 # ── CC / Pitch Bend ────────────────────────────────────────────────── -def test_cc_adds_event () -> None: - - """p.cc() should create a CcEvent at the correct pulse.""" - - _, builder = _make_builder() - - builder.cc(74, 100, beat=1.0) - - assert len(builder._pattern.cc_events) == 1 - - event = builder._pattern.cc_events[0] - assert event.message_type == 'control_change' - assert event.control == 74 - assert event.value == 100 - assert event.pulse == 24 # 1 beat * 24 ppq - - -def test_cc_ramp_generates_interpolated_events () -> None: - - """cc_ramp should produce linearly interpolated CC events.""" - - _, builder = _make_builder() +def test_cc_adds_event() -> None: + """p.cc() should create a CcEvent at the correct pulse.""" - builder.cc_ramp(74, start=0, end=127, beat_start=0, beat_end=1, resolution=6) + _, builder = _make_builder() - events = builder._pattern.cc_events - # From pulse 0 to pulse 24 in steps of 6 → pulses 0, 6, 12, 18, 24 - assert len(events) == 5 + builder.cc(74, 100, beat=1.0) - # All should be control_change for CC 74 - for event in events: - assert event.message_type == 'control_change' - assert event.control == 74 + assert len(builder._pattern.cc_events) == 1 - # First and last values - assert events[0].value == 0 - assert events[-1].value == 127 + event = builder._pattern.cc_events[0] + assert event.message_type == "control_change" + assert event.control == 74 + assert event.value == 100 + assert event.pulse == 24 # 1 beat * 24 ppq - # Values should be monotonically increasing - values = [e.value for e in events] - assert values == sorted(values) +def test_cc_ramp_generates_interpolated_events() -> None: + """cc_ramp should produce linearly interpolated CC events.""" -def test_cc_ramp_resolution () -> None: + _, builder = _make_builder() - """Higher resolution value should produce fewer events.""" + builder.cc_ramp(74, start=0, end=127, beat_start=0, beat_end=1, resolution=6) - _, builder_fine = _make_builder() - _, builder_coarse = _make_builder() + events = builder._pattern.cc_events + # From pulse 0 to pulse 24 in steps of 6 → pulses 0, 6, 12, 18, 24 + assert len(events) == 5 - builder_fine.cc_ramp(1, 0, 127, beat_start=0, beat_end=1, resolution=1) - builder_coarse.cc_ramp(1, 0, 127, beat_start=0, beat_end=1, resolution=6) + # All should be control_change for CC 74 + for event in events: + assert event.message_type == "control_change" + assert event.control == 74 - assert len(builder_fine._pattern.cc_events) > len(builder_coarse._pattern.cc_events) + # First and last values + assert events[0].value == 0 + assert events[-1].value == 127 + # Values should be monotonically increasing + values = [e.value for e in events] + assert values == sorted(values) -def test_pitch_bend_normalised_range () -> None: - """pitch_bend should map -1.0..1.0 to -8192..8191.""" +def test_cc_ramp_resolution() -> None: + """Higher resolution value should produce fewer events.""" - _, builder = _make_builder() + _, builder_fine = _make_builder() + _, builder_coarse = _make_builder() - builder.pitch_bend(1.0, beat=0) - builder.pitch_bend(-1.0, beat=1) - builder.pitch_bend(0.0, beat=2) + builder_fine.cc_ramp(1, 0, 127, beat_start=0, beat_end=1, resolution=1) + builder_coarse.cc_ramp(1, 0, 127, beat_start=0, beat_end=1, resolution=6) - events = builder._pattern.cc_events + assert len(builder_fine._pattern.cc_events) > len(builder_coarse._pattern.cc_events) - assert events[0].message_type == 'pitchwheel' - assert events[0].value == 8191 # clamped to max - assert events[1].value == -8192 - assert events[2].value == 0 +def test_pitch_bend_normalised_range() -> None: + """pitch_bend should map -1.0..1.0 to -8192..8191.""" -def test_pitch_bend_ramp () -> None: + _, builder = _make_builder() - """pitch_bend_ramp should produce interpolated pitchwheel events.""" + builder.pitch_bend(1.0, beat=0) + builder.pitch_bend(-1.0, beat=1) + builder.pitch_bend(0.0, beat=2) - _, builder = _make_builder() + events = builder._pattern.cc_events - builder.pitch_bend_ramp(-1.0, 1.0, beat_start=0, beat_end=1, resolution=6) + assert events[0].message_type == "pitchwheel" + assert events[0].value == 8191 # clamped to max + assert events[1].value == -8192 + assert events[2].value == 0 - events = builder._pattern.cc_events - # From pulse 0 to 24 in steps of 6 → 5 events - assert len(events) == 5 - for event in events: - assert event.message_type == 'pitchwheel' - assert -8192 <= event.value <= 8191 +def test_pitch_bend_ramp() -> None: + """pitch_bend_ramp should produce interpolated pitchwheel events.""" - # Should go from negative to positive - assert events[0].value < 0 - assert events[-1].value > 0 + _, builder = _make_builder() + builder.pitch_bend_ramp(-1.0, 1.0, beat_start=0, beat_end=1, resolution=6) -def test_cc_ramp_resolution_zero_raises () -> None: + events = builder._pattern.cc_events + # From pulse 0 to 24 in steps of 6 → 5 events + assert len(events) == 5 - """cc_ramp(resolution=0) raises ValueError instead of looping forever.""" + for event in events: + assert event.message_type == "pitchwheel" + assert -8192 <= event.value <= 8191 - pattern, builder = _make_builder(length=4) + # Should go from negative to positive + assert events[0].value < 0 + assert events[-1].value > 0 - with pytest.raises(ValueError, match="resolution must be at least 1 pulse"): - builder.cc_ramp(74, 0, 127, beat_start=0, beat_end=1, resolution=0) +def test_cc_ramp_resolution_zero_raises() -> None: + """cc_ramp(resolution=0) raises ValueError instead of looping forever.""" -def test_cc_ramp_defaults_beat_end_to_pattern_length () -> None: + pattern, builder = _make_builder(length=4) - """When beat_end is omitted, the ramp should extend to pattern length.""" + with pytest.raises(ValueError, match="resolution must be at least 1 pulse"): + builder.cc_ramp(74, 0, 127, beat_start=0, beat_end=1, resolution=0) - _, builder = _make_builder(length=2) - builder.cc_ramp(74, 0, 127, beat_start=0, resolution=12) +def test_cc_ramp_defaults_beat_end_to_pattern_length() -> None: + """When beat_end is omitted, the ramp should extend to pattern length.""" - events = builder._pattern.cc_events - # 2 beats = 48 pulses, step 12 → pulses 0, 12, 24, 36, 48 → 5 events - assert len(events) == 5 - assert events[-1].pulse == 48 + _, builder = _make_builder(length=2) + builder.cc_ramp(74, 0, 127, beat_start=0, resolution=12) -def test_cc_ramp_with_ease_in_shape () -> None: + events = builder._pattern.cc_events + # 2 beats = 48 pulses, step 12 → pulses 0, 12, 24, 36, 48 → 5 events + assert len(events) == 5 + assert events[-1].pulse == 48 - """cc_ramp with shape='ease_in' produces non-linear (quadratic) CC values.""" - _, builder = _make_builder(length=4) +def test_cc_ramp_with_ease_in_shape() -> None: + """cc_ramp with shape='ease_in' produces non-linear (quadratic) CC values.""" - # 4 beats = 96 pulses; with resolution=96 we get exactly 2 events: pulse 0 and pulse 96 - builder.cc_ramp(74, 0, 100, beat_start=0, beat_end=4, resolution=96, shape="ease_in") + _, builder = _make_builder(length=4) - events = sorted(builder._pattern.cc_events, key=lambda e: e.pulse) - assert events[0].value == 0 # t=0.0 → ease_in(0) = 0 - assert events[1].value == 100 # t=1.0 → ease_in(1) = 1.0 → 100 + # 4 beats = 96 pulses; with resolution=96 we get exactly 2 events: pulse 0 and pulse 96 + builder.cc_ramp( + 74, 0, 100, beat_start=0, beat_end=4, resolution=96, shape="ease_in" + ) - # Check a mid-ramp event: use resolution=48 (3 events: 0, 48, 96) - _, builder2 = _make_builder(length=4) - builder2.cc_ramp(74, 0, 100, beat_start=0, beat_end=4, resolution=48, shape="ease_in") - mid_events = sorted(builder2._pattern.cc_events, key=lambda e: e.pulse) - # t=0.5 → ease_in(0.5) = 0.25 → value ≈ 25 - assert mid_events[1].value == 25 + events = sorted(builder._pattern.cc_events, key=lambda e: e.pulse) + assert events[0].value == 0 # t=0.0 → ease_in(0) = 0 + assert events[1].value == 100 # t=1.0 → ease_in(1) = 1.0 → 100 + # Check a mid-ramp event: use resolution=48 (3 events: 0, 48, 96) + _, builder2 = _make_builder(length=4) + builder2.cc_ramp( + 74, 0, 100, beat_start=0, beat_end=4, resolution=48, shape="ease_in" + ) + mid_events = sorted(builder2._pattern.cc_events, key=lambda e: e.pulse) + # t=0.5 → ease_in(0.5) = 0.25 → value ≈ 25 + assert mid_events[1].value == 25 -def test_cc_ramp_default_shape_is_linear () -> None: - """cc_ramp with no shape argument remains linear (regression test).""" +def test_cc_ramp_default_shape_is_linear() -> None: + """cc_ramp with no shape argument remains linear (regression test).""" - _, builder = _make_builder(length=4) - builder.cc_ramp(74, 0, 100, beat_start=0, beat_end=4, resolution=48) + _, builder = _make_builder(length=4) + builder.cc_ramp(74, 0, 100, beat_start=0, beat_end=4, resolution=48) - events = sorted(builder._pattern.cc_events, key=lambda e: e.pulse) - # t=0.5 → linear → value = 50 - assert events[1].value == 50 + events = sorted(builder._pattern.cc_events, key=lambda e: e.pulse) + # t=0.5 → linear → value = 50 + assert events[1].value == 50 -def test_cc_events_cleared_on_rebuild () -> None: +def test_cc_events_cleared_on_rebuild() -> None: + """Pattern.cc_events should be reset to [] each cycle.""" - """Pattern.cc_events should be reset to [] each cycle.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) - pattern = subsequence.pattern.Pattern(channel=0, length=4) + pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=0, message_type="control_change", control=74, value=100 + ) + ) - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=0, message_type='control_change', control=74, value=100) - ) + assert len(pattern.cc_events) == 1 - assert len(pattern.cc_events) == 1 + # Simulate what _rebuild does + pattern.steps = {} + pattern.cc_events = [] - # Simulate what _rebuild does - pattern.steps = {} - pattern.cc_events = [] - - assert len(pattern.cc_events) == 0 + assert len(pattern.cc_events) == 0 # ── OSC output ──────────────────────────────────────────────────────────────── -def test_osc_adds_event () -> None: - - """p.osc() should create an OscEvent at the correct pulse.""" - - _, builder = _make_builder() - - builder.osc("/mixer/fader/1", 0.5, beat=1.0) - - assert len(builder._pattern.osc_events) == 1 +def test_osc_adds_event() -> None: + """p.osc() should create an OscEvent at the correct pulse.""" - event = builder._pattern.osc_events[0] - assert event.address == "/mixer/fader/1" - assert event.args == (0.5,) - assert event.pulse == 24 # 1 beat * 24 ppq + _, builder = _make_builder() + builder.osc("/mixer/fader/1", 0.5, beat=1.0) -def test_osc_no_args () -> None: + assert len(builder._pattern.osc_events) == 1 - """p.osc() with no extra arguments should produce an event with empty args.""" + event = builder._pattern.osc_events[0] + assert event.address == "/mixer/fader/1" + assert event.args == (0.5,) + assert event.pulse == 24 # 1 beat * 24 ppq - _, builder = _make_builder() - builder.osc("/scene/next", beat=0.0) +def test_osc_no_args() -> None: + """p.osc() with no extra arguments should produce an event with empty args.""" - assert len(builder._pattern.osc_events) == 1 + _, builder = _make_builder() - event = builder._pattern.osc_events[0] - assert event.address == "/scene/next" - assert event.args == () - assert event.pulse == 0 + builder.osc("/scene/next", beat=0.0) + assert len(builder._pattern.osc_events) == 1 -def test_osc_multiple_args () -> None: + event = builder._pattern.osc_events[0] + assert event.address == "/scene/next" + assert event.args == () + assert event.pulse == 0 - """p.osc() should accept and preserve multiple arguments.""" - _, builder = _make_builder() +def test_osc_multiple_args() -> None: + """p.osc() should accept and preserve multiple arguments.""" - builder.osc("/matrix/cell", 3, 7, 0.8, beat=2.0) + _, builder = _make_builder() - event = builder._pattern.osc_events[0] - assert event.args == (3, 7, 0.8) + builder.osc("/matrix/cell", 3, 7, 0.8, beat=2.0) + event = builder._pattern.osc_events[0] + assert event.args == (3, 7, 0.8) -def test_osc_ramp_generates_interpolated_events () -> None: - """osc_ramp should produce linearly interpolated float events.""" +def test_osc_ramp_generates_interpolated_events() -> None: + """osc_ramp should produce linearly interpolated float events.""" - _, builder = _make_builder() + _, builder = _make_builder() - builder.osc_ramp("/filter/cutoff", start=0.0, end=1.0, beat_start=0, beat_end=1, resolution=6) + builder.osc_ramp( + "/filter/cutoff", start=0.0, end=1.0, beat_start=0, beat_end=1, resolution=6 + ) - events = builder._pattern.osc_events - # From pulse 0 to pulse 24 in steps of 6 → pulses 0, 6, 12, 18, 24 - assert len(events) == 5 + events = builder._pattern.osc_events + # From pulse 0 to pulse 24 in steps of 6 → pulses 0, 6, 12, 18, 24 + assert len(events) == 5 - for event in events: - assert event.address == "/filter/cutoff" - assert len(event.args) == 1 + for event in events: + assert event.address == "/filter/cutoff" + assert len(event.args) == 1 - # First and last values - assert events[0].args[0] == pytest.approx(0.0) - assert events[-1].args[0] == pytest.approx(1.0) + # First and last values + assert events[0].args[0] == pytest.approx(0.0) + assert events[-1].args[0] == pytest.approx(1.0) - # Values should be monotonically increasing - values = [e.args[0] for e in events] - assert values == sorted(values) + # Values should be monotonically increasing + values = [e.args[0] for e in events] + assert values == sorted(values) -def test_osc_ramp_resolution () -> None: +def test_osc_ramp_resolution() -> None: + """Higher resolution value should produce fewer events.""" - """Higher resolution value should produce fewer events.""" + _, builder_fine = _make_builder() + _, builder_coarse = _make_builder() - _, builder_fine = _make_builder() - _, builder_coarse = _make_builder() + builder_fine.osc_ramp("/fader", 0.0, 1.0, beat_start=0, beat_end=1, resolution=1) + builder_coarse.osc_ramp("/fader", 0.0, 1.0, beat_start=0, beat_end=1, resolution=6) - builder_fine.osc_ramp("/fader", 0.0, 1.0, beat_start=0, beat_end=1, resolution=1) - builder_coarse.osc_ramp("/fader", 0.0, 1.0, beat_start=0, beat_end=1, resolution=6) + assert len(builder_fine._pattern.osc_events) > len( + builder_coarse._pattern.osc_events + ) - assert len(builder_fine._pattern.osc_events) > len(builder_coarse._pattern.osc_events) +def test_osc_ramp_defaults_beat_end_to_pattern_length() -> None: + """osc_ramp with no beat_end should ramp to the full pattern length.""" -def test_osc_ramp_defaults_beat_end_to_pattern_length () -> None: + _, builder = _make_builder(length=4) - """osc_ramp with no beat_end should ramp to the full pattern length.""" + builder.osc_ramp("/fader", 0.0, 1.0, beat_start=0, resolution=96) - _, builder = _make_builder(length=4) + events = builder._pattern.osc_events + # 4 beats = 96 pulses; resolution=96 → events at pulse 0 and 96 + assert len(events) == 2 + assert events[0].pulse == 0 + assert events[-1].pulse == 96 - builder.osc_ramp("/fader", 0.0, 1.0, beat_start=0, resolution=96) - events = builder._pattern.osc_events - # 4 beats = 96 pulses; resolution=96 → events at pulse 0 and 96 - assert len(events) == 2 - assert events[0].pulse == 0 - assert events[-1].pulse == 96 +def test_osc_ramp_with_easing() -> None: + """osc_ramp with shape='ease_in' should produce non-linear values.""" + _, builder = _make_builder(length=4) -def test_osc_ramp_with_easing () -> None: + # 4 beats = 96 pulses; resolution=48 → 3 events at pulses 0, 48, 96 + builder.osc_ramp( + "/filter", 0.0, 1.0, beat_start=0, beat_end=4, resolution=48, shape="ease_in" + ) - """osc_ramp with shape='ease_in' should produce non-linear values.""" + events = sorted(builder._pattern.osc_events, key=lambda e: e.pulse) + assert len(events) == 3 - _, builder = _make_builder(length=4) + # t=0.5 → ease_in(0.5) = 0.25 (quadratic), so midpoint ≈ 0.25 not 0.5 + mid_val = events[1].args[0] + assert mid_val == pytest.approx(0.25, abs=0.01) - # 4 beats = 96 pulses; resolution=48 → 3 events at pulses 0, 48, 96 - builder.osc_ramp("/filter", 0.0, 1.0, beat_start=0, beat_end=4, resolution=48, shape="ease_in") - events = sorted(builder._pattern.osc_events, key=lambda e: e.pulse) - assert len(events) == 3 +def test_osc_events_cleared_on_rebuild() -> None: + """Pattern.osc_events should be reset to [] each cycle.""" - # t=0.5 → ease_in(0.5) = 0.25 (quadratic), so midpoint ≈ 0.25 not 0.5 - mid_val = events[1].args[0] - assert mid_val == pytest.approx(0.25, abs=0.01) + pattern = subsequence.pattern.Pattern(channel=0, length=4) + pattern.osc_events.append( + subsequence.pattern.OscEvent(pulse=0, address="/fader", args=(0.5,)) + ) -def test_osc_events_cleared_on_rebuild () -> None: + assert len(pattern.osc_events) == 1 - """Pattern.osc_events should be reset to [] each cycle.""" + # Simulate what _rebuild does + pattern.steps = {} + pattern.cc_events = [] + pattern.osc_events = [] - pattern = subsequence.pattern.Pattern(channel=0, length=4) - - pattern.osc_events.append( - subsequence.pattern.OscEvent(pulse=0, address="/fader", args=(0.5,)) - ) - - assert len(pattern.osc_events) == 1 - - # Simulate what _rebuild does - pattern.steps = {} - pattern.cc_events = [] - pattern.osc_events = [] - - assert len(pattern.osc_events) == 0 + assert len(pattern.osc_events) == 0 # ── p.bend() ────────────────────────────────────────────────────────────────── -def test_bend_last_note () -> None: - - """bend(note=-1) should place ramp events within the last note's duration.""" +def test_bend_last_note() -> None: + """bend(note=-1) should place ramp events within the last note's duration.""" - _, builder = _make_builder(length=4) + _, builder = _make_builder(length=4) - # 4 notes at pulses 0, 24, 48, 72; use add_note to avoid needing a drum_note_map - for pos in (0, 24, 48, 72): - builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) - builder.legato(0.9) + # 4 notes at pulses 0, 24, 48, 72; use add_note to avoid needing a drum_note_map + for pos in (0, 24, 48, 72): + builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) + builder.legato(0.9) - sorted_positions = sorted(builder._pattern.steps.keys()) - last_pos = sorted_positions[-1] - last_duration = max(n.duration for n in builder._pattern.steps[last_pos].notes) + sorted_positions = sorted(builder._pattern.steps.keys()) + last_pos = sorted_positions[-1] + last_duration = max(n.duration for n in builder._pattern.steps[last_pos].notes) - builder.bend(note=-1, amount=0.5) + builder.bend(note=-1, amount=0.5) - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] - # All ramp events (excluding reset) should be within [last_pos, last_pos + duration] - ramp_events = bend_events[:-1] # last event is the reset - for e in ramp_events: - assert last_pos <= e.pulse <= last_pos + last_duration + # All ramp events (excluding reset) should be within [last_pos, last_pos + duration] + ramp_events = bend_events[:-1] # last event is the reset + for e in ramp_events: + assert last_pos <= e.pulse <= last_pos + last_duration -def test_bend_first_note () -> None: +def test_bend_first_note() -> None: + """bend(note=0) should place ramp events starting at position 0.""" - """bend(note=0) should place ramp events starting at position 0.""" + _, builder = _make_builder(length=4) + for pos in (0, 24, 48, 72): + builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) + builder.legato(0.9) - _, builder = _make_builder(length=4) - for pos in (0, 24, 48, 72): - builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) - builder.legato(0.9) + builder.bend(note=0, amount=-0.5) - builder.bend(note=0, amount=-0.5) + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + assert bend_events[0].pulse == 0 # ramp starts at note onset - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - assert bend_events[0].pulse == 0 # ramp starts at note onset +def test_bend_with_start_end_fraction() -> None: + """bend() with start=0.5, end=0.9 should narrow the ramp to that fraction.""" -def test_bend_with_start_end_fraction () -> None: + _, builder = _make_builder(length=4) + for pos in (0, 24, 48, 72): + builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) + builder.legato(0.9) - """bend() with start=0.5, end=0.9 should narrow the ramp to that fraction.""" + sorted_positions = sorted(builder._pattern.steps.keys()) + first_pos = sorted_positions[0] + duration = max(n.duration for n in builder._pattern.steps[first_pos].notes) - _, builder = _make_builder(length=4) - for pos in (0, 24, 48, 72): - builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) - builder.legato(0.9) + builder.bend(note=0, amount=1.0, start=0.5, end=0.9, resolution=1) - sorted_positions = sorted(builder._pattern.steps.keys()) - first_pos = sorted_positions[0] - duration = max(n.duration for n in builder._pattern.steps[first_pos].notes) + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + ramp_events = bend_events[:-1] - builder.bend(note=0, amount=1.0, start=0.5, end=0.9, resolution=1) + expected_start = first_pos + int(duration * 0.5) + expected_end = first_pos + int(duration * 0.9) - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - ramp_events = bend_events[:-1] + assert ramp_events[0].pulse == expected_start + assert ramp_events[-1].pulse == expected_end - expected_start = first_pos + int(duration * 0.5) - expected_end = first_pos + int(duration * 0.9) - assert ramp_events[0].pulse == expected_start - assert ramp_events[-1].pulse == expected_end +def test_bend_resolution_zero_raises() -> None: + """bend(resolution=0) raises ValueError instead of looping forever.""" + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=24) -def test_bend_resolution_zero_raises () -> None: + with pytest.raises(ValueError, match="resolution must be at least 1 pulse"): + builder.bend(note=0, amount=0.5, resolution=0) - """bend(resolution=0) raises ValueError instead of looping forever.""" - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=24) +def test_bend_inserts_reset_at_next_note() -> None: + """bend() should insert a pitch_bend(0) at the onset of the following note.""" - with pytest.raises(ValueError, match="resolution must be at least 1 pulse"): - builder.bend(note=0, amount=0.5, resolution=0) + _, builder = _make_builder(length=4) + for pos in (0, 24, 48, 72): + builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) + builder.legato(0.9) + sorted_positions = sorted(builder._pattern.steps.keys()) + next_note_pulse = sorted_positions[1] # note after note 0 -def test_bend_inserts_reset_at_next_note () -> None: + builder.bend(note=0, amount=0.5) - """bend() should insert a pitch_bend(0) at the onset of the following note.""" + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + reset_event = bend_events[-1] - _, builder = _make_builder(length=4) - for pos in (0, 24, 48, 72): - builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) - builder.legato(0.9) + assert reset_event.pulse == next_note_pulse + assert reset_event.value == 0 - sorted_positions = sorted(builder._pattern.steps.keys()) - next_note_pulse = sorted_positions[1] # note after note 0 - builder.bend(note=0, amount=0.5) +def test_bend_reset_wraps_to_bar_start() -> None: + """bend() on the last note must reset at the NEXT cycle's first onset. - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - reset_event = bend_events[-1] + Resetting at pulse 0 cancelled a wrap-spilled bend tail mid-flight, + leaving the next cycle's first note bent. + """ - assert reset_event.pulse == next_note_pulse - assert reset_event.value == 0 + _, builder = _make_builder(length=4) + for pos in (0, 24, 48, 72): + builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) + builder.legato(0.9) + builder.bend(note=-1, amount=0.5) -def test_bend_reset_wraps_to_bar_start () -> None: + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + reset_event = bend_events[-1] - """bend() on the last note must reset at the NEXT cycle's first onset. + # total_pulses (96) + first onset (0) = the next cycle's first onset. + assert reset_event.pulse == 96 + assert reset_event.value == 0 - Resetting at pulse 0 cancelled a wrap-spilled bend tail mid-flight, - leaving the next cycle's first note bent. - """ - _, builder = _make_builder(length=4) - for pos in (0, 24, 48, 72): - builder._pattern.add_note(position=pos, pitch=40, velocity=80, duration=6) - builder.legato(0.9) +def test_bend_with_easing() -> None: + """bend() with shape='ease_in' should produce a non-linear ramp.""" - builder.bend(note=-1, amount=0.5) + _, builder = _make_builder(length=4) + # Single note at pulse 0 with long duration so we can observe the curve + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=48) - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - reset_event = bend_events[-1] + # resolution=48 → 2 events: pulse 0 (t=0) and pulse 48 (t=1) + builder.bend(note=0, amount=1.0, shape="ease_in", resolution=48) - # total_pulses (96) + first onset (0) = the next cycle's first onset. - assert reset_event.pulse == 96 - assert reset_event.value == 0 + bend_events = sorted( + [e for e in builder._pattern.cc_events if e.message_type == "pitchwheel"], + key=lambda e: e.pulse, + ) + ramp_events = [e for e in bend_events if e.value != 0 or e.pulse == 0] + # t=0 → ease_in(0)=0 → value=0; t=1 → ease_in(1)=1 → value≈8191 + assert ramp_events[0].value == 0 + assert ramp_events[-1].value == 8191 -def test_bend_with_easing () -> None: - """bend() with shape='ease_in' should produce a non-linear ramp.""" +def test_bend_empty_pattern() -> None: + """bend() on an empty pattern should be a no-op.""" - _, builder = _make_builder(length=4) - # Single note at pulse 0 with long duration so we can observe the curve - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=48) + _, builder = _make_builder(length=4) + builder.bend(note=0, amount=0.5) # should not raise - # resolution=48 → 2 events: pulse 0 (t=0) and pulse 48 (t=1) - builder.bend(note=0, amount=1.0, shape="ease_in", resolution=48) + assert builder._pattern.cc_events == [] - bend_events = sorted( - [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'], - key=lambda e: e.pulse - ) - ramp_events = [e for e in bend_events if e.value != 0 or e.pulse == 0] - # t=0 → ease_in(0)=0 → value=0; t=1 → ease_in(1)=1 → value≈8191 - assert ramp_events[0].value == 0 - assert ramp_events[-1].value == 8191 +def test_bend_index_out_of_range() -> None: + """bend() with an out-of-range index should raise IndexError.""" + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=6) -def test_bend_empty_pattern () -> None: - - """bend() on an empty pattern should be a no-op.""" - - _, builder = _make_builder(length=4) - builder.bend(note=0, amount=0.5) # should not raise - - assert builder._pattern.cc_events == [] - - -def test_bend_index_out_of_range () -> None: - - """bend() with an out-of-range index should raise IndexError.""" - - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=6) - - with pytest.raises(IndexError): - builder.bend(note=5, amount=0.5) + with pytest.raises(IndexError): + builder.bend(note=5, amount=0.5) # ── p.portamento() ──────────────────────────────────────────────────────────── -def test_portamento_generates_glides_between_notes () -> None: - - """portamento() should insert pitchwheel events in the tail of each note.""" - - _, builder = _make_builder(length=4) - # Two notes at pulse 0 and 48 - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - - builder.portamento(time=0.25, resolution=1, wrap=False) +def test_portamento_generates_glides_between_notes() -> None: + """portamento() should insert pitchwheel events in the tail of each note.""" - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] + _, builder = _make_builder(length=4) + # Two notes at pulse 0 and 48 + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - # Ramp events should be in the tail of note at pulse 0: [0 + int(40*0.75), 0+40] = [30, 40] - ramp_events = [e for e in bend_events if e.value != 0] - assert len(ramp_events) > 0 - for e in ramp_events: - assert 30 <= e.pulse <= 40 + builder.portamento(time=0.25, resolution=1, wrap=False) + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] -def test_portamento_resets_at_each_note_onset () -> None: + # Ramp events should be in the tail of note at pulse 0: [0 + int(40*0.75), 0+40] = [30, 40] + ramp_events = [e for e in bend_events if e.value != 0] + assert len(ramp_events) > 0 + for e in ramp_events: + assert 30 <= e.pulse <= 40 - """portamento() should insert a pitch_bend(0) reset at each destination note onset.""" - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) +def test_portamento_resets_at_each_note_onset() -> None: + """portamento() should insert a pitch_bend(0) reset at each destination note onset.""" - builder.portamento(time=0.25, resolution=1, wrap=False) + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - reset_events = [e for e in bend_events if e.value == 0 and e.pulse == 48] + builder.portamento(time=0.25, resolution=1, wrap=False) - assert len(reset_events) == 1 + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + reset_events = [e for e in bend_events if e.value == 0 and e.pulse == 48] + assert len(reset_events) == 1 -def test_portamento_skips_large_intervals () -> None: - """portamento() should skip pairs whose interval exceeds bend_range.""" +def test_portamento_skips_large_intervals() -> None: + """portamento() should skip pairs whose interval exceeds bend_range.""" - _, builder = _make_builder(length=4) - # Interval of 5 semitones — exceeds default bend_range=2 - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=45, velocity=80, duration=40) + _, builder = _make_builder(length=4) + # Interval of 5 semitones — exceeds default bend_range=2 + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=45, velocity=80, duration=40) - builder.portamento(time=0.25, bend_range=2.0, wrap=False) + builder.portamento(time=0.25, bend_range=2.0, wrap=False) - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - assert len(bend_events) == 0 # skipped — no events generated + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + assert len(bend_events) == 0 # skipped — no events generated -def test_portamento_bend_range_none () -> None: +def test_portamento_bend_range_none() -> None: + """portamento(bend_range=None) should generate events regardless of interval size.""" - """portamento(bend_range=None) should generate events regardless of interval size.""" + _, builder = _make_builder(length=4) + # Large interval + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=55, velocity=80, duration=40) - _, builder = _make_builder(length=4) - # Large interval - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=55, velocity=80, duration=40) + builder.portamento(time=0.25, bend_range=None, wrap=False) - builder.portamento(time=0.25, bend_range=None, wrap=False) + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + # Should have generated events despite large interval; value clamped to ±8191 + assert len(bend_events) > 0 + for e in bend_events: + assert -8192 <= e.value <= 8191 - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - # Should have generated events despite large interval; value clamped to ±8191 - assert len(bend_events) > 0 - for e in bend_events: - assert -8192 <= e.value <= 8191 +def test_portamento_wrap_true() -> None: + """portamento(wrap=True) should glide from the last note toward the first.""" -def test_portamento_wrap_true () -> None: + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - """portamento(wrap=True) should glide from the last note toward the first.""" + builder.portamento(time=0.25, resolution=1, wrap=True) - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) + # With wrap=True the reset lands on the NEXT cycle's first onset + # (total 96 + first onset 0), after any wrap-spilled glide events. + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + wrap_resets = [e for e in bend_events if e.pulse == 96 and e.value == 0] - builder.portamento(time=0.25, resolution=1, wrap=True) + assert len(wrap_resets) >= 1 - # With wrap=True the reset lands on the NEXT cycle's first onset - # (total 96 + first onset 0), after any wrap-spilled glide events. - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - wrap_resets = [e for e in bend_events if e.pulse == 96 and e.value == 0] - assert len(wrap_resets) >= 1 +def test_portamento_wrap_false() -> None: + """portamento(wrap=False) should not generate a glide from the last note.""" + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) -def test_portamento_wrap_false () -> None: + builder.portamento(time=0.25, resolution=1, wrap=False) - """portamento(wrap=False) should not generate a glide from the last note.""" + # With wrap=False there should be no *ramp* events (non-zero value) in the tail + # of the last note (position 48, duration 40, tail starts at 60) + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + events_in_last_note_tail = [e for e in bend_events if e.pulse > 48 and e.value != 0] + assert len(events_in_last_note_tail) == 0 - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - builder.portamento(time=0.25, resolution=1, wrap=False) +def test_portamento_time_fraction() -> None: + """portamento() glide should occupy the correct fraction of the note duration.""" - # With wrap=False there should be no *ramp* events (non-zero value) in the tail - # of the last note (position 48, duration 40, tail starts at 60) - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - events_in_last_note_tail = [e for e in bend_events if e.pulse > 48 and e.value != 0] - assert len(events_in_last_note_tail) == 0 + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) + time_frac = 0.5 + builder.portamento(time=time_frac, resolution=1, wrap=False) -def test_portamento_time_fraction () -> None: + # Collect all pitchwheel events except the reset at pulse 48 + all_bend = [e for e in builder._pattern.cc_events if e.message_type == "pitchwheel"] + ramp_events = [e for e in all_bend if e.pulse != 48] - """portamento() glide should occupy the correct fraction of the note duration.""" - - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - - time_frac = 0.5 - builder.portamento(time=time_frac, resolution=1, wrap=False) - - # Collect all pitchwheel events except the reset at pulse 48 - all_bend = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - ramp_events = [e for e in all_bend if e.pulse != 48] - - # Glide starts at 0 + int(40 * 0.5) = 20, ends at 40 - assert ramp_events[0].pulse == 20 - assert ramp_events[-1].pulse == 40 + # Glide starts at 0 + int(40 * 0.5) = 20, ends at 40 + assert ramp_events[0].pulse == 20 + assert ramp_events[-1].pulse == 40 # ── p.slide() ───────────────────────────────────────────────────────────────── -def test_slide_by_note_index () -> None: - - """slide(notes=[1]) should only glide into the 2nd note.""" - - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - builder._pattern.add_note(position=72, pitch=43, velocity=80, duration=20) - - # Only slide into note index 1 (position 48) - builder.slide(notes=[1], time=0.25, wrap=False) - - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - - # extend=True (default) stretches note 0 to the 48-pulse gap, and the - # glide occupies the tail of the EXTENDED note, landing on the target - # onset: [48 * 0.75, 48] = [36, 48]. - assert builder._pattern.steps[0].notes[0].duration == 48 - - ramp_events = [e for e in bend_events if e.value != 0] - assert len(ramp_events) > 0 - for e in ramp_events: - assert 36 <= e.pulse <= 48 +def test_slide_by_note_index() -> None: + """slide(notes=[1]) should only glide into the 2nd note.""" - # No ramp events in tail of note 1 (pos 48) since note 2 isn't flagged - events_in_note1_tail = [e for e in ramp_events if 60 <= e.pulse <= 72] - assert len(events_in_note1_tail) == 0 + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) + builder._pattern.add_note(position=72, pitch=43, velocity=80, duration=20) + # Only slide into note index 1 (position 48) + builder.slide(notes=[1], time=0.25, wrap=False) -def test_slide_by_step_index () -> None: + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] - """slide(steps=[4]) should slide into the note at step 4 (pulse 24).""" + # extend=True (default) stretches note 0 to the 48-pulse gap, and the + # glide occupies the tail of the EXTENDED note, landing on the target + # onset: [48 * 0.75, 48] = [36, 48]. + assert builder._pattern.steps[0].notes[0].duration == 48 - _, builder = _make_builder(length=4, default_grid=16) - # step 0 → pulse 0, step 4 → pulse 24 (16-step grid over 4 beats = 6 pulses/step) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) - builder._pattern.add_note(position=24, pitch=42, velocity=80, duration=20) + ramp_events = [e for e in bend_events if e.value != 0] + assert len(ramp_events) > 0 + for e in ramp_events: + assert 36 <= e.pulse <= 48 - builder.slide(steps=[4], time=0.5, wrap=False) + # No ramp events in tail of note 1 (pos 48) since note 2 isn't flagged + events_in_note1_tail = [e for e in ramp_events if 60 <= e.pulse <= 72] + assert len(events_in_note1_tail) == 0 - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - # Glide should be in tail of note at pulse 0 - ramp_events = [e for e in bend_events if e.value != 0] - assert len(ramp_events) > 0 - reset_events = [e for e in bend_events if e.pulse == 24 and e.value == 0] - assert len(reset_events) == 1 +def test_slide_by_step_index() -> None: + """slide(steps=[4]) should slide into the note at step 4 (pulse 24).""" + _, builder = _make_builder(length=4, default_grid=16) + # step 0 → pulse 0, step 4 → pulse 24 (16-step grid over 4 beats = 6 pulses/step) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) + builder._pattern.add_note(position=24, pitch=42, velocity=80, duration=20) -def test_slide_extend_true () -> None: + builder.slide(steps=[4], time=0.5, wrap=False) - """slide(extend=True) should extend the preceding note to meet the target.""" + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=20) + # Glide should be in tail of note at pulse 0 + ramp_events = [e for e in bend_events if e.value != 0] + assert len(ramp_events) > 0 + reset_events = [e for e in bend_events if e.pulse == 24 and e.value == 0] + assert len(reset_events) == 1 - builder.slide(notes=[1], time=0.25, extend=True, wrap=False) - # Preceding note (at position 0) should be extended to reach position 48 - preceding_note = builder._pattern.steps[0].notes[0] - assert preceding_note.duration == 48 +def test_slide_extend_true() -> None: + """slide(extend=True) should extend the preceding note to meet the target.""" + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=20) -def test_slide_extend_false () -> None: + builder.slide(notes=[1], time=0.25, extend=True, wrap=False) - """slide(extend=False) should leave the preceding note's duration unchanged.""" + # Preceding note (at position 0) should be extended to reach position 48 + preceding_note = builder._pattern.steps[0].notes[0] + assert preceding_note.duration == 48 - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=20) - builder.slide(notes=[1], time=0.25, extend=False, wrap=False) +def test_slide_extend_false() -> None: + """slide(extend=False) should leave the preceding note's duration unchanged.""" - preceding_note = builder._pattern.steps[0].notes[0] - assert preceding_note.duration == 20 # unchanged + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=20) + builder.slide(notes=[1], time=0.25, extend=False, wrap=False) -def test_slide_requires_notes_or_steps () -> None: + preceding_note = builder._pattern.steps[0].notes[0] + assert preceding_note.duration == 20 # unchanged - """slide() with neither notes nor steps should raise ValueError.""" - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) +def test_slide_requires_notes_or_steps() -> None: + """slide() with neither notes nor steps should raise ValueError.""" - with pytest.raises(ValueError): - builder.slide() + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=20) + with pytest.raises(ValueError): + builder.slide() -def test_slide_wrap () -> None: - """slide(notes=[-1 mapped to last], wrap=True) glides from last to first.""" +def test_slide_wrap() -> None: + """slide(notes=[-1 mapped to last], wrap=True) glides from last to first.""" - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=40) - # Flag note at index 0 as the destination (wrap from last → first) - builder.slide(notes=[0], time=0.5, wrap=True, extend=False) + # Flag note at index 0 as the destination (wrap from last → first) + builder.slide(notes=[0], time=0.5, wrap=True, extend=False) - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] - # Ramp events should be in tail of last note (pos 48, duration 40) - ramp_events = [e for e in bend_events if e.value != 0] - for e in ramp_events: - assert e.pulse >= 48 # within or after the last note's position + # Ramp events should be in tail of last note (pos 48, duration 40) + ramp_events = [e for e in bend_events if e.value != 0] + for e in ramp_events: + assert e.pulse >= 48 # within or after the last note's position - # Reset at the next cycle's first onset (total 96 + onset 0). - wrap_resets = [e for e in bend_events if e.pulse == 96 and e.value == 0] - assert len(wrap_resets) >= 1 + # Reset at the next cycle's first onset (total 96 + onset 0). + wrap_resets = [e for e in bend_events if e.pulse == 96 and e.value == 0] + assert len(wrap_resets) >= 1 # --- bresenham_poly --- -def test_bresenham_poly_full_density_fills_all_steps () -> None: - """Weights summing to 1.0 should produce a hit on every grid step.""" +def test_bresenham_poly_full_density_fills_all_steps() -> None: + """Weights summing to 1.0 should produce a hit on every grid step.""" - drum_map = {"kick": 36, "snare": 38, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + drum_map = {"kick": 36, "snare": 38, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.bresenham_poly( - parts={"kick": 0.25, "snare": 0.25, "hat": 0.5}, - velocity=100, - ) + builder.bresenham_poly( + parts={"kick": 0.25, "snare": 0.25, "hat": 0.5}, + velocity=100, + ) - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert total_notes == 16 + total_notes = sum(len(step.notes) for step in pattern.steps.values()) + assert total_notes == 16 -def test_bresenham_poly_rest_voice_reduces_notes () -> None: +def test_bresenham_poly_rest_voice_reduces_notes() -> None: + """Weights summing to 0.5 should produce half as many notes as grid steps.""" - """Weights summing to 0.5 should produce half as many notes as grid steps.""" + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.bresenham_poly( + parts={"kick": 0.25, "hat": 0.25}, + velocity=100, + ) - builder.bresenham_poly( - parts={"kick": 0.25, "hat": 0.25}, - velocity=100, - ) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) + assert total_notes == 8 - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert total_notes == 8 +def test_bresenham_poly_no_overlaps() -> None: + """No two voices should fire on the same step.""" -def test_bresenham_poly_no_overlaps () -> None: + drum_map = {"kick": 36, "snare": 38, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - """No two voices should fire on the same step.""" + builder.bresenham_poly( + parts={"kick": 0.3, "snare": 0.2, "hat": 0.5}, + velocity=100, + ) - drum_map = {"kick": 36, "snare": 38, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + for step in pattern.steps.values(): + assert len(step.notes) <= 1 - builder.bresenham_poly( - parts={"kick": 0.3, "snare": 0.2, "hat": 0.5}, - velocity=100, - ) - for step in pattern.steps.values(): - assert len(step.notes) <= 1 +def test_bresenham_poly_per_voice_velocity() -> None: + """Each voice should use its own velocity from the velocity dict.""" + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) -def test_bresenham_poly_per_voice_velocity () -> None: + builder.bresenham_poly( + parts={"kick": 0.5, "hat": 0.5}, + velocity={"kick": 127, "hat": 60}, + ) - """Each voice should use its own velocity from the velocity dict.""" + pitch_velocities: dict = {} + for step in pattern.steps.values(): + for note in step.notes: + pitch_velocities[note.pitch] = note.velocity - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + assert pitch_velocities[36] == 127 + assert pitch_velocities[42] == 60 - builder.bresenham_poly( - parts={"kick": 0.5, "hat": 0.5}, - velocity={"kick": 127, "hat": 60}, - ) - pitch_velocities: dict = {} - for step in pattern.steps.values(): - for note in step.notes: - pitch_velocities[note.pitch] = note.velocity +def test_bresenham_poly_scalar_velocity_applies_to_all() -> None: + """A single int velocity should apply to every placed note.""" - assert pitch_velocities[36] == 127 - assert pitch_velocities[42] == 60 + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.bresenham_poly( + parts={"kick": 0.5, "hat": 0.5}, + velocity=77, + ) -def test_bresenham_poly_scalar_velocity_applies_to_all () -> None: + for step in pattern.steps.values(): + for note in step.notes: + assert note.velocity == 77 - """A single int velocity should apply to every placed note.""" - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) +def test_bresenham_poly_velocity_dict_missing_key_uses_default() -> None: + """Voices absent from the velocity dict should use DEFAULT_VELOCITY.""" - builder.bresenham_poly( - parts={"kick": 0.5, "hat": 0.5}, - velocity=77, - ) + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - for step in pattern.steps.values(): - for note in step.notes: - assert note.velocity == 77 + builder.bresenham_poly( + parts={"kick": 0.5, "hat": 0.5}, + velocity={"kick": 127}, # hat not specified + ) + for step in pattern.steps.values(): + for note in step.notes: + if note.pitch == 42: + assert note.velocity == subsequence.constants.velocity.DEFAULT_VELOCITY -def test_bresenham_poly_velocity_dict_missing_key_uses_default () -> None: - """Voices absent from the velocity dict should use DEFAULT_VELOCITY.""" +def test_bresenham_poly_dropout_reduces_notes() -> None: + """Dropout should reduce the total number of placed notes.""" - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - builder.bresenham_poly( - parts={"kick": 0.5, "hat": 0.5}, - velocity={"kick": 127}, # hat not specified - ) + builder.bresenham_poly( + parts={"kick": 0.5, "hat": 0.5}, + velocity=100, + probability=0.5, + ) - for step in pattern.steps.values(): - for note in step.notes: - if note.pitch == 42: - assert note.velocity == subsequence.constants.velocity.DEFAULT_VELOCITY + total_notes = sum(len(step.notes) for step in pattern.steps.values()) + assert 0 < total_notes < 16 -def test_bresenham_poly_dropout_reduces_notes () -> None: +def test_bresenham_poly_custom_grid() -> None: + """A custom grid should be used as the step count.""" - """Dropout should reduce the total number of placed notes.""" + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + builder.bresenham_poly( + parts={"kick": 0.5, "hat": 0.5}, + velocity=100, + grid=8, + ) - builder.bresenham_poly( - parts={"kick": 0.5, "hat": 0.5}, - velocity=100, - probability=0.5, - ) + total_notes = sum(len(step.notes) for step in pattern.steps.values()) + assert total_notes == 8 - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert 0 < total_notes < 16 +def test_bresenham_poly_empty_parts_raises() -> None: + """An empty parts dict should raise ValueError.""" -def test_bresenham_poly_custom_grid () -> None: + pattern, builder = _make_builder(length=4) - """A custom grid should be used as the step count.""" + with pytest.raises(ValueError): + builder.bresenham_poly(parts={}) - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.bresenham_poly( - parts={"kick": 0.5, "hat": 0.5}, - velocity=100, - grid=8, - ) +def test_bresenham_poly_negative_weight_raises() -> None: + """Negative density weights should raise ValueError.""" - total_notes = sum(len(step.notes) for step in pattern.steps.values()) - assert total_notes == 8 + drum_map = {"kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + with pytest.raises(ValueError): + builder.bresenham_poly(parts={"kick": -0.5}) -def test_bresenham_poly_empty_parts_raises () -> None: - """An empty parts dict should raise ValueError.""" +def test_bresenham_poly_deterministic_with_seed() -> None: + """Same seed should produce the same pattern when dropout is used.""" - pattern, builder = _make_builder(length=4) + drum_map = {"kick": 36, "hat": 42} - with pytest.raises(ValueError): - builder.bresenham_poly(parts={}) + def build(seed: int) -> set: + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(seed) + builder.bresenham_poly( + parts={"kick": 0.25, "hat": 0.5}, + velocity=100, + probability=0.7, + ) + return set(pattern.steps.keys()) - -def test_bresenham_poly_negative_weight_raises () -> None: - - """Negative density weights should raise ValueError.""" - - drum_map = {"kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - - with pytest.raises(ValueError): - builder.bresenham_poly(parts={"kick": -0.5}) - - -def test_bresenham_poly_deterministic_with_seed () -> None: - - """Same seed should produce the same pattern when dropout is used.""" - - drum_map = {"kick": 36, "hat": 42} - - def build (seed: int) -> set: - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(seed) - builder.bresenham_poly( - parts={"kick": 0.25, "hat": 0.5}, - velocity=100, - probability=0.7, - ) - return set(pattern.steps.keys()) - - assert build(42) == build(42) + assert build(42) == build(42) # --- no_overlap --- -def test_bresenham_no_overlap_skips_existing_pitch () -> None: - - """With no_overlap=True, bresenham should not place a note where the same pitch exists.""" - drum_map = {"kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) +def test_bresenham_no_overlap_skips_existing_pitch() -> None: + """With no_overlap=True, bresenham should not place a note where the same pitch exists.""" - # Place anchor kicks on beats 1, 2, 3, 4 (steps 0, 4, 8, 12). - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - anchors_placed = sum(len(s.notes) for s in pattern.steps.values()) - assert anchors_placed == 4 + drum_map = {"kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - # Ghost kicks with no_overlap — should skip steps that already have kick. - builder.bresenham("kick", pulses=5, velocity=45, no_overlap=True) + # Place anchor kicks on beats 1, 2, 3, 4 (steps 0, 4, 8, 12). + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + anchors_placed = sum(len(s.notes) for s in pattern.steps.values()) + assert anchors_placed == 4 - for step in pattern.steps.values(): - kick_notes = [n for n in step.notes if n.pitch == 36] - assert len(kick_notes) <= 1 + # Ghost kicks with no_overlap — should skip steps that already have kick. + builder.bresenham("kick", pulses=5, velocity=45, no_overlap=True) + for step in pattern.steps.values(): + kick_notes = [n for n in step.notes if n.pitch == 36] + assert len(kick_notes) <= 1 -def test_bresenham_without_no_overlap_allows_duplicates () -> None: - """Without no_overlap, bresenham places notes even where the same pitch exists.""" +def test_bresenham_without_no_overlap_allows_duplicates() -> None: + """Without no_overlap, bresenham places notes even where the same pitch exists.""" - drum_map = {"kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + drum_map = {"kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - builder.bresenham("kick", pulses=5, velocity=45) + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + builder.bresenham("kick", pulses=5, velocity=45) - total_notes = sum(len(s.notes) for s in pattern.steps.values()) - assert total_notes == 9 # 4 anchors + 5 ghost kicks, overlaps allowed + total_notes = sum(len(s.notes) for s in pattern.steps.values()) + assert total_notes == 9 # 4 anchors + 5 ghost kicks, overlaps allowed -def test_euclidean_no_overlap () -> None: +def test_euclidean_no_overlap() -> None: + """no_overlap should work for euclidean too (shared _place_rhythm_sequence).""" - """no_overlap should work for euclidean too (shared _place_rhythm_sequence).""" + drum_map = {"snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - drum_map = {"snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.hit_steps("snare", [4, 12], velocity=100) + builder.euclidean("snare", pulses=4, velocity=50, no_overlap=True) - builder.hit_steps("snare", [4, 12], velocity=100) - builder.euclidean("snare", pulses=4, velocity=50, no_overlap=True) + for step in pattern.steps.values(): + snare_notes = [n for n in step.notes if n.pitch == 38] + assert len(snare_notes) <= 1 - for step in pattern.steps.values(): - snare_notes = [n for n in step.notes if n.pitch == 38] - assert len(snare_notes) <= 1 +def test_bresenham_poly_no_overlap() -> None: + """bresenham_poly with no_overlap should skip steps with existing same-pitch notes.""" -def test_bresenham_poly_no_overlap () -> None: + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - """bresenham_poly with no_overlap should skip steps with existing same-pitch notes.""" + # Place kick anchors first. + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + # Poly layer includes kick at ghost velocity — should skip occupied kick steps. + builder.bresenham_poly( + parts={"kick": 0.5, "hat": 0.5}, + velocity={"kick": 45, "hat": 70}, + no_overlap=True, + ) - # Place kick anchors first. - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + for step in pattern.steps.values(): + kick_notes = [n for n in step.notes if n.pitch == 36] + assert len(kick_notes) <= 1 - # Poly layer includes kick at ghost velocity — should skip occupied kick steps. - builder.bresenham_poly( - parts={"kick": 0.5, "hat": 0.5}, - velocity={"kick": 45, "hat": 70}, - no_overlap=True, - ) - for step in pattern.steps.values(): - kick_notes = [n for n in step.notes if n.pitch == 36] - assert len(kick_notes) <= 1 +def test_no_overlap_allows_different_pitches_on_same_step() -> None: + """no_overlap only prevents same-pitch collisions — different pitches can share a step.""" + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) -def test_no_overlap_allows_different_pitches_on_same_step () -> None: + # Place kicks on every beat. + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - """no_overlap only prevents same-pitch collisions — different pitches can share a step.""" + # Bresenham hat with no_overlap — hats are a different pitch, should still be placed. + builder.bresenham("hat", pulses=4, velocity=70, no_overlap=True) - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - - # Place kicks on every beat. - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - - # Bresenham hat with no_overlap — hats are a different pitch, should still be placed. - builder.bresenham("hat", pulses=4, velocity=70, no_overlap=True) - - total_notes = sum(len(s.notes) for s in pattern.steps.values()) - assert total_notes == 8 # 4 kicks + 4 hats, no collisions because different pitches + total_notes = sum(len(s.notes) for s in pattern.steps.values()) + assert total_notes == 8 # 4 kicks + 4 hats, no collisions because different pitches # --- ghost_fill --- -def test_ghost_fill_places_notes () -> None: - - """ghost_fill should place at least some notes at moderate density.""" - - drum_map = {"snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - - builder.ghost_fill("snare", density=0.5, velocity=35, bias="uniform") - - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total > 0 - - -def test_ghost_fill_no_overlap_respects_anchors () -> None: - - """ghost_fill with no_overlap should not place notes where anchors exist.""" +def test_ghost_fill_places_notes() -> None: + """ghost_fill should place at least some notes at moderate density.""" - drum_map = {"kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + drum_map = {"snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - builder.ghost_fill("kick", density=1.0, velocity=40, bias="uniform", no_overlap=True) + builder.ghost_fill("snare", density=0.5, velocity=35, bias="uniform") - # Check anchor positions — should have exactly 1 kick each - step_dur = 4.0 / 16 - for step_idx in [0, 4, 8, 12]: - pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) - if pulse in pattern.steps: - kick_notes = [n for n in pattern.steps[pulse].notes if n.pitch == 36] - assert len(kick_notes) == 1 + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total > 0 -def test_ghost_fill_sixteenths_bias () -> None: +def test_ghost_fill_no_overlap_respects_anchors() -> None: + """ghost_fill with no_overlap should not place notes where anchors exist.""" - """Sixteenths bias should strongly prefer non-downbeat positions.""" + drum_map = {"kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + builder.ghost_fill( + "kick", density=1.0, velocity=40, bias="uniform", no_overlap=True + ) - builder.ghost_fill("hat", density=0.8, velocity=50, bias="sixteenths") + # Check anchor positions — should have exactly 1 kick each + step_dur = 4.0 / 16 + for step_idx in [0, 4, 8, 12]: + pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) + if pulse in pattern.steps: + kick_notes = [n for n in pattern.steps[pulse].notes if n.pitch == 36] + assert len(kick_notes) == 1 - # Count hits on downbeats vs offbeats - step_dur = 4.0 / 16 - downbeat_hits = 0 - offbeat_hits = 0 - for step_idx in range(16): - pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) - if pulse in pattern.steps: - count = len(pattern.steps[pulse].notes) - if step_idx % 4 == 0: - downbeat_hits += count - else: - offbeat_hits += count - assert offbeat_hits > downbeat_hits +def test_ghost_fill_sixteenths_bias() -> None: + """Sixteenths bias should strongly prefer non-downbeat positions.""" + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) -def test_ghost_fill_velocity_tuple () -> None: + builder.ghost_fill("hat", density=0.8, velocity=50, bias="sixteenths") - """When velocity is a (low, high) tuple, all velocities should be in range.""" + # Count hits on downbeats vs offbeats + step_dur = 4.0 / 16 + downbeat_hits = 0 + offbeat_hits = 0 + for step_idx in range(16): + pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) + if pulse in pattern.steps: + count = len(pattern.steps[pulse].notes) + if step_idx % 4 == 0: + downbeat_hits += count + else: + offbeat_hits += count - drum_map = {"snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + assert offbeat_hits > downbeat_hits - builder.ghost_fill("snare", density=0.8, velocity=(25, 45), bias="uniform") - for step in pattern.steps.values(): - for note in step.notes: - assert 25 <= note.velocity <= 45 +def test_ghost_fill_velocity_tuple() -> None: + """When velocity is a (low, high) tuple, all velocities should be in range.""" + drum_map = {"snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) -def test_ghost_fill_density_zero () -> None: + builder.ghost_fill("snare", density=0.8, velocity=(25, 45), bias="uniform") - """Density 0 should place no notes.""" + for step in pattern.steps.values(): + for note in step.notes: + assert 25 <= note.velocity <= 45 - drum_map = {"snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.ghost_fill("snare", density=0.0, velocity=35) +def test_ghost_fill_density_zero() -> None: + """Density 0 should place no notes.""" - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total == 0 + drum_map = {"snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.ghost_fill("snare", density=0.0, velocity=35) -def test_ghost_fill_custom_bias_list () -> None: + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total == 0 - """A custom probability list should work as bias.""" - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) +def test_ghost_fill_custom_bias_list() -> None: + """A custom probability list should work as bias.""" - # Only allow ghost notes on steps 0 and 8 - probs = [0.0] * 16 - probs[0] = 1.0 - probs[8] = 1.0 + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - builder.ghost_fill("hat", density=1.0, velocity=60, bias=probs) + # Only allow ghost notes on steps 0 and 8 + probs = [0.0] * 16 + probs[0] = 1.0 + probs[8] = 1.0 - # All hits should be on steps 0 and 8 only - step_dur = 4.0 / 16 - for step_idx in range(16): - pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) - if pulse in pattern.steps and pattern.steps[pulse].notes: - assert step_idx in (0, 8), f"Unexpected hit at step {step_idx}" + builder.ghost_fill("hat", density=1.0, velocity=60, bias=probs) + # All hits should be on steps 0 and 8 only + step_dur = 4.0 / 16 + for step_idx in range(16): + pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) + if pulse in pattern.steps and pattern.steps[pulse].notes: + assert step_idx in (0, 8), f"Unexpected hit at step {step_idx}" -def test_ghost_fill_unknown_bias_raises () -> None: - """An unknown bias string should raise ValueError.""" +def test_ghost_fill_unknown_bias_raises() -> None: + """An unknown bias string should raise ValueError.""" - drum_map = {"snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + drum_map = {"snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - with pytest.raises(ValueError, match="Unknown ghost_fill bias"): - builder.ghost_fill("snare", density=0.5, bias="nonexistent") + with pytest.raises(ValueError, match="Unknown ghost_fill bias"): + builder.ghost_fill("snare", density=0.5, bias="nonexistent") -@pytest.mark.parametrize("bias, beat_weights", [ - ("uniform", [1.0, 1.0, 1.0, 1.0]), - ("offbeat", [0.05, 0.3, 1.0, 0.3]), - ("sixteenths", [0.05, 1.0, 0.3, 1.0]), - ("before", [0.05, 0.25, 0.25, 1.0]), - ("after", [0.05, 1.0, 0.25, 0.25]), - ("downbeat", [1.0, 0.05, 0.15, 0.05]), - ("upbeat", [0.05, 0.05, 1.0, 0.05]), - ("e_and_a", [0.05, 1.0, 1.0, 1.0]), -]) -def test_build_ghost_bias_curves (bias: str, beat_weights: typing.List[float]) -> None: +@pytest.mark.parametrize( + "bias, beat_weights", + [ + ("uniform", [1.0, 1.0, 1.0, 1.0]), + ("offbeat", [0.05, 0.3, 1.0, 0.3]), + ("sixteenths", [0.05, 1.0, 0.3, 1.0]), + ("before", [0.05, 0.25, 0.25, 1.0]), + ("after", [0.05, 1.0, 0.25, 0.25]), + ("downbeat", [1.0, 0.05, 0.15, 0.05]), + ("upbeat", [0.05, 0.05, 1.0, 0.05]), + ("e_and_a", [0.05, 1.0, 1.0, 1.0]), + ], +) +def test_build_ghost_bias_curves(bias: str, beat_weights: typing.List[float]) -> None: + """Each named bias curve repeats its documented weight shape every beat. - """Each named bias curve repeats its documented weight shape every beat. + At grid=16 a beat spans 4 steps (downbeat, "e", "&", "a"); the weight at + each position pins where the curve concentrates its probability. + """ - At grid=16 a beat spans 4 steps (downbeat, "e", "&", "a"); the weight at - each position pins where the curve concentrates its probability. - """ + weights = subsequence.pattern_builder.PatternBuilder.build_ghost_bias(16, bias) - weights = subsequence.pattern_builder.PatternBuilder.build_ghost_bias(16, bias) + assert weights == beat_weights * 4 - assert weights == beat_weights * 4 +def test_ghost_fill_deterministic() -> None: + """Same seed should produce the same ghost pattern.""" -def test_ghost_fill_deterministic () -> None: + drum_map = {"snare": 38} - """Same seed should produce the same ghost pattern.""" + def _run(seed: int) -> list: + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(seed) + builder.ghost_fill("snare", density=0.4, velocity=35, bias="sixteenths") + return sorted(pattern.steps.keys()) - drum_map = {"snare": 38} + assert _run(42) == _run(42) - def _run (seed: int) -> list: - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(seed) - builder.ghost_fill("snare", density=0.4, velocity=35, bias="sixteenths") - return sorted(pattern.steps.keys()) - assert _run(42) == _run(42) +def test_ghost_fill_velocity_sequence() -> None: + """A sequence passed to velocity should assign per-step velocities.""" + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) -def test_ghost_fill_velocity_sequence () -> None: + velocities = [20, 40, 60, 80] + builder.ghost_fill(38, density=1.0, velocity=velocities, bias="uniform") - """A sequence passed to velocity should assign per-step velocities.""" + # grid is 16 by default, so steps 0-15. + for step_idx in range(16): + pulse = int(step_idx * 0.25 * subsequence.constants.MIDI_QUARTER_NOTE) + assert ( + pattern.steps[pulse].notes[0].velocity + == velocities[step_idx % len(velocities)] + ) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - velocities = [20, 40, 60, 80] - builder.ghost_fill(38, density=1.0, velocity=velocities, bias="uniform") +def test_ghost_fill_velocity_callable() -> None: + """A callable passed to velocity should evaluate per step.""" - # grid is 16 by default, so steps 0-15. - for step_idx in range(16): - pulse = int(step_idx * 0.25 * subsequence.constants.MIDI_QUARTER_NOTE) - assert pattern.steps[pulse].notes[0].velocity == velocities[step_idx % len(velocities)] + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) + def my_vel(i: int) -> int: + return 10 + (i * 5) -def test_ghost_fill_velocity_callable () -> None: + builder.ghost_fill(38, density=1.0, velocity=my_vel, bias="uniform") - """A callable passed to velocity should evaluate per step.""" + for step_idx in range(16): + pulse = int(step_idx * 0.25 * subsequence.constants.MIDI_QUARTER_NOTE) + assert pattern.steps[pulse].notes[0].velocity == 10 + (step_idx * 5) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - - def my_vel (i: int) -> int: - return 10 + (i * 5) - - builder.ghost_fill(38, density=1.0, velocity=my_vel, bias="uniform") - - for step_idx in range(16): - pulse = int(step_idx * 0.25 * subsequence.constants.MIDI_QUARTER_NOTE) - assert pattern.steps[pulse].notes[0].velocity == 10 + (step_idx * 5) # --- cellular_1d --- -def test_cellular_places_notes () -> None: - - """cellular_1d() should place notes from the CA pattern.""" - - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - - # Use generation 5 where Rule 30 produces multiple active cells - builder.cellular_1d("hat", rule=30, generation=5, velocity=50) +def test_cellular_places_notes() -> None: + """cellular_1d() should place notes from the CA pattern.""" - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total > 0 + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) + # Use generation 5 where Rule 30 produces multiple active cells + builder.cellular_1d("hat", rule=30, generation=5, velocity=50) -def test_cellular_evolves_across_cycles () -> None: + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total > 0 - """Different generations should produce different patterns.""" - drum_map = {"hat": 42} +def test_cellular_evolves_across_cycles() -> None: + """Different generations should produce different patterns.""" - def _run (gen: int) -> list: - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - builder.cellular_1d("hat", rule=30, generation=gen, velocity=50) - return sorted(pattern.steps.keys()) + drum_map = {"hat": 42} - assert _run(5) != _run(10) + def _run(gen: int) -> list: + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) + builder.cellular_1d("hat", rule=30, generation=gen, velocity=50) + return sorted(pattern.steps.keys()) + assert _run(5) != _run(10) -def test_cellular_no_overlap () -> None: - """cellular_1d with no_overlap should skip positions where the pitch exists.""" +def test_cellular_no_overlap() -> None: + """cellular_1d with no_overlap should skip positions where the pitch exists.""" - drum_map = {"kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + drum_map = {"kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - builder.cellular_1d("kick", rule=30, generation=5, velocity=40, no_overlap=True) + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + builder.cellular_1d("kick", rule=30, generation=5, velocity=40, no_overlap=True) - # Anchor positions should have exactly 1 kick - step_dur = 4.0 / 16 - for step_idx in [0, 4, 8, 12]: - pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) - if pulse in pattern.steps: - kick_notes = [n for n in pattern.steps[pulse].notes if n.pitch == 36] - assert len(kick_notes) == 1 + # Anchor positions should have exactly 1 kick + step_dur = 4.0 / 16 + for step_idx in [0, 4, 8, 12]: + pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) + if pulse in pattern.steps: + kick_notes = [n for n in pattern.steps[pulse].notes if n.pitch == 36] + assert len(kick_notes) == 1 -def test_cellular_defaults_to_cycle () -> None: +def test_cellular_defaults_to_cycle() -> None: + """When generation is not specified, it should use self.cycle.""" - """When generation is not specified, it should use self.cycle.""" + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + # Set cycle to a specific value + builder.cycle = 7 + builder.cellular_1d("hat", rule=30, velocity=50) - # Set cycle to a specific value - builder.cycle = 7 - builder.cellular_1d("hat", rule=30, velocity=50) + # Compare with explicit generation=7 + pattern2, builder2 = _make_builder(length=4, drum_note_map=drum_map) + builder2.rng = random.Random(42) + builder2.cellular_1d("hat", rule=30, generation=7, velocity=50) - # Compare with explicit generation=7 - pattern2, builder2 = _make_builder(length=4, drum_note_map=drum_map) - builder2.rng = random.Random(42) - builder2.cellular_1d("hat", rule=30, generation=7, velocity=50) + assert sorted(pattern.steps.keys()) == sorted(pattern2.steps.keys()) - assert sorted(pattern.steps.keys()) == sorted(pattern2.steps.keys()) +def test_cellular_dropout() -> None: + """Dropout should reduce the number of placed notes.""" -def test_cellular_dropout () -> None: + drum_map = {"hat": 42} - """Dropout should reduce the number of placed notes.""" + pattern_full, builder_full = _make_builder(length=4, drum_note_map=drum_map) + builder_full.rng = random.Random(42) + builder_full.cellular_1d( + "hat", rule=30, generation=10, velocity=50, probability=1.0 + ) - drum_map = {"hat": 42} + pattern_drop, builder_drop = _make_builder(length=4, drum_note_map=drum_map) + builder_drop.rng = random.Random(42) + builder_drop.cellular_1d( + "hat", rule=30, generation=10, velocity=50, probability=0.5 + ) - pattern_full, builder_full = _make_builder(length=4, drum_note_map=drum_map) - builder_full.rng = random.Random(42) - builder_full.cellular_1d("hat", rule=30, generation=10, velocity=50, probability=1.0) + full_count = sum(len(s.notes) for s in pattern_full.steps.values()) + drop_count = sum(len(s.notes) for s in pattern_drop.steps.values()) - pattern_drop, builder_drop = _make_builder(length=4, drum_note_map=drum_map) - builder_drop.rng = random.Random(42) - builder_drop.cellular_1d("hat", rule=30, generation=10, velocity=50, probability=0.5) - - full_count = sum(len(s.notes) for s in pattern_full.steps.values()) - drop_count = sum(len(s.notes) for s in pattern_drop.steps.values()) - - assert drop_count < full_count + assert drop_count < full_count # --- cellular_2d --- -def test_cellular_2d_places_notes () -> None: - - """cellular_2d() should place notes from live cells in the grid.""" - - drum_map = {"kick": 36, "snare": 38, "hat": 42, "open": 46} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - - pitches = ["kick", "snare", "hat", "open"] - builder.cellular_2d(pitches, rule="B368/S245", generation=5, initial_state="random", seed=99, density=0.4) - - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total > 0 - - -def test_cellular_2d_pitch_mapping () -> None: - - """Each row in the grid should map to the corresponding pitch.""" - - drum_map = {"kick": 36, "snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - - # Explicit seed: only row 0 has live cells (kick only) - seed_grid = [[1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] - builder.cellular_2d(["kick", "snare"], generation=0, initial_state=seed_grid) - - all_pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] - assert 36 in all_pitches # kick present - assert 38 not in all_pitches # snare absent - - -def test_cellular_2d_velocity_single () -> None: - - """A single velocity int should apply to all rows.""" +def test_cellular_2d_places_notes() -> None: + """cellular_2d() should place notes from live cells in the grid.""" + + drum_map = {"kick": 36, "snare": 38, "hat": 42, "open": 46} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + pitches = ["kick", "snare", "hat", "open"] + builder.cellular_2d( + pitches, + rule="B368/S245", + generation=5, + initial_state="random", + seed=99, + density=0.4, + ) - seed_grid = [[1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], - [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]] - builder.cellular_2d(["kick", "hat"], generation=0, initial_state=seed_grid, velocity=77) + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total > 0 - all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] - assert all(v == 77 for v in all_velocities) +def test_cellular_2d_pitch_mapping() -> None: + """Each row in the grid should map to the corresponding pitch.""" -def test_cellular_2d_velocity_list () -> None: + drum_map = {"kick": 36, "snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - """A velocity list should apply per-row velocities.""" + # Explicit seed: only row 0 has live cells (kick only) + seed_grid = [ + [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] + builder.cellular_2d(["kick", "snare"], generation=0, initial_state=seed_grid) + + all_pitches = [n.pitch for step in pattern.steps.values() for n in step.notes] + assert 36 in all_pitches # kick present + assert 38 not in all_pitches # snare absent + + +def test_cellular_2d_velocity_single() -> None: + """A single velocity int should apply to all rows.""" - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - # Row 0 (kick): velocity 90. Row 1 (hat): velocity 50. - seed_grid = [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]] - builder.cellular_2d(["kick", "hat"], generation=0, initial_state=seed_grid, velocity=[90, 50]) + seed_grid = [ + [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1], + ] + builder.cellular_2d( + ["kick", "hat"], generation=0, initial_state=seed_grid, velocity=77 + ) + + all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] + assert all(v == 77 for v in all_velocities) - kick_velocities = [ - n.velocity for step in pattern.steps.values() - for n in step.notes if n.pitch == 36 - ] - hat_velocities = [ - n.velocity for step in pattern.steps.values() - for n in step.notes if n.pitch == 42 - ] - assert all(v == 90 for v in kick_velocities) - assert all(v == 50 for v in hat_velocities) +def test_cellular_2d_velocity_list() -> None: + """A velocity list should apply per-row velocities.""" + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) -def test_cellular_2d_velocity_tuple_range () -> None: + # Row 0 (kick): velocity 90. Row 1 (hat): velocity 50. + seed_grid = [ + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + ] + builder.cellular_2d( + ["kick", "hat"], generation=0, initial_state=seed_grid, velocity=[90, 50] + ) - """cellular_2d(velocity=(60, 90)) draws each note's velocity from the range.""" + kick_velocities = [ + n.velocity + for step in pattern.steps.values() + for n in step.notes + if n.pitch == 36 + ] + hat_velocities = [ + n.velocity + for step in pattern.steps.values() + for n in step.notes + if n.pitch == 42 + ] - drum_map = {"kick": 36, "hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + assert all(v == 90 for v in kick_velocities) + assert all(v == 50 for v in hat_velocities) - seed_grid = [[1] * 16, [1] * 16] - builder.cellular_2d(["kick", "hat"], generation=0, initial_state=seed_grid, velocity=(60, 90)) - all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] +def test_cellular_2d_velocity_tuple_range() -> None: + """cellular_2d(velocity=(60, 90)) draws each note's velocity from the range.""" - assert all_velocities - assert all(60 <= v <= 90 for v in all_velocities) + drum_map = {"kick": 36, "hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) + seed_grid = [[1] * 16, [1] * 16] + builder.cellular_2d( + ["kick", "hat"], generation=0, initial_state=seed_grid, velocity=(60, 90) + ) + + all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] + + assert all_velocities + assert all(60 <= v <= 90 for v in all_velocities) + + +def test_cellular_2d_seed_one_deterministic() -> None: + """Two identical cellular_2d calls with seed=1 produce identical grids (seed=1 must not fall back to an unseeded fill).""" + + drum_map = {"kick": 36, "hat": 42} -def test_cellular_2d_seed_one_deterministic () -> None: + pattern1, builder1 = _make_builder(length=4, drum_note_map=drum_map) + builder1.rng = random.Random(42) + builder1.cellular_2d( + ["kick", "hat"], generation=3, initial_state="random", seed=1, density=0.5 + ) - """Two identical cellular_2d calls with seed=1 produce identical grids (seed=1 must not fall back to an unseeded fill).""" + pattern2, builder2 = _make_builder(length=4, drum_note_map=drum_map) + builder2.rng = random.Random(42) + builder2.cellular_2d( + ["kick", "hat"], generation=3, initial_state="random", seed=1, density=0.5 + ) - drum_map = {"kick": 36, "hat": 42} + notes1 = [ + (pos, n.pitch) + for pos in sorted(pattern1.steps) + for n in pattern1.steps[pos].notes + ] + notes2 = [ + (pos, n.pitch) + for pos in sorted(pattern2.steps) + for n in pattern2.steps[pos].notes + ] - pattern1, builder1 = _make_builder(length=4, drum_note_map=drum_map) - builder1.rng = random.Random(42) - builder1.cellular_2d(["kick", "hat"], generation=3, initial_state="random", seed=1, density=0.5) + assert notes1 + assert notes1 == notes2 - pattern2, builder2 = _make_builder(length=4, drum_note_map=drum_map) - builder2.rng = random.Random(42) - builder2.cellular_2d(["kick", "hat"], generation=3, initial_state="random", seed=1, density=0.5) - notes1 = [(pos, n.pitch) for pos in sorted(pattern1.steps) for n in pattern1.steps[pos].notes] - notes2 = [(pos, n.pitch) for pos in sorted(pattern2.steps) for n in pattern2.steps[pos].notes] +def test_cellular_2d_generation_none_uses_cycle() -> None: + """When generation is None, it should use self.cycle.""" - assert notes1 - assert notes1 == notes2 + drum_map = {"hat": 42, "snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) + builder.cycle = 5 + pattern2, builder2 = _make_builder(length=4, drum_note_map=drum_map) + builder2.rng = random.Random(42) -def test_cellular_2d_generation_none_uses_cycle () -> None: + pitches = ["hat", "snare"] + builder.cellular_2d(pitches, initial_state="random", seed=42, density=0.4) + builder2.cellular_2d( + pitches, generation=5, initial_state="random", seed=42, density=0.4 + ) - """When generation is None, it should use self.cycle.""" + assert sorted(pattern.steps.keys()) == sorted(pattern2.steps.keys()) - drum_map = {"hat": 42, "snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - builder.cycle = 5 - pattern2, builder2 = _make_builder(length=4, drum_note_map=drum_map) - builder2.rng = random.Random(42) +def test_cellular_2d_drum_name_strings() -> None: + """Pitches passed as drum name strings resolve to MIDI numbers via drum_note_map.""" - pitches = ["hat", "snare"] - builder.cellular_2d(pitches, initial_state="random", seed=42, density=0.4) - builder2.cellular_2d(pitches, generation=5, initial_state="random", seed=42, density=0.4) + drum_map = {"c4": 60, "g4": 67} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - assert sorted(pattern.steps.keys()) == sorted(pattern2.steps.keys()) + seed_grid = [ + [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1], + ] + builder.cellular_2d(["c4", "g4"], generation=0, initial_state=seed_grid) + all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} + assert 60 in all_pitches + assert 67 in all_pitches -def test_cellular_2d_drum_name_strings () -> None: - """Pitches passed as drum name strings resolve to MIDI numbers via drum_note_map.""" +def test_cellular_2d_dropout() -> None: + """Dropout should reduce the number of placed notes.""" - drum_map = {"c4": 60, "g4": 67} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) + drum_map = {"hat": 42, "kick": 36} + pitches = ["hat", "kick"] - seed_grid = [[1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], - [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]] - builder.cellular_2d(["c4", "g4"], generation=0, initial_state=seed_grid) + pattern_full, builder_full = _make_builder(length=4, drum_note_map=drum_map) + builder_full.rng = random.Random(42) + builder_full.cellular_2d( + pitches, + initial_state="random", + seed=7, + density=0.6, + generation=3, + probability=1.0, + ) - all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} - assert 60 in all_pitches - assert 67 in all_pitches + pattern_drop, builder_drop = _make_builder(length=4, drum_note_map=drum_map) + builder_drop.rng = random.Random(42) + builder_drop.cellular_2d( + pitches, + initial_state="random", + seed=7, + density=0.6, + generation=3, + probability=0.2, + ) + full_count = sum(len(s.notes) for s in pattern_full.steps.values()) + drop_count = sum(len(s.notes) for s in pattern_drop.steps.values()) -def test_cellular_2d_dropout () -> None: + assert drop_count < full_count - """Dropout should reduce the number of placed notes.""" - drum_map = {"hat": 42, "kick": 36} - pitches = ["hat", "kick"] +def test_cellular_2d_invalid_rule_raises() -> None: + """An invalid rule string should raise ValueError.""" - pattern_full, builder_full = _make_builder(length=4, drum_note_map=drum_map) - builder_full.rng = random.Random(42) - builder_full.cellular_2d(pitches, initial_state="random", seed=7, density=0.6, generation=3, probability=1.0) + import pytest - pattern_drop, builder_drop = _make_builder(length=4, drum_note_map=drum_map) - builder_drop.rng = random.Random(42) - builder_drop.cellular_2d(pitches, initial_state="random", seed=7, density=0.6, generation=3, probability=0.2) + drum_map = {"kick": 36} + _, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - full_count = sum(len(s.notes) for s in pattern_full.steps.values()) - drop_count = sum(len(s.notes) for s in pattern_drop.steps.values()) - - assert drop_count < full_count - - -def test_cellular_2d_invalid_rule_raises () -> None: - - """An invalid rule string should raise ValueError.""" - - import pytest - - drum_map = {"kick": 36} - _, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - - with pytest.raises(ValueError): - builder.cellular_2d(["kick"], rule="notarule", initial_state="random", seed=42) + with pytest.raises(ValueError): + builder.cellular_2d(["kick"], rule="notarule", initial_state="random", seed=42) # --- p.markov() --- -def test_markov_places_notes () -> None: - - """markov() should place at least one note for a simple chain.""" - - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - - builder.markov( - transitions={"root": [("3rd", 2), ("5th", 1)], "3rd": [("root", 1)], "5th": [("root", 1)]}, - pitch_map={"root": 52, "3rd": 56, "5th": 59}, - velocity=80, - ) +def test_markov_places_notes() -> None: + """markov() should place at least one note for a simple chain.""" - total = sum(len(s.notes) for s in pattern.steps.values()) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - assert total > 0 + builder.markov( + transitions={ + "root": [("3rd", 2), ("5th", 1)], + "3rd": [("root", 1)], + "5th": [("root", 1)], + }, + pitch_map={"root": 52, "3rd": 56, "5th": 59}, + velocity=80, + ) + total = sum(len(s.notes) for s in pattern.steps.values()) -def test_markov_correct_step_count () -> None: + assert total > 0 - """markov() should place int(length / step) notes (one per grid position).""" - # length=4, spacing=0.25 → 16 notes - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) +def test_markov_correct_step_count() -> None: + """markov() should place int(length / step) notes (one per grid position).""" - builder.markov( - transitions={"root": [("root", 1)]}, - pitch_map={"root": 52}, - velocity=80, - spacing=0.25, - ) + # length=4, spacing=0.25 → 16 notes + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - total = sum(len(s.notes) for s in pattern.steps.values()) + builder.markov( + transitions={"root": [("root", 1)]}, + pitch_map={"root": 52}, + velocity=80, + spacing=0.25, + ) - assert total == 16 + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total == 16 -def test_markov_pitches_come_from_pitch_map () -> None: - """All placed pitches should be values from the pitch_map dict.""" +def test_markov_pitches_come_from_pitch_map() -> None: + """All placed pitches should be values from the pitch_map dict.""" - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - pitch_map = {"root": 52, "3rd": 56, "5th": 59} + pitch_map = {"root": 52, "3rd": 56, "5th": 59} - builder.markov( - transitions={"root": [("3rd", 3), ("5th", 2)], "3rd": [("root", 1)], "5th": [("root", 1)]}, - pitch_map=pitch_map, - velocity=80, - ) + builder.markov( + transitions={ + "root": [("3rd", 3), ("5th", 2)], + "3rd": [("root", 1)], + "5th": [("root", 1)], + }, + pitch_map=pitch_map, + velocity=80, + ) - allowed = set(pitch_map.values()) + allowed = set(pitch_map.values()) - for step in pattern.steps.values(): - for note in step.notes: - assert note.pitch in allowed + for step in pattern.steps.values(): + for note in step.notes: + assert note.pitch in allowed -def test_markov_deterministic () -> None: +def test_markov_deterministic() -> None: + """Same seed should produce identical pitch sequences.""" - """Same seed should produce identical pitch sequences.""" + def _run(seed: int) -> list: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(seed) + builder.markov( + transitions={ + "root": [("3rd", 3), ("5th", 2)], + "3rd": [("5th", 3), ("root", 2)], + "5th": [("root", 3)], + }, + pitch_map={"root": 52, "3rd": 56, "5th": 59}, + ) + return [ + n.pitch + for s in sorted(pattern.steps.keys()) + for n in pattern.steps[s].notes + ] - def _run (seed: int) -> list: - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(seed) - builder.markov( - transitions={"root": [("3rd", 3), ("5th", 2)], "3rd": [("5th", 3), ("root", 2)], "5th": [("root", 3)]}, - pitch_map={"root": 52, "3rd": 56, "5th": 59}, - ) - return [n.pitch for s in sorted(pattern.steps.keys()) for n in pattern.steps[s].notes] + assert _run(42) == _run(42) + assert _run(42) != _run(99) - assert _run(42) == _run(42) - assert _run(42) != _run(99) +def test_markov_custom_start_state() -> None: + """When start is given, the first note should use that state's pitch.""" -def test_markov_custom_start_state () -> None: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - """When start is given, the first note should use that state's pitch.""" + builder.markov( + transitions={"root": [("3rd", 1)], "3rd": [("root", 1)], "5th": [("root", 1)]}, + pitch_map={"root": 52, "3rd": 56, "5th": 59}, + start="5th", + spacing=0.25, + ) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + # The first note placed (beat 0) should be pitch 59 (5th). + first_note = pattern.steps[0].notes[0] - builder.markov( - transitions={"root": [("3rd", 1)], "3rd": [("root", 1)], "5th": [("root", 1)]}, - pitch_map={"root": 52, "3rd": 56, "5th": 59}, - start="5th", - spacing=0.25, - ) + assert first_note.pitch == 59 - # The first note placed (beat 0) should be pitch 59 (5th). - first_note = pattern.steps[0].notes[0] - assert first_note.pitch == 59 +def test_markov_velocity_applied() -> None: + """All placed notes should have the specified velocity.""" + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) -def test_markov_velocity_applied () -> None: + builder.markov( + transitions={"root": [("root", 1)]}, + pitch_map={"root": 52}, + velocity=73, + ) - """All placed notes should have the specified velocity.""" + for step in pattern.steps.values(): + for note in step.notes: + assert note.velocity == 73 - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - builder.markov( - transitions={"root": [("root", 1)]}, - pitch_map={"root": 52}, - velocity=73, - ) +def test_markov_empty_transitions_raises() -> None: + """An empty transitions dict should raise ValueError.""" - for step in pattern.steps.values(): - for note in step.notes: - assert note.velocity == 73 + pattern, builder = _make_builder(length=4) + with pytest.raises(ValueError, match="transitions dict cannot be empty"): + builder.markov(transitions={}, pitch_map={"root": 52}) -def test_markov_empty_transitions_raises () -> None: - """An empty transitions dict should raise ValueError.""" +def test_markov_empty_pitch_map_raises() -> None: + """An empty pitch_map should raise ValueError.""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) - with pytest.raises(ValueError, match="transitions dict cannot be empty"): - builder.markov(transitions={}, pitch_map={"root": 52}) + with pytest.raises(ValueError, match="pitch_map dict cannot be empty"): + builder.markov(transitions={"root": [("root", 1)]}, pitch_map={}) -def test_markov_empty_pitch_map_raises () -> None: +def test_markov_step_size_controls_density() -> None: + """Larger step should place fewer notes over the same pattern length.""" - """An empty pitch_map should raise ValueError.""" + def _count(step: float) -> int: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) + builder.markov( + transitions={"root": [("root", 1)]}, + pitch_map={"root": 52}, + spacing=step, + ) + return sum(len(s.notes) for s in pattern.steps.values()) - pattern, builder = _make_builder(length=4) - - with pytest.raises(ValueError, match="pitch_map dict cannot be empty"): - builder.markov(transitions={"root": [("root", 1)]}, pitch_map={}) - - -def test_markov_step_size_controls_density () -> None: - - """Larger step should place fewer notes over the same pattern length.""" - - def _count (step: float) -> int: - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - builder.markov( - transitions={"root": [("root", 1)]}, - pitch_map={"root": 52}, - spacing=step, - ) - return sum(len(s.notes) for s in pattern.steps.values()) - - # spacing=0.25 → 16 notes; spacing=0.5 → 8 notes - assert _count(0.25) == 16 - assert _count(0.5) == 8 + # spacing=0.25 → 16 notes; spacing=0.5 → 8 notes + assert _count(0.25) == 16 + assert _count(0.5) == 8 # --- p.melody() --- -def _melody_state ( - key: str = "C", - mode: str = "ionian", - low: int = 60, - high: int = 72, - nir_strength: float = 0.5, - chord_weight: float = 0.0, - rest_probability: float = 0.0, - pitch_diversity: float = 1.0, +def _melody_state( + key: str = "C", + mode: str = "ionian", + low: int = 60, + high: int = 72, + nir_strength: float = 0.5, + chord_weight: float = 0.0, + rest_probability: float = 0.0, + pitch_diversity: float = 1.0, ) -> subsequence.melodic_state.MelodicState: + """Return a MelodicState with test defaults.""" - """Return a MelodicState with test defaults.""" - - return subsequence.melodic_state.MelodicState( - key=key, mode=mode, low=low, high=high, - nir_strength=nir_strength, chord_weight=chord_weight, - rest_probability=rest_probability, pitch_diversity=pitch_diversity, - ) - - -def test_melody_places_notes () -> None: - - """melody() should place at least one note over a default-length pattern.""" + return subsequence.melodic_state.MelodicState( + key=key, + mode=mode, + low=low, + high=high, + nir_strength=nir_strength, + chord_weight=chord_weight, + rest_probability=rest_probability, + pitch_diversity=pitch_diversity, + ) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - ms = _melody_state() - builder.melody(state=ms) +def test_melody_places_notes() -> None: + """melody() should place at least one note over a default-length pattern.""" - total = sum(len(s.notes) for s in pattern.steps.values()) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - assert total > 0 + ms = _melody_state() + builder.melody(state=ms) + total = sum(len(s.notes) for s in pattern.steps.values()) -def test_melody_correct_step_count () -> None: + assert total > 0 - """melody() should place int(length / step) notes when rest_probability is 0.""" - # length=4, spacing=0.25 → 16 steps, all filled (no rests) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) +def test_melody_correct_step_count() -> None: + """melody() should place int(length / step) notes when rest_probability is 0.""" - ms = _melody_state(rest_probability=0.0) - builder.melody(state=ms, spacing=0.25) + # length=4, spacing=0.25 → 16 steps, all filled (no rests) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) - total = sum(len(s.notes) for s in pattern.steps.values()) + ms = _melody_state(rest_probability=0.0) + builder.melody(state=ms, spacing=0.25) - assert total == 16 + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total == 16 -def test_melody_step_size_controls_density () -> None: - """Larger step should produce fewer notes over the same pattern length.""" +def test_melody_step_size_controls_density() -> None: + """Larger step should produce fewer notes over the same pattern length.""" - def _count (step: float) -> int: - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) - ms = _melody_state(rest_probability=0.0) - builder.melody(state=ms, spacing=step) - return sum(len(s.notes) for s in pattern.steps.values()) + def _count(step: float) -> int: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) + ms = _melody_state(rest_probability=0.0) + builder.melody(state=ms, spacing=step) + return sum(len(s.notes) for s in pattern.steps.values()) - assert _count(0.25) == 16 - assert _count(0.5) == 8 + assert _count(0.25) == 16 + assert _count(0.5) == 8 -def test_melody_pitches_in_scale () -> None: +def test_melody_pitches_in_scale() -> None: + """All placed pitches should belong to the scale defined in MelodicState.""" - """All placed pitches should belong to the scale defined in MelodicState.""" + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + ms = _melody_state(key="C", mode="ionian", low=60, high=72) + builder.melody(state=ms, spacing=0.25) - ms = _melody_state(key="C", mode="ionian", low=60, high=72) - builder.melody(state=ms, spacing=0.25) + allowed = set(ms._pitch_pool) - allowed = set(ms._pitch_pool) + for step in pattern.steps.values(): + for note in step.notes: + assert note.pitch in allowed - for step in pattern.steps.values(): - for note in step.notes: - assert note.pitch in allowed +def test_melody_velocity_fixed() -> None: + """A fixed integer velocity should be applied to all placed notes.""" -def test_melody_velocity_fixed () -> None: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) - """A fixed integer velocity should be applied to all placed notes.""" + ms = _melody_state(rest_probability=0.0) + builder.melody(state=ms, velocity=77) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) + for step in pattern.steps.values(): + for note in step.notes: + assert note.velocity == 77 - ms = _melody_state(rest_probability=0.0) - builder.melody(state=ms, velocity=77) - for step in pattern.steps.values(): - for note in step.notes: - assert note.velocity == 77 +def test_melody_velocity_tuple() -> None: + """A velocity tuple should produce values within the specified range.""" + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) -def test_melody_velocity_tuple () -> None: + low_vel, high_vel = 60, 100 + ms = _melody_state(rest_probability=0.0) + builder.melody(state=ms, velocity=(low_vel, high_vel)) - """A velocity tuple should produce values within the specified range.""" + for step in pattern.steps.values(): + for note in step.notes: + assert low_vel <= note.velocity <= high_vel - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) - low_vel, high_vel = 60, 100 - ms = _melody_state(rest_probability=0.0) - builder.melody(state=ms, velocity=(low_vel, high_vel)) +def test_melody_deterministic() -> None: + """Same seed and same MelodicState should produce identical pitch sequences.""" - for step in pattern.steps.values(): - for note in step.notes: - assert low_vel <= note.velocity <= high_vel + def _run(seed: int) -> list: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(seed) + ms = _melody_state() + builder.melody(state=ms, spacing=0.25, velocity=80) + return sorted( + (beat, n.pitch, n.velocity) + for beat, step in pattern.steps.items() + for n in step.notes + ) + assert _run(42) == _run(42) + assert _run(42) != _run(99) -def test_melody_deterministic () -> None: - """Same seed and same MelodicState should produce identical pitch sequences.""" +def test_melody_rest_probability_produces_gaps() -> None: + """With rest_probability=0.5, fewer than all steps should contain a note.""" - def _run (seed: int) -> list: - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(seed) - ms = _melody_state() - builder.melody(state=ms, spacing=0.25, velocity=80) - return sorted( - (beat, n.pitch, n.velocity) - for beat, step in pattern.steps.items() - for n in step.notes - ) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(7) - assert _run(42) == _run(42) - assert _run(42) != _run(99) + ms = _melody_state(rest_probability=0.5) + builder.melody(state=ms, spacing=0.25) + total = sum(len(s.notes) for s in pattern.steps.values()) -def test_melody_rest_probability_produces_gaps () -> None: + assert total < 16 - """With rest_probability=0.5, fewer than all steps should contain a note.""" - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(7) +def test_melody_chord_tones_passed_through() -> None: + """chord_tones list should be forwarded to MelodicState without error.""" - ms = _melody_state(rest_probability=0.5) - builder.melody(state=ms, spacing=0.25) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) - total = sum(len(s.notes) for s in pattern.steps.values()) + ms = _melody_state(chord_weight=0.8, rest_probability=0.0) + chord_tones = [60, 64, 67] - assert total < 16 + # Should not raise; notes should be placed + builder.melody(state=ms, spacing=0.5, chord_tones=chord_tones) + total = sum(len(s.notes) for s in pattern.steps.values()) -def test_melody_chord_tones_passed_through () -> None: - - """chord_tones list should be forwarded to MelodicState without error.""" - - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) - - ms = _melody_state(chord_weight=0.8, rest_probability=0.0) - chord_tones = [60, 64, 67] - - # Should not raise; notes should be placed - builder.melody(state=ms, spacing=0.5, chord_tones=chord_tones) - - total = sum(len(s.notes) for s in pattern.steps.values()) - - assert total == 8 + assert total == 8 # ───────────────────────────────────────────────────────────── # thin() # ───────────────────────────────────────────────────────────── -def _count_notes_at_pitch (pattern: subsequence.pattern.Pattern, midi_pitch: int) -> int: - """Count notes with a given MIDI pitch across all steps.""" - return sum( - 1 - for step in pattern.steps.values() - for note in step.notes - if note.pitch == midi_pitch - ) - - -def test_thin_amount_zero () -> None: - - """amount=0.0 must remove nothing.""" - - drum_map = {"hat": 42, "kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(0) - - builder.hit_steps("hat", range(16), velocity=80) - before = _count_notes_at_pitch(pattern, 42) - builder.thin("hat", "strength", amount=0.0) - after = _count_notes_at_pitch(pattern, 42) +def _count_notes_at_pitch(pattern: subsequence.pattern.Pattern, midi_pitch: int) -> int: + """Count notes with a given MIDI pitch across all steps.""" + return sum( + 1 + for step in pattern.steps.values() + for note in step.notes + if note.pitch == midi_pitch + ) - assert after == before +def test_thin_amount_zero() -> None: + """amount=0.0 must remove nothing.""" -def test_thin_amount_one () -> None: + drum_map = {"hat": 42, "kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(0) - """amount=1.0 with uniform strategy removes all notes for the instrument.""" + builder.hit_steps("hat", range(16), velocity=80) + before = _count_notes_at_pitch(pattern, 42) - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(0) + builder.thin("hat", "strength", amount=0.0) + after = _count_notes_at_pitch(pattern, 42) - builder.hit_steps("hat", range(16), velocity=80) - builder.thin("hat", "uniform", amount=1.0) + assert after == before - assert _count_notes_at_pitch(pattern, 42) == 0 +def test_thin_amount_one() -> None: + """amount=1.0 with uniform strategy removes all notes for the instrument.""" -def test_thin_per_instrument () -> None: + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(0) - """thin() should only affect the named instrument, not other pitches.""" + builder.hit_steps("hat", range(16), velocity=80) + builder.thin("hat", "uniform", amount=1.0) - drum_map = {"hat": 42, "kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(0) + assert _count_notes_at_pitch(pattern, 42) == 0 - builder.hit_steps("hat", range(16), velocity=80) - builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) - kick_before = _count_notes_at_pitch(pattern, 36) +def test_thin_per_instrument() -> None: + """thin() should only affect the named instrument, not other pitches.""" - # Thin hats completely - builder.thin("hat", "uniform", amount=1.0) + drum_map = {"hat": 42, "kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(0) - assert _count_notes_at_pitch(pattern, 42) == 0 - assert _count_notes_at_pitch(pattern, 36) == kick_before + builder.hit_steps("hat", range(16), velocity=80) + builder.hit_steps("kick", [0, 4, 8, 12], velocity=100) + kick_before = _count_notes_at_pitch(pattern, 36) -def test_thin_sixteenths_preserves_beats () -> None: + # Thin hats completely + builder.thin("hat", "uniform", amount=1.0) - """sixteenths strategy should remove e/a positions and preserve downbeats.""" + assert _count_notes_at_pitch(pattern, 42) == 0 + assert _count_notes_at_pitch(pattern, 36) == kick_before - # Strategy mirrors ghost_fill bias "sixteenths" but inverted: - # ghost_fill "sixteenths" adds to e/a; thin "sixteenths" removes from e/a. - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - # Fill all 16 steps - builder.hit_steps("hat", range(16), velocity=80) +def test_thin_sixteenths_preserves_beats() -> None: + """sixteenths strategy should remove e/a positions and preserve downbeats.""" - # After thinning with amount=1.0, downbeats (steps 0,4,8,12) should - # be protected (priority ~0.05) while e/a steps (priority 1.0) are gone. - builder.thin("hat", "sixteenths", amount=1.0, rng=random.Random(42)) + # Strategy mirrors ghost_fill bias "sixteenths" but inverted: + # ghost_fill "sixteenths" adds to e/a; thin "sixteenths" removes from e/a. + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) - step_dur = 4.0 / 16 - downbeat_notes = 0 - for step_idx in [0, 4, 8, 12]: - pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) - if pulse in pattern.steps: - downbeat_notes += sum(1 for n in pattern.steps[pulse].notes if n.pitch == 42) + # Fill all 16 steps + builder.hit_steps("hat", range(16), velocity=80) - assert downbeat_notes > 0 + # After thinning with amount=1.0, downbeats (steps 0,4,8,12) should + # be protected (priority ~0.05) while e/a steps (priority 1.0) are gone. + builder.thin("hat", "sixteenths", amount=1.0, rng=random.Random(42)) + step_dur = 4.0 / 16 + downbeat_notes = 0 + for step_idx in [0, 4, 8, 12]: + pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) + if pulse in pattern.steps: + downbeat_notes += sum( + 1 for n in pattern.steps[pulse].notes if n.pitch == 42 + ) -def test_thin_strength_drops_weakest_first () -> None: + assert downbeat_notes > 0 - """strength strategy should drop e/a before & before downbeats.""" - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(99) +def test_thin_strength_drops_weakest_first() -> None: + """strength strategy should drop e/a before & before downbeats.""" - builder.hit_steps("hat", range(16), velocity=80) + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(99) - # amount=0.4: enough to drop most e/a (priority 1.0 * 0.4 = 0.4) - # but not downbeats (priority 0.05 * 0.4 = 0.02 → almost never dropped) - builder.thin("hat", "strength", amount=0.4, rng=random.Random(99)) + builder.hit_steps("hat", range(16), velocity=80) - step_dur = 4.0 / 16 - downbeat_notes = 0 - ea_notes = 0 + # amount=0.4: enough to drop most e/a (priority 1.0 * 0.4 = 0.4) + # but not downbeats (priority 0.05 * 0.4 = 0.02 → almost never dropped) + builder.thin("hat", "strength", amount=0.4, rng=random.Random(99)) - for step_idx in range(16): - pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) - count = 0 - if pulse in pattern.steps: - count = sum(1 for n in pattern.steps[pulse].notes if n.pitch == 42) - pos = step_idx % 4 - if pos == 0: - downbeat_notes += count - elif pos != 2: # e and a positions - ea_notes += count + step_dur = 4.0 / 16 + downbeat_notes = 0 + ea_notes = 0 - assert downbeat_notes >= ea_notes + for step_idx in range(16): + pulse = int(step_idx * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) + count = 0 + if pulse in pattern.steps: + count = sum(1 for n in pattern.steps[pulse].notes if n.pitch == 42) + pos = step_idx % 4 + if pos == 0: + downbeat_notes += count + elif pos != 2: # e and a positions + ea_notes += count + assert downbeat_notes >= ea_notes -def test_thin_custom_list () -> None: - """A custom float list should be used as drop priorities.""" +def test_thin_custom_list() -> None: + """A custom float list should be used as drop priorities.""" - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(0) + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(0) - builder.hit_steps("hat", range(16), velocity=80) + builder.hit_steps("hat", range(16), velocity=80) - # Drop all steps except step 0 (priority 0.0 = never dropped) - priorities = [0.0] + [1.0] * 15 - builder.thin("hat", priorities, amount=1.0) + # Drop all steps except step 0 (priority 0.0 = never dropped) + priorities = [0.0] + [1.0] * 15 + builder.thin("hat", priorities, amount=1.0) - step_dur = 4.0 / 16 - pulse_0 = int(0 * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) - assert _count_notes_at_pitch(pattern, 42) == 1 - assert pulse_0 in pattern.steps + step_dur = 4.0 / 16 + pulse_0 = int(0 * step_dur * subsequence.constants.MIDI_QUARTER_NOTE) + assert _count_notes_at_pitch(pattern, 42) == 1 + assert pulse_0 in pattern.steps -def test_thin_invalid_strategy () -> None: +def test_thin_invalid_strategy() -> None: + """Unknown strategy string should raise ValueError.""" - """Unknown strategy string should raise ValueError.""" + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.hit_steps("hat", range(16), velocity=80) - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.hit_steps("hat", range(16), velocity=80) + with pytest.raises(ValueError): + builder.thin("hat", "no_such_strategy", amount=0.5) - with pytest.raises(ValueError): - builder.thin("hat", "no_such_strategy", amount=0.5) +def test_thin_invalid_list_length() -> None: + """Custom list with wrong length should raise ValueError.""" -def test_thin_invalid_list_length () -> None: + drum_map = {"hat": 42} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.hit_steps("hat", range(16), velocity=80) - """Custom list with wrong length should raise ValueError.""" + with pytest.raises(ValueError): + builder.thin("hat", [0.5] * 8, amount=0.5) # grid=16, list len=8 - drum_map = {"hat": 42} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.hit_steps("hat", range(16), velocity=80) - with pytest.raises(ValueError): - builder.thin("hat", [0.5] * 8, amount=0.5) # grid=16, list len=8 +def test_thin_deterministic() -> None: + """Same seed should always produce the same thinned pattern.""" + drum_map = {"hat": 42} -def test_thin_deterministic () -> None: + def _run(seed: int) -> list: + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.hit_steps("hat", range(16), velocity=80) + builder.thin("hat", "strength", amount=0.5, rng=random.Random(seed)) + return sorted(pattern.steps.keys()) - """Same seed should always produce the same thinned pattern.""" + assert _run(7) == _run(7) + assert _run(7) != _run( + 8 + ) # different seeds → different results (overwhelmingly likely) - drum_map = {"hat": 42} - def _run (seed: int) -> list: - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.hit_steps("hat", range(16), velocity=80) - builder.thin("hat", "strength", amount=0.5, rng=random.Random(seed)) - return sorted(pattern.steps.keys()) +def test_thin_no_pitch_thins_all() -> None: + """pitch=None with amount=1.0 removes all notes regardless of pitch.""" - assert _run(7) == _run(7) - assert _run(7) != _run(8) # different seeds → different results (overwhelmingly likely) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) + # Place notes at two different integer pitches + for step in range(16): + beat = step * 0.25 + builder.note(pitch=60, beat=beat, velocity=80, duration=0.1) + builder.note(pitch=64, beat=beat, velocity=80, duration=0.1) -def test_thin_no_pitch_thins_all () -> None: + total_before = sum(len(s.notes) for s in pattern.steps.values()) + assert total_before == 32 - """pitch=None with amount=1.0 removes all notes regardless of pitch.""" + builder.thin(strategy="uniform", amount=1.0) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) + total_after = sum(len(s.notes) for s in pattern.steps.values()) + assert total_after == 0 - # Place notes at two different integer pitches - for step in range(16): - beat = step * 0.25 - builder.note(pitch=60, beat=beat, velocity=80, duration=0.1) - builder.note(pitch=64, beat=beat, velocity=80, duration=0.1) - total_before = sum(len(s.notes) for s in pattern.steps.values()) - assert total_before == 32 +def test_thin_no_pitch_amount_zero() -> None: + """pitch=None with amount=0.0 removes nothing.""" - builder.thin(strategy="uniform", amount=1.0) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) - total_after = sum(len(s.notes) for s in pattern.steps.values()) - assert total_after == 0 + for step in range(16): + beat = step * 0.25 + builder.note(pitch=60, beat=beat, velocity=80, duration=0.1) + builder.note(pitch=64, beat=beat, velocity=80, duration=0.1) + total_before = sum(len(s.notes) for s in pattern.steps.values()) -def test_thin_no_pitch_amount_zero () -> None: + builder.thin(strategy="strength", amount=0.0) - """pitch=None with amount=0.0 removes nothing.""" + total_after = sum(len(s.notes) for s in pattern.steps.values()) + assert total_after == total_before - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) - for step in range(16): - beat = step * 0.25 - builder.note(pitch=60, beat=beat, velocity=80, duration=0.1) - builder.note(pitch=64, beat=beat, velocity=80, duration=0.1) +def test_thin_no_pitch_respects_strategy() -> None: + """pitch=None still honours the strategy — priority-0 positions are never thinned.""" - total_before = sum(len(s.notes) for s in pattern.steps.values()) + pattern, builder = _make_builder(length=4) - builder.thin(strategy="strength", amount=0.0) + # Fill all 16 steps with two pitches + for step in range(16): + builder.note(pitch=60, beat=step * 0.25, velocity=80, duration=0.1) + builder.note(pitch=64, beat=step * 0.25, velocity=80, duration=0.1) - total_after = sum(len(s.notes) for s in pattern.steps.values()) - assert total_after == total_before + # Custom strategy: first 8 steps priority=1.0 (always drop with amount=1.0), + # last 8 steps priority=0.0 (never drop). This is fully deterministic. + custom = [1.0] * 8 + [0.0] * 8 + builder.thin(strategy=custom, amount=1.0, grid=16) + total = sum(len(s.notes) for s in pattern.steps.values()) + # Steps 0-7: 16 notes removed. Steps 8-15: 16 notes protected. + assert total == 16 -def test_thin_no_pitch_respects_strategy () -> None: - """pitch=None still honours the strategy — priority-0 positions are never thinned.""" +def test_thin_no_pitch_deletes_empty_steps() -> None: + """pitch=None should delete entire steps (not leave empty step objects).""" - pattern, builder = _make_builder(length=4) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(0) - # Fill all 16 steps with two pitches - for step in range(16): - builder.note(pitch=60, beat=step * 0.25, velocity=80, duration=0.1) - builder.note(pitch=64, beat=step * 0.25, velocity=80, duration=0.1) + # Single pitch per step so each step has exactly one note + for step in range(16): + builder.note(pitch=60, beat=step * 0.25, velocity=80, duration=0.1) - # Custom strategy: first 8 steps priority=1.0 (always drop with amount=1.0), - # last 8 steps priority=0.0 (never drop). This is fully deterministic. - custom = [1.0] * 8 + [0.0] * 8 - builder.thin(strategy=custom, amount=1.0, grid=16) + builder.thin(strategy="uniform", amount=1.0) - total = sum(len(s.notes) for s in pattern.steps.values()) - # Steps 0-7: 16 notes removed. Steps 8-15: 16 notes protected. - assert total == 16 - - -def test_thin_no_pitch_deletes_empty_steps () -> None: - - """pitch=None should delete entire steps (not leave empty step objects).""" - - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(0) - - # Single pitch per step so each step has exactly one note - for step in range(16): - builder.note(pitch=60, beat=step * 0.25, velocity=80, duration=0.1) - - builder.thin(strategy="uniform", amount=1.0) - - # All steps should be fully removed from the dict - assert len(pattern.steps) == 0 + # All steps should be fully removed from the dict + assert len(pattern.steps) == 0 # --- p.lsystem() --- -def test_lsystem_places_notes () -> None: - - """lsystem() should place at least one note for a simple deterministic rule.""" - - drum_map = {"kick": 36} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) - - builder.lsystem( - pitch_map={"A": "kick"}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=3, - ) +def test_lsystem_places_notes() -> None: + """lsystem() should place at least one note for a simple deterministic rule.""" - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total > 0 + drum_map = {"kick": 36} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) + builder.lsystem( + pitch_map={"A": "kick"}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=3, + ) -def test_lsystem_autofit_places_all_mapped_symbols () -> None: + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total > 0 - """With spacing=None, every mapped symbol in the expanded string gets a note.""" - # "A" maps to a pitch; "B" is a rest. - # Fibonacci gen=4: "ABAABABA" — 5 A's, 3 B's → 5 notes expected. - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) +def test_lsystem_autofit_places_all_mapped_symbols() -> None: + """With spacing=None, every mapped symbol in the expanded string gets a note.""" - builder.lsystem( - pitch_map={"A": 60}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=4, - ) + # "A" maps to a pitch; "B" is a rest. + # Fibonacci gen=4: "ABAABABA" — 5 A's, 3 B's → 5 notes expected. + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total == 5 + builder.lsystem( + pitch_map={"A": 60}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=4, + ) + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total == 5 -def test_lsystem_fixed_step_truncates () -> None: - """With a fixed step, symbols past the bar end are ignored.""" +def test_lsystem_fixed_step_truncates() -> None: + """With a fixed step, symbols past the bar end are ignored.""" - # spacing=0.5 in a 4-beat bar fits exactly 8 symbols. - # generations=6 produces 13 Fibonacci symbols — only first 8 used. - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + # spacing=0.5 in a 4-beat bar fits exactly 8 symbols. + # generations=6 produces 13 Fibonacci symbols — only first 8 used. + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - # "A" maps to note, "B" is rest — we just check total notes ≤ 8. - builder.lsystem( - pitch_map={"A": 60, "B": 62}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=6, - spacing=0.5, - ) + # "A" maps to note, "B" is rest — we just check total notes ≤ 8. + builder.lsystem( + pitch_map={"A": 60, "B": 62}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=6, + spacing=0.5, + ) - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total <= 8 + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total <= 8 -def test_lsystem_unmapped_symbols_are_rests () -> None: +def test_lsystem_unmapped_symbols_are_rests() -> None: + """Characters not in pitch_map produce silence; time still advances.""" - """Characters not in pitch_map produce silence; time still advances.""" + # Fibonacci gen=2: "ABA" — 2 A's, 1 B. Only A is mapped → 2 notes. + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - # Fibonacci gen=2: "ABA" — 2 A's, 1 B. Only A is mapped → 2 notes. - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + builder.lsystem( + pitch_map={"A": 60}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=2, + ) - builder.lsystem( - pitch_map={"A": 60}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=2, - ) + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total == 2 - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total == 2 +def test_lsystem_empty_pitch_map_produces_silence() -> None: + """An empty pitch_map should produce no notes.""" -def test_lsystem_empty_pitch_map_produces_silence () -> None: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - """An empty pitch_map should produce no notes.""" + builder.lsystem( + pitch_map={}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=5, + ) - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total == 0 - builder.lsystem( - pitch_map={}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=5, - ) - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total == 0 +def test_lsystem_pitches_from_map() -> None: + """All placed note pitches should be values from pitch_map.""" + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) -def test_lsystem_pitches_from_map () -> None: + builder.lsystem( + pitch_map={"A": 60, "B": 64}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=4, + ) - """All placed note pitches should be values from pitch_map.""" + all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} + assert all_pitches <= {60, 64} - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - builder.lsystem( - pitch_map={"A": 60, "B": 64}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=4, - ) +def test_lsystem_drum_names() -> None: + """String values in pitch_map resolve to MIDI via drum_note_map.""" - all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} - assert all_pitches <= {60, 64} + drum_map = {"kick": 36, "snare": 38} + pattern, builder = _make_builder(length=4, drum_note_map=drum_map) + builder.rng = random.Random(42) + builder.lsystem( + pitch_map={"A": "kick", "B": "snare"}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=3, + ) -def test_lsystem_drum_names () -> None: + all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} + assert all_pitches <= {36, 38} - """String values in pitch_map resolve to MIDI via drum_note_map.""" - drum_map = {"kick": 36, "snare": 38} - pattern, builder = _make_builder(length=4, drum_note_map=drum_map) - builder.rng = random.Random(42) +def test_lsystem_velocity_fixed() -> None: + """A fixed integer velocity applies to all notes.""" - builder.lsystem( - pitch_map={"A": "kick", "B": "snare"}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=3, - ) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} - assert all_pitches <= {36, 38} + builder.lsystem( + pitch_map={"A": 60, "B": 62}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=4, + velocity=73, + ) + all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] + assert all(v == 73 for v in all_velocities) -def test_lsystem_velocity_fixed () -> None: - """A fixed integer velocity applies to all notes.""" +def test_lsystem_velocity_tuple() -> None: + """A velocity (low, high) tuple randomises per note within the range.""" - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) - builder.lsystem( - pitch_map={"A": 60, "B": 62}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=4, - velocity=73, - ) + builder.lsystem( + pitch_map={"A": 60, "B": 62}, + axiom="A", + rules={"A": "AB", "B": "A"}, + generations=4, + velocity=(60, 100), + ) - all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] - assert all(v == 73 for v in all_velocities) + all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] + assert all(60 <= v <= 100 for v in all_velocities) -def test_lsystem_velocity_tuple () -> None: +def test_lsystem_deterministic() -> None: + """Same rng produces identical pitch sequence; different rng may differ.""" - """A velocity (low, high) tuple randomises per note within the range.""" + def _run(seed: int) -> list: + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(seed) + builder.lsystem( + pitch_map={"A": 60, "B": 62}, + axiom="A", + rules={"A": [("AB", 3), ("BA", 1)], "B": "A"}, + generations=4, + velocity=80, + ) + # Sort by pulse position, return pitch list to detect stochastic differences. + return [ + n.pitch for pos in sorted(pattern.steps) for n in pattern.steps[pos].notes + ] - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) + assert _run(42) == _run(42) + # Different seeds produce different stochastic expansions (overwhelmingly likely). + assert _run(1) != _run(2) - builder.lsystem( - pitch_map={"A": 60, "B": 62}, - axiom="A", - rules={"A": "AB", "B": "A"}, - generations=4, - velocity=(60, 100), - ) - all_velocities = [n.velocity for step in pattern.steps.values() for n in step.notes] - assert all(60 <= v <= 100 for v in all_velocities) +def test_lsystem_generations_zero() -> None: + """generations=0 uses the axiom directly.""" + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(42) -def test_lsystem_deterministic () -> None: + # Axiom "AB" → 2 symbols, both mapped → 2 notes. + builder.lsystem( + pitch_map={"A": 60, "B": 64}, + axiom="AB", + rules={"A": "ABC", "B": "A"}, + generations=0, + ) - """Same rng produces identical pitch sequence; different rng may differ.""" + total = sum(len(s.notes) for s in pattern.steps.values()) + assert total == 2 - def _run (seed: int) -> list: - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(seed) - builder.lsystem( - pitch_map={"A": 60, "B": 62}, - axiom="A", - rules={"A": [("AB", 3), ("BA", 1)], "B": "A"}, - generations=4, - velocity=80, - ) - # Sort by pulse position, return pitch list to detect stochastic differences. - return [n.pitch for pos in sorted(pattern.steps) for n in pattern.steps[pos].notes] - assert _run(42) == _run(42) - # Different seeds produce different stochastic expansions (overwhelmingly likely). - assert _run(1) != _run(2) +def test_lsystem_stochastic_rules() -> None: + """Stochastic rules produce valid output without errors.""" + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(7) -def test_lsystem_generations_zero () -> None: + builder.lsystem( + pitch_map={"A": 60, "B": 62, "C": 64}, + axiom="A", + rules={"A": [("AB", 2), ("AC", 1)], "B": "A", "C": "BA"}, + generations=4, + ) - """generations=0 uses the axiom directly.""" - - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(42) - - # Axiom "AB" → 2 symbols, both mapped → 2 notes. - builder.lsystem( - pitch_map={"A": 60, "B": 64}, - axiom="AB", - rules={"A": "ABC", "B": "A"}, - generations=0, - ) - - total = sum(len(s.notes) for s in pattern.steps.values()) - assert total == 2 - - -def test_lsystem_stochastic_rules () -> None: - - """Stochastic rules produce valid output without errors.""" - - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(7) - - builder.lsystem( - pitch_map={"A": 60, "B": 62, "C": 64}, - axiom="A", - rules={"A": [("AB", 2), ("AC", 1)], "B": "A", "C": "BA"}, - generations=4, - ) - - all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} - assert all_pitches <= {60, 62, 64} + all_pitches = {n.pitch for step in pattern.steps.values() for n in step.notes} + assert all_pitches <= {60, 62, 64} # --- program_change --- -def test_program_change_no_bank () -> None: - - """program_change() with no bank args emits a single program_change event.""" - - pattern, builder = _make_builder(length=4) - builder.program_change(48) +def test_program_change_no_bank() -> None: + """program_change() with no bank args emits a single program_change event.""" - assert len(pattern.cc_events) == 1 - assert pattern.cc_events[0].message_type == 'program_change' - assert pattern.cc_events[0].value == 48 + pattern, builder = _make_builder(length=4) + builder.program_change(48) + assert len(pattern.cc_events) == 1 + assert pattern.cc_events[0].message_type == "program_change" + assert pattern.cc_events[0].value == 48 -def test_program_change_bank_msb_only () -> None: - """bank_msb emits CC 0 before the program change.""" +def test_program_change_bank_msb_only() -> None: + """bank_msb emits CC 0 before the program change.""" - pattern, builder = _make_builder(length=4) - builder.program_change(10, bank_msb=81) + pattern, builder = _make_builder(length=4) + builder.program_change(10, bank_msb=81) - assert len(pattern.cc_events) == 2 - cc, pc = pattern.cc_events - assert cc.message_type == 'control_change' - assert cc.control == 0 - assert cc.value == 81 - assert pc.message_type == 'program_change' - assert pc.value == 10 + assert len(pattern.cc_events) == 2 + cc, pc = pattern.cc_events + assert cc.message_type == "control_change" + assert cc.control == 0 + assert cc.value == 81 + assert pc.message_type == "program_change" + assert pc.value == 10 -def test_program_change_bank_lsb_only () -> None: +def test_program_change_bank_lsb_only() -> None: + """bank_lsb emits CC 32 before the program change.""" - """bank_lsb emits CC 32 before the program change.""" + pattern, builder = _make_builder(length=4) + builder.program_change(10, bank_lsb=3) - pattern, builder = _make_builder(length=4) - builder.program_change(10, bank_lsb=3) + assert len(pattern.cc_events) == 2 + cc, pc = pattern.cc_events + assert cc.message_type == "control_change" + assert cc.control == 32 + assert cc.value == 3 + assert pc.message_type == "program_change" - assert len(pattern.cc_events) == 2 - cc, pc = pattern.cc_events - assert cc.message_type == 'control_change' - assert cc.control == 32 - assert cc.value == 3 - assert pc.message_type == 'program_change' +def test_program_change_bank_both() -> None: + """Both bank_msb and bank_lsb emit CC 0, CC 32, then program change — in order.""" -def test_program_change_bank_both () -> None: + pattern, builder = _make_builder(length=4) + builder.program_change(48, bank_msb=81, bank_lsb=0) - """Both bank_msb and bank_lsb emit CC 0, CC 32, then program change — in order.""" + assert len(pattern.cc_events) == 3 + msb, lsb, pc = pattern.cc_events + assert msb.message_type == "control_change" and msb.control == 0 and msb.value == 81 + assert lsb.message_type == "control_change" and lsb.control == 32 and lsb.value == 0 + assert pc.message_type == "program_change" and pc.value == 48 - pattern, builder = _make_builder(length=4) - builder.program_change(48, bank_msb=81, bank_lsb=0) - assert len(pattern.cc_events) == 3 - msb, lsb, pc = pattern.cc_events - assert msb.message_type == 'control_change' and msb.control == 0 and msb.value == 81 - assert lsb.message_type == 'control_change' and lsb.control == 32 and lsb.value == 0 - assert pc.message_type == 'program_change' and pc.value == 48 +def test_program_change_bank_same_pulse() -> None: + """All three events share the same pulse position as the beat argument.""" + pattern, builder = _make_builder(length=4) + builder.program_change(10, beat=2.0, bank_msb=1, bank_lsb=0) -def test_program_change_bank_same_pulse () -> None: + pulses = {e.pulse for e in pattern.cc_events} + assert len(pulses) == 1 # all at the same pulse - """All three events share the same pulse position as the beat argument.""" - pattern, builder = _make_builder(length=4) - builder.program_change(10, beat=2.0, bank_msb=1, bank_lsb=0) +def test_program_change_clamped() -> None: + """Program and bank values outside 0–127 are clamped.""" - pulses = {e.pulse for e in pattern.cc_events} - assert len(pulses) == 1 # all at the same pulse + pattern, builder = _make_builder(length=4) + builder.program_change(200, bank_msb=-5, bank_lsb=999) - -def test_program_change_clamped () -> None: - - """Program and bank values outside 0–127 are clamped.""" - - pattern, builder = _make_builder(length=4) - builder.program_change(200, bank_msb=-5, bank_lsb=999) - - msb, lsb, pc = pattern.cc_events - assert msb.value == 0 - assert lsb.value == 127 - assert pc.value == 127 + msb, lsb, pc = pattern.cc_events + assert msb.value == 0 + assert lsb.value == 127 + assert pc.value == 127 # --- nrpn / rpn / nrpn_ramp / rpn_ramp --- -def test_nrpn_emits_3_cc_sequence_without_null_reset () -> None: - - """nrpn() with null_reset=False emits 3 CCs in order: 99, 98, 6.""" - - pattern, builder = _make_builder() - builder.nrpn(88, 64, null_reset=False) - - assert len(pattern.cc_events) == 3 - a, b, c = pattern.cc_events - - assert (a.control, a.value) == (99, 0) # NRPN MSB (parameter 88 = 0×128+88) - assert (b.control, b.value) == (98, 88) # NRPN LSB - assert (c.control, c.value) == (6, 64) # Data Entry MSB - - # All co-scheduled at the same pulse - assert {e.pulse for e in pattern.cc_events} == {0} - assert all(e.message_type == 'control_change' for e in pattern.cc_events) - +def test_nrpn_emits_3_cc_sequence_without_null_reset() -> None: + """nrpn() with null_reset=False emits 3 CCs in order: 99, 98, 6.""" -def test_nrpn_emits_5_ccs_with_null_reset_default () -> None: + pattern, builder = _make_builder() + builder.nrpn(88, 64, null_reset=False) - """null_reset defaults True — adds CC 101=127, CC 100=127 sentinel.""" + assert len(pattern.cc_events) == 3 + a, b, c = pattern.cc_events - pattern, builder = _make_builder() - builder.nrpn(88, 64) + assert (a.control, a.value) == (99, 0) # NRPN MSB (parameter 88 = 0×128+88) + assert (b.control, b.value) == (98, 88) # NRPN LSB + assert (c.control, c.value) == (6, 64) # Data Entry MSB - assert len(pattern.cc_events) == 5 + # All co-scheduled at the same pulse + assert {e.pulse for e in pattern.cc_events} == {0} + assert all(e.message_type == "control_change" for e in pattern.cc_events) - null_msb, null_lsb = pattern.cc_events[3], pattern.cc_events[4] - assert (null_msb.control, null_msb.value) == (101, 127) - assert (null_lsb.control, null_lsb.value) == (100, 127) +def test_nrpn_emits_5_ccs_with_null_reset_default() -> None: + """null_reset defaults True — adds CC 101=127, CC 100=127 sentinel.""" -def test_nrpn_fine_true_splits_14bit_value () -> None: + pattern, builder = _make_builder() + builder.nrpn(88, 64) - """fine=True splits a 14-bit value across Data Entry MSB and LSB.""" + assert len(pattern.cc_events) == 5 - pattern, builder = _make_builder() - # 12000 = 93 * 128 + 96 - builder.nrpn(88, 12000, fine=True, null_reset=False) + null_msb, null_lsb = pattern.cc_events[3], pattern.cc_events[4] + assert (null_msb.control, null_msb.value) == (101, 127) + assert (null_lsb.control, null_lsb.value) == (100, 127) - assert len(pattern.cc_events) == 4 - value_msb = pattern.cc_events[2] - value_lsb = pattern.cc_events[3] - assert (value_msb.control, value_msb.value) == (6, 93) - assert (value_lsb.control, value_lsb.value) == (38, 96) +def test_nrpn_fine_true_splits_14bit_value() -> None: + """fine=True splits a 14-bit value across Data Entry MSB and LSB.""" + pattern, builder = _make_builder() + # 12000 = 93 * 128 + 96 + builder.nrpn(88, 12000, fine=True, null_reset=False) -def test_nrpn_large_parameter_uses_msb () -> None: + assert len(pattern.cc_events) == 4 + value_msb = pattern.cc_events[2] + value_lsb = pattern.cc_events[3] - """Parameter numbers > 127 require non-zero NRPN MSB.""" + assert (value_msb.control, value_msb.value) == (6, 93) + assert (value_lsb.control, value_lsb.value) == (38, 96) - pattern, builder = _make_builder() - # 4198 = 32 * 128 + 102 — Take 5's SeqOnOff parameter - builder.nrpn(4198, 1, null_reset=False) - param_msb, param_lsb, _ = pattern.cc_events - assert (param_msb.control, param_msb.value) == (99, 32) - assert (param_lsb.control, param_lsb.value) == (98, 102) +def test_nrpn_large_parameter_uses_msb() -> None: + """Parameter numbers > 127 require non-zero NRPN MSB.""" + pattern, builder = _make_builder() + # 4198 = 32 * 128 + 102 — Take 5's SeqOnOff parameter + builder.nrpn(4198, 1, null_reset=False) -def test_nrpn_raises_on_7bit_overflow () -> None: + param_msb, param_lsb, _ = pattern.cc_events + assert (param_msb.control, param_msb.value) == (99, 32) + assert (param_lsb.control, param_lsb.value) == (98, 102) - """fine=False values above 127 raise ValueError.""" - _, builder = _make_builder() +def test_nrpn_raises_on_7bit_overflow() -> None: + """fine=False values above 127 raise ValueError.""" - with pytest.raises(ValueError, match="0–127"): - builder.nrpn(0, 200, fine=False) + _, builder = _make_builder() + with pytest.raises(ValueError, match="0–127"): + builder.nrpn(0, 200, fine=False) -def test_nrpn_raises_on_14bit_overflow () -> None: - """fine=True values above 16383 raise ValueError via pack_14bit.""" +def test_nrpn_raises_on_14bit_overflow() -> None: + """fine=True values above 16383 raise ValueError via pack_14bit.""" - _, builder = _make_builder() + _, builder = _make_builder() - with pytest.raises(ValueError, match="14-bit"): - builder.nrpn(0, 20000, fine=True) + with pytest.raises(ValueError, match="14-bit"): + builder.nrpn(0, 20000, fine=True) -def test_nrpn_raises_on_out_of_range_parameter_number () -> None: +def test_nrpn_raises_on_out_of_range_parameter_number() -> None: + """Integer parameter numbers must be in the 14-bit range 0–16383.""" - """Integer parameter numbers must be in the 14-bit range 0–16383.""" + _, builder = _make_builder() - _, builder = _make_builder() + with pytest.raises(ValueError, match="NRPN parameter number must be 0–16383"): + builder.nrpn(20000, 0) - with pytest.raises(ValueError, match="NRPN parameter number must be 0–16383"): - builder.nrpn(20000, 0) + with pytest.raises(ValueError, match="NRPN parameter number must be 0–16383"): + builder.nrpn(-1, 0) - with pytest.raises(ValueError, match="NRPN parameter number must be 0–16383"): - builder.nrpn(-1, 0) +def test_rpn_raises_on_out_of_range_parameter_number() -> None: + """Integer RPN parameter numbers must be in the 14-bit range.""" -def test_rpn_raises_on_out_of_range_parameter_number () -> None: + _, builder = _make_builder() - """Integer RPN parameter numbers must be in the 14-bit range.""" + with pytest.raises(ValueError, match="RPN parameter number must be 0–16383"): + builder.rpn(20000, 0) - _, builder = _make_builder() - with pytest.raises(ValueError, match="RPN parameter number must be 0–16383"): - builder.rpn(20000, 0) +def test_nrpn_name_map_resolves_string() -> None: + """String parameter names resolve via the pattern's nrpn_name_map.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + nrpn_name_map={"osc1_freq_fine": 9}, + default_grid=16, + ) + builder.nrpn("osc1_freq_fine", 100, null_reset=False) -def test_nrpn_name_map_resolves_string () -> None: + assert pattern.cc_events[0].value == 0 # NRPN MSB + assert pattern.cc_events[1].value == 9 # NRPN LSB resolves to 9 - """String parameter names resolve via the pattern's nrpn_name_map.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = 0, - nrpn_name_map = {"osc1_freq_fine": 9}, - default_grid = 16, - ) - builder.nrpn("osc1_freq_fine", 100, null_reset=False) +def test_nrpn_unknown_string_raises() -> None: + """String parameter without a matching nrpn_name_map raises.""" - assert pattern.cc_events[0].value == 0 # NRPN MSB - assert pattern.cc_events[1].value == 9 # NRPN LSB resolves to 9 + _, builder = _make_builder() + with pytest.raises(ValueError, match="String NRPN name"): + builder.nrpn("unknown_param", 50) -def test_nrpn_unknown_string_raises () -> None: - """String parameter without a matching nrpn_name_map raises.""" +def test_nrpn_emits_on_pattern_channel() -> None: + """NRPN events leave CcEvent.channel unset so the sequencer falls back to pattern.channel.""" - _, builder = _make_builder() + pattern, builder = _make_builder(channel=4) + builder.nrpn(88, 64, null_reset=False) - with pytest.raises(ValueError, match="String NRPN name"): - builder.nrpn("unknown_param", 50) + # All emitted events delegate channel resolution to the pattern. + assert all(e.channel is None for e in pattern.cc_events) -def test_nrpn_emits_on_pattern_channel () -> None: +def test_rpn_uses_cc_101_100() -> None: + """rpn() selects the parameter via CC 101 / 100, not 99 / 98.""" - """NRPN events leave CcEvent.channel unset so the sequencer falls back to pattern.channel.""" + pattern, builder = _make_builder() + builder.rpn(0, 2, null_reset=False) - pattern, builder = _make_builder(channel=4) - builder.nrpn(88, 64, null_reset=False) + a, b, c = pattern.cc_events + assert (a.control, a.value) == (101, 0) + assert (b.control, b.value) == (100, 0) + assert (c.control, c.value) == (6, 2) - # All emitted events delegate channel resolution to the pattern. - assert all(e.channel is None for e in pattern.cc_events) +def test_rpn_string_resolves_via_pymididefs() -> None: + """Standard RPN names resolve without any per-pattern map.""" -def test_rpn_uses_cc_101_100 () -> None: + pattern, builder = _make_builder() + builder.rpn("pitch_bend_sensitivity", 12, null_reset=False) - """rpn() selects the parameter via CC 101 / 100, not 99 / 98.""" + # RPN MSB / LSB select param 0 (pitch_bend_sensitivity) + assert (pattern.cc_events[0].control, pattern.cc_events[0].value) == (101, 0) + assert (pattern.cc_events[1].control, pattern.cc_events[1].value) == (100, 0) + assert (pattern.cc_events[2].control, pattern.cc_events[2].value) == (6, 12) - pattern, builder = _make_builder() - builder.rpn(0, 2, null_reset=False) - a, b, c = pattern.cc_events - assert (a.control, a.value) == (101, 0) - assert (b.control, b.value) == (100, 0) - assert (c.control, c.value) == (6, 2) +def test_rpn_unknown_name_raises() -> None: + """Non-standard RPN name without fallback raises ValueError.""" + _, builder = _make_builder() -def test_rpn_string_resolves_via_pymididefs () -> None: + with pytest.raises(ValueError, match="Unknown RPN name"): + builder.rpn("not_a_standard_rpn", 1) - """Standard RPN names resolve without any per-pattern map.""" - pattern, builder = _make_builder() - builder.rpn("pitch_bend_sensitivity", 12, null_reset=False) +def test_nrpn_ramp_selects_once_per_burst() -> None: + """nrpn_ramp emits exactly one CC 99/98 pair plus per-step Data Entry.""" - # RPN MSB / LSB select param 0 (pitch_bend_sensitivity) - assert (pattern.cc_events[0].control, pattern.cc_events[0].value) == (101, 0) - assert (pattern.cc_events[1].control, pattern.cc_events[1].value) == (100, 0) - assert (pattern.cc_events[2].control, pattern.cc_events[2].value) == (6, 12) + pattern, builder = _make_builder(length=4) + builder.nrpn_ramp( + 88, start=0, end=16383, beat_start=0, beat_end=1, resolution=6, null_reset=False + ) + # Pulse 0..24 in steps of 6 → 5 ramp steps + param_selects = [e for e in pattern.cc_events if e.control in (99, 98)] + data_entry_msbs = [e for e in pattern.cc_events if e.control == 6] + data_entry_lsbs = [e for e in pattern.cc_events if e.control == 38] -def test_rpn_unknown_name_raises () -> None: + # Exactly one selection at start + assert len(param_selects) == 2 # 99 + 98 + assert all(e.pulse == 0 for e in param_selects) - """Non-standard RPN name without fallback raises ValueError.""" + # 5 ramp updates, each with MSB + LSB (fine=True default) + assert len(data_entry_msbs) == 5 + assert len(data_entry_lsbs) == 5 - _, builder = _make_builder() - with pytest.raises(ValueError, match="Unknown RPN name"): - builder.rpn("not_a_standard_rpn", 1) +def test_nrpn_ramp_interpolates_14bit_values() -> None: + """Linear ramp produces predictable 14-bit values reconstructible via pack_14bit.""" + import pymididefs.cc -def test_nrpn_ramp_selects_once_per_burst () -> None: + pattern, builder = _make_builder(length=4) + builder.nrpn_ramp( + 88, start=0, end=16383, beat_start=0, beat_end=1, resolution=6, null_reset=False + ) - """nrpn_ramp emits exactly one CC 99/98 pair plus per-step Data Entry.""" + # Reconstruct the (pulse → 14-bit value) trace from CC 6 / CC 38 pairs + by_pulse: dict[int, dict[int, int]] = {} + for e in pattern.cc_events: + if e.control in (6, 38): + by_pulse.setdefault(e.pulse, {})[e.control] = e.value - pattern, builder = _make_builder(length=4) - builder.nrpn_ramp(88, start=0, end=16383, beat_start=0, beat_end=1, resolution=6, null_reset=False) + reconstructed = [ + (pulse, pymididefs.cc.unpack_14bit(by_pulse[pulse][6], by_pulse[pulse][38])) + for pulse in sorted(by_pulse) + ] - # Pulse 0..24 in steps of 6 → 5 ramp steps - param_selects = [e for e in pattern.cc_events if e.control in (99, 98)] - data_entry_msbs = [e for e in pattern.cc_events if e.control == 6] - data_entry_lsbs = [e for e in pattern.cc_events if e.control == 38] + # Pulses 0, 6, 12, 18, 24 over 24 total → fractions 0.0, 0.25, 0.5, 0.75, 1.0 + # Linear interpolation 0 → 16383 gives 0, 4096, 8192, 12287, 16383 + # (rounding: 16383 × 0.25 = 4095.75 → 4096; × 0.75 = 12287.25 → 12287) + assert reconstructed == [ + (0, 0), + (6, 4096), + (12, 8192), + (18, 12287), + (24, 16383), + ] - # Exactly one selection at start - assert len(param_selects) == 2 # 99 + 98 - assert all(e.pulse == 0 for e in param_selects) - # 5 ramp updates, each with MSB + LSB (fine=True default) - assert len(data_entry_msbs) == 5 - assert len(data_entry_lsbs) == 5 +def test_nrpn_ramp_raises_on_out_of_range_endpoint() -> None: + """Ramp endpoints outside the (fine-dependent) range raise immediately.""" + _, builder = _make_builder(length=4) -def test_nrpn_ramp_interpolates_14bit_values () -> None: + with pytest.raises(ValueError, match="ramp end must be 0"): + builder.nrpn_ramp(0, start=0, end=200, beat_start=0, beat_end=1, fine=False) - """Linear ramp produces predictable 14-bit values reconstructible via pack_14bit.""" + with pytest.raises(ValueError, match="ramp start must be 0"): + builder.nrpn_ramp(0, start=-1, end=100, beat_start=0, beat_end=1, fine=True) - import pymididefs.cc - pattern, builder = _make_builder(length=4) - builder.nrpn_ramp(88, start=0, end=16383, beat_start=0, beat_end=1, resolution=6, null_reset=False) +def test_rpn_ramp_raises_on_out_of_range_endpoint() -> None: + """rpn_ramp shares the same endpoint validation.""" - # Reconstruct the (pulse → 14-bit value) trace from CC 6 / CC 38 pairs - by_pulse: dict[int, dict[int, int]] = {} - for e in pattern.cc_events: - if e.control in (6, 38): - by_pulse.setdefault(e.pulse, {})[e.control] = e.value + _, builder = _make_builder(length=4) - reconstructed = [ - (pulse, pymididefs.cc.unpack_14bit(by_pulse[pulse][6], by_pulse[pulse][38])) - for pulse in sorted(by_pulse) - ] + with pytest.raises(ValueError, match="ramp end must be 0–16383"): + builder.rpn_ramp(0, start=0, end=20000, beat_start=0, beat_end=1, fine=True) - # Pulses 0, 6, 12, 18, 24 over 24 total → fractions 0.0, 0.25, 0.5, 0.75, 1.0 - # Linear interpolation 0 → 16383 gives 0, 4096, 8192, 12287, 16383 - # (rounding: 16383 × 0.25 = 4095.75 → 4096; × 0.75 = 12287.25 → 12287) - assert reconstructed == [ - (0, 0), - (6, 4096), - (12, 8192), - (18, 12287), - (24, 16383), - ] +def test_nrpn_ramp_null_reset_at_end() -> None: + """null_reset=True (default) appends 101=127, 100=127 once at beat_end.""" -def test_nrpn_ramp_raises_on_out_of_range_endpoint () -> None: + pattern, builder = _make_builder(length=4) + builder.nrpn_ramp(88, start=0, end=127, beat_start=0, beat_end=2, resolution=12) - """Ramp endpoints outside the (fine-dependent) range raise immediately.""" + null_msbs = [e for e in pattern.cc_events if e.control == 101 and e.value == 127] + null_lsbs = [e for e in pattern.cc_events if e.control == 100 and e.value == 127] - _, builder = _make_builder(length=4) + # Exactly one null sentinel pair + assert len(null_msbs) == 1 + assert len(null_lsbs) == 1 - with pytest.raises(ValueError, match="ramp end must be 0"): - builder.nrpn_ramp(0, start=0, end=200, beat_start=0, beat_end=1, fine=False) + # Both at beat_end (pulse 48) + expected_pulse = 2 * subsequence.constants.MIDI_QUARTER_NOTE + assert null_msbs[0].pulse == expected_pulse + assert null_lsbs[0].pulse == expected_pulse - with pytest.raises(ValueError, match="ramp start must be 0"): - builder.nrpn_ramp(0, start=-1, end=100, beat_start=0, beat_end=1, fine=True) +def test_nrpn_ramp_fine_false_emits_only_msb() -> None: + """fine=False should emit Data Entry MSB only (no CC 38).""" -def test_rpn_ramp_raises_on_out_of_range_endpoint () -> None: + pattern, builder = _make_builder(length=4) + builder.nrpn_ramp( + 88, + start=0, + end=127, + beat_start=0, + beat_end=1, + resolution=6, + fine=False, + null_reset=False, + ) - """rpn_ramp shares the same endpoint validation.""" + data_entry_lsbs = [e for e in pattern.cc_events if e.control == 38] + assert data_entry_lsbs == [] - _, builder = _make_builder(length=4) - with pytest.raises(ValueError, match="ramp end must be 0–16383"): - builder.rpn_ramp(0, start=0, end=20000, beat_start=0, beat_end=1, fine=True) +def test_rpn_ramp_pitch_bend_sensitivity() -> None: + """rpn_ramp works with the standard RPN string name.""" + pattern, builder = _make_builder(length=4) + builder.rpn_ramp( + "pitch_bend_sensitivity", + start=0, + end=12, + beat_start=0, + beat_end=1, + resolution=12, + fine=False, + null_reset=False, + ) -def test_nrpn_ramp_null_reset_at_end () -> None: - - """null_reset=True (default) appends 101=127, 100=127 once at beat_end.""" - - pattern, builder = _make_builder(length=4) - builder.nrpn_ramp(88, start=0, end=127, beat_start=0, beat_end=2, resolution=12) - - null_msbs = [e for e in pattern.cc_events if e.control == 101 and e.value == 127] - null_lsbs = [e for e in pattern.cc_events if e.control == 100 and e.value == 127] - - # Exactly one null sentinel pair - assert len(null_msbs) == 1 - assert len(null_lsbs) == 1 - - # Both at beat_end (pulse 48) - expected_pulse = 2 * subsequence.constants.MIDI_QUARTER_NOTE - assert null_msbs[0].pulse == expected_pulse - assert null_lsbs[0].pulse == expected_pulse - - -def test_nrpn_ramp_fine_false_emits_only_msb () -> None: - - """fine=False should emit Data Entry MSB only (no CC 38).""" - - pattern, builder = _make_builder(length=4) - builder.nrpn_ramp(88, start=0, end=127, beat_start=0, beat_end=1, resolution=6, fine=False, null_reset=False) - - data_entry_lsbs = [e for e in pattern.cc_events if e.control == 38] - assert data_entry_lsbs == [] - - -def test_rpn_ramp_pitch_bend_sensitivity () -> None: - - """rpn_ramp works with the standard RPN string name.""" - - pattern, builder = _make_builder(length=4) - builder.rpn_ramp("pitch_bend_sensitivity", start=0, end=12, beat_start=0, beat_end=1, resolution=12, fine=False, null_reset=False) - - # Selection uses CC 101 / 100, not 99 / 98 - param_selects = [e for e in pattern.cc_events if e.control in (99, 98, 101, 100)] - assert all(e.control in (101, 100) for e in param_selects) - assert len(param_selects) == 2 + # Selection uses CC 101 / 100, not 99 / 98 + param_selects = [e for e in pattern.cc_events if e.control in (99, 98, 101, 100)] + assert all(e.control in (101, 100) for e in param_selects) + assert len(param_selects) == 2 # --- p.data — shared inter-pattern state --- -def test_data_read_write () -> None: - - """p.data supports standard dict read/write.""" +def test_data_read_write() -> None: + """p.data supports standard dict read/write.""" - shared = {} - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, cycle=0, default_grid=16, data=shared - ) + shared = {} + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, default_grid=16, data=shared + ) - builder.data["root"] = 60 - assert builder.data["root"] == 60 - assert shared["root"] == 60 + builder.data["root"] = 60 + assert builder.data["root"] == 60 + assert shared["root"] == 60 -def test_data_get_default_when_missing () -> None: +def test_data_get_default_when_missing() -> None: + """p.data.get() returns the default when the key is absent.""" - """p.data.get() returns the default when the key is absent.""" + shared = {} + pattern = subsequence.pattern.Pattern(channel=0, length=4) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, cycle=0, default_grid=16, data=shared + ) - shared = {} - pattern = subsequence.pattern.Pattern(channel=0, length=4) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, cycle=0, default_grid=16, data=shared - ) + assert builder.data.get("missing") is None + assert builder.data.get("missing", 48) == 48 - assert builder.data.get("missing") is None - assert builder.data.get("missing", 48) == 48 +def test_data_cross_pattern() -> None: + """Two builders sharing the same dict can read each other's writes.""" -def test_data_cross_pattern () -> None: + shared = {} + pat_a = subsequence.pattern.Pattern(channel=0, length=4) + pat_b = subsequence.pattern.Pattern(channel=1, length=4) - """Two builders sharing the same dict can read each other's writes.""" + builder_a = subsequence.pattern_builder.PatternBuilder( + pattern=pat_a, cycle=0, default_grid=16, data=shared + ) + builder_b = subsequence.pattern_builder.PatternBuilder( + pattern=pat_b, cycle=0, default_grid=16, data=shared + ) - shared = {} - pat_a = subsequence.pattern.Pattern(channel=0, length=4) - pat_b = subsequence.pattern.Pattern(channel=1, length=4) + builder_a.data["bass_root"] = 36 + assert builder_b.data.get("bass_root") == 36 - builder_a = subsequence.pattern_builder.PatternBuilder( - pattern=pat_a, cycle=0, default_grid=16, data=shared - ) - builder_b = subsequence.pattern_builder.PatternBuilder( - pattern=pat_b, cycle=0, default_grid=16, data=shared - ) - builder_a.data["bass_root"] = 36 - assert builder_b.data.get("bass_root") == 36 +def test_data_persists_across_rebuilds() -> None: + """Values survive a rebuild (new builder instance, same dict reference).""" + shared = {} + pat = subsequence.pattern.Pattern(channel=0, length=4) -def test_data_persists_across_rebuilds () -> None: + builder_1 = subsequence.pattern_builder.PatternBuilder( + pattern=pat, cycle=0, default_grid=16, data=shared + ) + builder_1.data["density"] = 0.75 - """Values survive a rebuild (new builder instance, same dict reference).""" + builder_2 = subsequence.pattern_builder.PatternBuilder( + pattern=pat, cycle=1, default_grid=16, data=shared + ) + assert builder_2.data.get("density") == 0.75 - shared = {} - pat = subsequence.pattern.Pattern(channel=0, length=4) - builder_1 = subsequence.pattern_builder.PatternBuilder( - pattern=pat, cycle=0, default_grid=16, data=shared - ) - builder_1.data["density"] = 0.75 +def test_data_without_composition_uses_isolated_dict() -> None: + """A builder created without data= gets its own empty dict and does not crash.""" - builder_2 = subsequence.pattern_builder.PatternBuilder( - pattern=pat, cycle=1, default_grid=16, data=shared - ) - assert builder_2.data.get("density") == 0.75 - - -def test_data_without_composition_uses_isolated_dict () -> None: - - """A builder created without data= gets its own empty dict and does not crash.""" - - pattern, builder = _make_builder() - builder.data["x"] = 42 - assert builder.data["x"] == 42 + pattern, builder = _make_builder() + builder.data["x"] = 42 + assert builder.data["x"] == 42 # --- thue_morse --- -def test_thue_morse_single_pitch_count () -> None: - - """Note count equals number of 1s in the Thue-Morse sequence.""" +def test_thue_morse_single_pitch_count() -> None: + """Note count equals number of 1s in the Thue-Morse sequence.""" - import subsequence.sequence_utils - pattern, builder = _make_builder(length=4) - builder.thue_morse(60, velocity=80) - expected = sum(subsequence.sequence_utils.thue_morse(16)) - assert len(pattern.steps) == expected + import subsequence.sequence_utils + pattern, builder = _make_builder(length=4) + builder.thue_morse(60, velocity=80) + expected = sum(subsequence.sequence_utils.thue_morse(16)) + assert len(pattern.steps) == expected -def test_thue_morse_two_pitch_mode () -> None: - """In two-pitch mode, all 16 grid steps produce a note.""" +def test_thue_morse_two_pitch_mode() -> None: + """In two-pitch mode, all 16 grid steps produce a note.""" - pattern, builder = _make_builder(length=4) - builder.thue_morse(60, pitch_b=64, velocity=80) - assert len(pattern.steps) == 16 + pattern, builder = _make_builder(length=4) + builder.thue_morse(60, pitch_b=64, velocity=80) + assert len(pattern.steps) == 16 -def test_thue_morse_no_overlap () -> None: +def test_thue_morse_no_overlap() -> None: + """no_overlap=True prevents duplicate pitches at the same pulse.""" - """no_overlap=True prevents duplicate pitches at the same pulse.""" - - pattern, builder = _make_builder(length=4) - # Place pitch 60 at every step first, then thue_morse with no_overlap - for i in range(16): - builder.note(pitch=60, beat=i * 0.25, velocity=90, duration=0.1) - initial_count = len(pattern.steps) - builder.thue_morse(60, no_overlap=True, velocity=80) - # no_overlap should not add any new steps (pitch 60 already present everywhere) - assert len(pattern.steps) == initial_count + pattern, builder = _make_builder(length=4) + # Place pitch 60 at every step first, then thue_morse with no_overlap + for i in range(16): + builder.note(pitch=60, beat=i * 0.25, velocity=90, duration=0.1) + initial_count = len(pattern.steps) + builder.thue_morse(60, no_overlap=True, velocity=80) + # no_overlap should not add any new steps (pitch 60 already present everywhere) + assert len(pattern.steps) == initial_count # --- de_bruijn --- -def test_de_bruijn_autofit_note_count () -> None: - - """Auto-fit mode places exactly k**n notes.""" - - pattern, builder = _make_builder(length=4) - builder.de_bruijn([60, 62], window=3) # 2**3 = 8 notes - assert len(pattern.steps) == 8 +def test_de_bruijn_autofit_note_count() -> None: + """Auto-fit mode places exactly k**n notes.""" + pattern, builder = _make_builder(length=4) + builder.de_bruijn([60, 62], window=3) # 2**3 = 8 notes + assert len(pattern.steps) == 8 -def test_de_bruijn_fixed_step_truncates () -> None: - """Fixed step mode places at most int(length / step) notes.""" +def test_de_bruijn_fixed_step_truncates() -> None: + """Fixed step mode places at most int(length / step) notes.""" - pattern, builder = _make_builder(length=4) - builder.de_bruijn([60, 62], window=4, spacing=0.25) # 2**4=16 notes, 4/0.25=16 slots - assert len(pattern.steps) <= 16 + pattern, builder = _make_builder(length=4) + builder.de_bruijn( + [60, 62], window=4, spacing=0.25 + ) # 2**4=16 notes, 4/0.25=16 slots + assert len(pattern.steps) <= 16 -def test_de_bruijn_pitches_from_list () -> None: +def test_de_bruijn_pitches_from_list() -> None: + """All placed pitches are from the provided list.""" - """All placed pitches are from the provided list.""" - - pattern, builder = _make_builder(length=4) - pitches = [60, 62, 64] - builder.de_bruijn(pitches, window=2) - placed = {note.pitch for step in pattern.steps.values() for note in step.notes} - assert placed.issubset(set(pitches)) + pattern, builder = _make_builder(length=4) + pitches = [60, 62, 64] + builder.de_bruijn(pitches, window=2) + placed = {note.pitch for step in pattern.steps.values() for note in step.notes} + assert placed.issubset(set(pitches)) # --- fibonacci --- -def test_fibonacci_places_notes () -> None: - - """fibonacci places the requested number of notes.""" +def test_fibonacci_places_notes() -> None: + """fibonacci places the requested number of notes.""" - pattern, builder = _make_builder(length=4) - builder.fibonacci(60, count=8, velocity=80) - assert len(pattern.steps) == 8 + pattern, builder = _make_builder(length=4) + builder.fibonacci(60, count=8, velocity=80) + assert len(pattern.steps) == 8 -def test_fibonacci_correct_count () -> None: +def test_fibonacci_correct_count() -> None: + """Total placed notes equals steps parameter.""" - """Total placed notes equals steps parameter.""" + pattern, builder = _make_builder(length=4) + builder.fibonacci(60, count=11) + assert len(pattern.steps) == 11 - pattern, builder = _make_builder(length=4) - builder.fibonacci(60, count=11) - assert len(pattern.steps) == 11 +def test_fibonacci_velocity_tuple() -> None: + """Velocity tuple produces velocities within the given range.""" -def test_fibonacci_velocity_tuple () -> None: - - """Velocity tuple produces velocities within the given range.""" - - pattern, builder = _make_builder(length=4) - builder.rng = random.Random(1) - builder.fibonacci(60, count=8, velocity=(60, 100)) - vels = [note.velocity for step in pattern.steps.values() for note in step.notes] - assert all(60 <= v <= 100 for v in vels) + pattern, builder = _make_builder(length=4) + builder.rng = random.Random(1) + builder.fibonacci(60, count=8, velocity=(60, 100)) + vels = [note.velocity for step in pattern.steps.values() for note in step.notes] + assert all(60 <= v <= 100 for v in vels) # --- lorenz --- -def test_lorenz_correct_step_count () -> None: - - """lorenz places int(length / step) notes.""" +def test_lorenz_correct_step_count() -> None: + """lorenz places int(length / step) notes.""" - pattern, builder = _make_builder(length=4) - builder.lorenz([60, 62, 64, 65, 67], spacing=0.25) - assert len(pattern.steps) == 16 + pattern, builder = _make_builder(length=4) + builder.lorenz([60, 62, 64, 65, 67], spacing=0.25) + assert len(pattern.steps) == 16 -def test_lorenz_pitches_from_list () -> None: +def test_lorenz_pitches_from_list() -> None: + """All placed pitches are from the pitches list.""" - """All placed pitches are from the pitches list.""" + pattern, builder = _make_builder(length=4) + pitches = [60, 62, 64] + builder.lorenz(pitches, spacing=0.5) + placed = {note.pitch for step in pattern.steps.values() for note in step.notes} + assert placed.issubset(set(pitches)) - pattern, builder = _make_builder(length=4) - pitches = [60, 62, 64] - builder.lorenz(pitches, spacing=0.5) - placed = {note.pitch for step in pattern.steps.values() for note in step.notes} - assert placed.issubset(set(pitches)) +def test_lorenz_int_velocity_is_fixed() -> None: + """lorenz(velocity=80) gives every note exactly velocity 80 — an int means a fixed velocity, not a modulation range.""" -def test_lorenz_int_velocity_is_fixed () -> None: + pattern, builder = _make_builder(length=4) + builder.lorenz([60, 62, 64, 65, 67], spacing=0.25, velocity=80) - """lorenz(velocity=80) gives every note exactly velocity 80 — an int means a fixed velocity, not a modulation range.""" + vels = [n.velocity for step in pattern.steps.values() for n in step.notes] - pattern, builder = _make_builder(length=4) - builder.lorenz([60, 62, 64, 65, 67], spacing=0.25, velocity=80) + assert vels + assert all(v == 80 for v in vels) - vels = [n.velocity for step in pattern.steps.values() for n in step.notes] - assert vels - assert all(v == 80 for v in vels) +def test_lorenz_custom_mapping() -> None: + """Custom mapping callable controls pitch, velocity, duration.""" + pattern, builder = _make_builder(length=4) -def test_lorenz_custom_mapping () -> None: + def my_map(x: float, y: float, z: float) -> typing.Tuple[int, int, float]: + return (60, 90, 0.1) - """Custom mapping callable controls pitch, velocity, duration.""" + builder.lorenz([60, 62], spacing=0.5, mapping=my_map) + vels = [note.velocity for step in pattern.steps.values() for note in step.notes] + assert all(v == 90 for v in vels) - pattern, builder = _make_builder(length=4) - def my_map (x: float, y: float, z: float) -> typing.Tuple[int, int, float]: - return (60, 90, 0.1) +# --- reaction_diffusion --- - builder.lorenz([60, 62], spacing=0.5, mapping=my_map) - vels = [note.velocity for step in pattern.steps.values() for note in step.notes] - assert all(v == 90 for v in vels) +def test_reaction_diffusion_threshold_affects_density() -> None: + """Lower threshold produces at least as many notes as higher threshold.""" -# --- reaction_diffusion --- + pattern_lo, builder_lo = _make_builder(length=4) + builder_lo.reaction_diffusion(60, threshold=0.2, steps=500) + pattern_hi, builder_hi = _make_builder(length=4) + builder_hi.reaction_diffusion(60, threshold=0.8, steps=500) -def test_reaction_diffusion_threshold_affects_density () -> None: + assert len(pattern_lo.steps) >= len(pattern_hi.steps) - """Lower threshold produces at least as many notes as higher threshold.""" - pattern_lo, builder_lo = _make_builder(length=4) - builder_lo.reaction_diffusion(60, threshold=0.2, steps=500) +def test_reaction_diffusion_dropout_reduces_notes() -> None: + """dropout > 0 produces fewer notes on average.""" - pattern_hi, builder_hi = _make_builder(length=4) - builder_hi.reaction_diffusion(60, threshold=0.8, steps=500) + import random as stdlib_random - assert len(pattern_lo.steps) >= len(pattern_hi.steps) + counts = [] + for seed in range(10): + p, b = _make_builder(length=4) + b.rng = stdlib_random.Random(seed) + b.reaction_diffusion(60, threshold=0.3, probability=0.2, steps=300) + counts.append(len(p.steps)) + p2, b2 = _make_builder(length=4) + b2.reaction_diffusion(60, threshold=0.3, probability=1.0, steps=300) + no_dropout_count = len(p2.steps) -def test_reaction_diffusion_dropout_reduces_notes () -> None: + assert sum(counts) / len(counts) < no_dropout_count - """dropout > 0 produces fewer notes on average.""" - import random as stdlib_random - counts = [] - for seed in range(10): - p, b = _make_builder(length=4) - b.rng = stdlib_random.Random(seed) - b.reaction_diffusion(60, threshold=0.3, probability=0.2, steps=300) - counts.append(len(p.steps)) +# --- self_avoiding_walk --- - p2, b2 = _make_builder(length=4) - b2.reaction_diffusion(60, threshold=0.3, probability=1.0, steps=300) - no_dropout_count = len(p2.steps) - assert sum(counts) / len(counts) < no_dropout_count +def test_self_avoiding_walk_correct_count() -> None: + """Total notes equals int(length / step).""" + pattern, builder = _make_builder(length=4) + builder.self_avoiding_walk([60, 62, 64, 65, 67, 69, 71, 72], spacing=0.25) + assert len(pattern.steps) == 16 -# --- self_avoiding_walk --- +def test_self_avoiding_walk_pitches_from_list() -> None: + """All placed pitches are from the provided list.""" -def test_self_avoiding_walk_correct_count () -> None: + pattern, builder = _make_builder(length=4) + pitches = [60, 62, 64, 65, 67] + builder.self_avoiding_walk(pitches, spacing=0.25) + placed = {note.pitch for step in pattern.steps.values() for note in step.notes} + assert placed.issubset(set(pitches)) - """Total notes equals int(length / step).""" - pattern, builder = _make_builder(length=4) - builder.self_avoiding_walk([60, 62, 64, 65, 67, 69, 71, 72], spacing=0.25) - assert len(pattern.steps) == 16 +def test_self_avoiding_walk_deterministic() -> None: + """Same seed produces identical pitch sequence.""" + pattern1, builder1 = _make_builder(length=4) + builder1.rng = random.Random(42) + builder1.self_avoiding_walk([60, 62, 64, 65, 67], spacing=0.25) -def test_self_avoiding_walk_pitches_from_list () -> None: + pattern2, builder2 = _make_builder(length=4) + builder2.rng = random.Random(42) + builder2.self_avoiding_walk([60, 62, 64, 65, 67], spacing=0.25) - """All placed pitches are from the provided list.""" + pitches1 = [ + note.pitch + for step in sorted(pattern1.steps) + for note in pattern1.steps[step].notes + ] + pitches2 = [ + note.pitch + for step in sorted(pattern2.steps) + for note in pattern2.steps[step].notes + ] + assert pitches1 == pitches2 - pattern, builder = _make_builder(length=4) - pitches = [60, 62, 64, 65, 67] - builder.self_avoiding_walk(pitches, spacing=0.25) - placed = {note.pitch for step in pattern.steps.values() for note in step.notes} - assert placed.issubset(set(pitches)) +# ── snap_to_scale(strength=) ────────────────────────────────────────────────────── -def test_self_avoiding_walk_deterministic () -> None: - """Same seed produces identical pitch sequence.""" +def _pitches_in_pattern(pattern) -> list: + return [ + note.pitch + for step in sorted(pattern.steps) + for note in pattern.steps[step].notes + ] - pattern1, builder1 = _make_builder(length=4) - builder1.rng = random.Random(42) - builder1.self_avoiding_walk([60, 62, 64, 65, 67], spacing=0.25) - pattern2, builder2 = _make_builder(length=4) - builder2.rng = random.Random(42) - builder2.self_avoiding_walk([60, 62, 64, 65, 67], spacing=0.25) +def _place_chromatic_notes(builder, pitches: list, length: float = 4) -> None: + """Place a list of pitches at evenly spaced beats.""" + spacing = length / len(pitches) + for i, p in enumerate(pitches): + builder.note(p, beat=i * spacing, duration=spacing * 0.9) - pitches1 = [note.pitch for step in sorted(pattern1.steps) for note in pattern1.steps[step].notes] - pitches2 = [note.pitch for step in sorted(pattern2.steps) for note in pattern2.steps[step].notes] - assert pitches1 == pitches2 +def test_snap_to_scale_strength_one_snaps_all() -> None: + """strength=1.0 (default) must snap every note — identical to old behaviour.""" -# ── snap_to_scale(strength=) ────────────────────────────────────────────────────── + # C major pitch classes: [0, 2, 4, 5, 7, 9, 11] + # Place C# (61), D# (63), F# (66) — all outside C major + pattern, builder = _make_builder(length=4) + _place_chromatic_notes(builder, [61, 63, 66]) + builder.snap_to_scale("C", "ionian", strength=1.0) + pitches = _pitches_in_pattern(pattern) + # quantize_pitch prefers the UPWARD neighbour on equidistant ties: + # C# (61) → D (62), D# (63) → E (64), F# (66) → G (67) + assert pitches == [62, 64, 67] -def _pitches_in_pattern (pattern) -> list: - return [note.pitch for step in sorted(pattern.steps) for note in pattern.steps[step].notes] +def test_snap_to_scale_strength_zero_leaves_all_unchanged() -> None: + """strength=0.0 must leave every note pitch untouched.""" -def _place_chromatic_notes (builder, pitches: list, length: float = 4) -> None: - """Place a list of pitches at evenly spaced beats.""" - spacing = length / len(pitches) - for i, p in enumerate(pitches): - builder.note(p, beat=i * spacing, duration=spacing * 0.9) + pattern, builder = _make_builder(length=4) + _place_chromatic_notes(builder, [61, 63, 66]) + builder.snap_to_scale("C", "ionian", strength=0.0) + pitches = _pitches_in_pattern(pattern) + assert pitches == [61, 63, 66] -def test_snap_to_scale_strength_one_snaps_all () -> None: - """strength=1.0 (default) must snap every note — identical to old behaviour.""" +def test_snap_to_scale_default_strength_unchanged() -> None: + """Omitting strength= must behave identically to strength=1.0.""" - # C major pitch classes: [0, 2, 4, 5, 7, 9, 11] - # Place C# (61), D# (63), F# (66) — all outside C major - pattern, builder = _make_builder(length=4) - _place_chromatic_notes(builder, [61, 63, 66]) - builder.snap_to_scale("C", "ionian", strength=1.0) + pattern1, builder1 = _make_builder(length=4) + _place_chromatic_notes(builder1, [61, 63, 66]) + builder1.snap_to_scale("C", "ionian") - pitches = _pitches_in_pattern(pattern) - # quantize_pitch prefers the UPWARD neighbour on equidistant ties: - # C# (61) → D (62), D# (63) → E (64), F# (66) → G (67) - assert pitches == [62, 64, 67] + pattern2, builder2 = _make_builder(length=4) + _place_chromatic_notes(builder2, [61, 63, 66]) + builder2.snap_to_scale("C", "ionian", strength=1.0) + assert _pitches_in_pattern(pattern1) == _pitches_in_pattern(pattern2) -def test_snap_to_scale_strength_zero_leaves_all_unchanged () -> None: - """strength=0.0 must leave every note pitch untouched.""" +def test_snap_to_scale_strength_partial_is_reproducible() -> None: + """Same RNG seed + same strength must produce the same set of snapped notes.""" - pattern, builder = _make_builder(length=4) - _place_chromatic_notes(builder, [61, 63, 66]) - builder.snap_to_scale("C", "ionian", strength=0.0) + pitches_in = [61, 63, 66, 58, 70, 73, 56, 69] - pitches = _pitches_in_pattern(pattern) - assert pitches == [61, 63, 66] + pattern1, builder1 = _make_builder(length=4) + builder1.rng = random.Random(99) + _place_chromatic_notes(builder1, pitches_in) + builder1.snap_to_scale("C", "ionian", strength=0.5) + pattern2, builder2 = _make_builder(length=4) + builder2.rng = random.Random(99) + _place_chromatic_notes(builder2, pitches_in) + builder2.snap_to_scale("C", "ionian", strength=0.5) -def test_snap_to_scale_default_strength_unchanged () -> None: + assert _pitches_in_pattern(pattern1) == _pitches_in_pattern(pattern2) - """Omitting strength= must behave identically to strength=1.0.""" - pattern1, builder1 = _make_builder(length=4) - _place_chromatic_notes(builder1, [61, 63, 66]) - builder1.snap_to_scale("C", "ionian") +def test_snap_to_scale_strength_partial_snaps_some_notes() -> None: + """strength=0.5 should snap roughly half the notes (statistical check over many notes).""" - pattern2, builder2 = _make_builder(length=4) - _place_chromatic_notes(builder2, [61, 63, 66]) - builder2.snap_to_scale("C", "ionian", strength=1.0) + # Place 200 notes all on C# (61), which is outside C major. + # quantize_pitch prefers upward, so C# (61) → D (62). + # With strength=0.5, ~50% should be snapped to D (62), rest stay at C# (61). + n = 200 + pattern, builder = _make_builder(length=n * 0.25) + builder.rng = random.Random(7) + for i in range(n): + builder.note(61, beat=i * 0.25, duration=0.2) + builder.snap_to_scale("C", "ionian", strength=0.5) - assert _pitches_in_pattern(pattern1) == _pitches_in_pattern(pattern2) + pitches = _pitches_in_pattern(pattern) + snapped_count = sum(1 for p in pitches if p == 62) # snapped up to D + unsnapped_count = sum(1 for p in pitches if p == 61) # left as C# + # Allow ±15% tolerance + assert 0.35 * n <= snapped_count <= 0.65 * n, ( + f"Expected ~{n // 2} snapped, got {snapped_count}/{n}" + ) + assert snapped_count + unsnapped_count == n -def test_snap_to_scale_strength_partial_is_reproducible () -> None: - """Same RNG seed + same strength must produce the same set of snapped notes.""" +# --------------------------------------------------------------------------- +# p.bar_cycle() +# --------------------------------------------------------------------------- - pitches_in = [61, 63, 66, 58, 70, 73, 56, 69] - pattern1, builder1 = _make_builder(length=4) - builder1.rng = random.Random(99) - _place_chromatic_notes(builder1, pitches_in) - builder1.snap_to_scale("C", "ionian", strength=0.5) +def _make_builder_at_bar(bar: int) -> "subsequence.pattern_builder.PatternBuilder": + _, builder = _make_builder() + builder.bar = bar + return builder - pattern2, builder2 = _make_builder(length=4) - builder2.rng = random.Random(99) - _place_chromatic_notes(builder2, pitches_in) - builder2.snap_to_scale("C", "ionian", strength=0.5) - assert _pitches_in_pattern(pattern1) == _pitches_in_pattern(pattern2) +def test_bar_cycle_first() -> None: + builder = _make_builder_at_bar(0) + ph = builder.bar_cycle(4) + assert ph.bar == 0 + assert ph.first is True + assert ph.last is False -def test_snap_to_scale_strength_partial_snaps_some_notes () -> None: +def test_bar_cycle_last() -> None: + builder = _make_builder_at_bar(3) + ph = builder.bar_cycle(4) + assert ph.bar == 3 + assert ph.first is False + assert ph.last is True - """strength=0.5 should snap roughly half the notes (statistical check over many notes).""" - # Place 200 notes all on C# (61), which is outside C major. - # quantize_pitch prefers upward, so C# (61) → D (62). - # With strength=0.5, ~50% should be snapped to D (62), rest stay at C# (61). - n = 200 - pattern, builder = _make_builder(length=n * 0.25) - builder.rng = random.Random(7) - for i in range(n): - builder.note(61, beat=i * 0.25, duration=0.2) - builder.snap_to_scale("C", "ionian", strength=0.5) +def test_bar_cycle_middle() -> None: + builder = _make_builder_at_bar(2) + ph = builder.bar_cycle(4) + assert ph.bar == 2 + assert ph.first is False + assert ph.last is False - pitches = _pitches_in_pattern(pattern) - snapped_count = sum(1 for p in pitches if p == 62) # snapped up to D - unsnapped_count = sum(1 for p in pitches if p == 61) # left as C# - # Allow ±15% tolerance - assert 0.35 * n <= snapped_count <= 0.65 * n, ( - f"Expected ~{n // 2} snapped, got {snapped_count}/{n}" - ) - assert snapped_count + unsnapped_count == n +def test_bar_cycle_progress() -> None: + assert _make_builder_at_bar(0).bar_cycle(4).progress == pytest.approx(0.0) + assert _make_builder_at_bar(2).bar_cycle(4).progress == pytest.approx(0.5) + assert _make_builder_at_bar(3).bar_cycle(4).progress == pytest.approx(0.75) -# --------------------------------------------------------------------------- -# p.bar_cycle() -# --------------------------------------------------------------------------- +def test_bar_cycle_wraps_correctly() -> None: + # bar=5 with phrase(4) → position 1 (5 % 4 == 1) + ph = _make_builder_at_bar(5).bar_cycle(4) + assert ph.bar == 1 + assert ph.first is False + assert ph.last is False -def _make_builder_at_bar (bar: int) -> "subsequence.pattern_builder.PatternBuilder": - _, builder = _make_builder() - builder.bar = bar - return builder - -def test_bar_cycle_first () -> None: - builder = _make_builder_at_bar(0) - ph = builder.bar_cycle(4) - assert ph.bar == 0 - assert ph.first is True - assert ph.last is False - -def test_bar_cycle_last () -> None: - builder = _make_builder_at_bar(3) - ph = builder.bar_cycle(4) - assert ph.bar == 3 - assert ph.first is False - assert ph.last is True - -def test_bar_cycle_middle () -> None: - builder = _make_builder_at_bar(2) - ph = builder.bar_cycle(4) - assert ph.bar == 2 - assert ph.first is False - assert ph.last is False - -def test_bar_cycle_progress () -> None: - assert _make_builder_at_bar(0).bar_cycle(4).progress == pytest.approx(0.0) - assert _make_builder_at_bar(2).bar_cycle(4).progress == pytest.approx(0.5) - assert _make_builder_at_bar(3).bar_cycle(4).progress == pytest.approx(0.75) - -def test_bar_cycle_wraps_correctly () -> None: - # bar=5 with phrase(4) → position 1 (5 % 4 == 1) - ph = _make_builder_at_bar(5).bar_cycle(4) - assert ph.bar == 1 - assert ph.first is False - assert ph.last is False -def test_bar_cycle_single_bar () -> None: - ph = _make_builder_at_bar(0).bar_cycle(1) - assert ph.bar == 0 - assert ph.first is True - assert ph.last is True - assert ph.progress == pytest.approx(0.0) +def test_bar_cycle_single_bar() -> None: + ph = _make_builder_at_bar(0).bar_cycle(1) + assert ph.bar == 0 + assert ph.first is True + assert ph.last is True + assert ph.progress == pytest.approx(0.0) -def test_bar_cycle_length_8 () -> None: - # bar=2 with phrase(8) → "bar 3 of every 8" in user's example - ph = _make_builder_at_bar(2).bar_cycle(8) - assert ph.bar == 2 - assert ph.first is False - assert ph.last is False -def test_bar_cycle_last_of_16 () -> None: - # bar=15 with phrase(16) → last bar before every 16th - assert _make_builder_at_bar(15).bar_cycle(16).last is True +def test_bar_cycle_length_8() -> None: + # bar=2 with phrase(8) → "bar 3 of every 8" in user's example + ph = _make_builder_at_bar(2).bar_cycle(8) + assert ph.bar == 2 + assert ph.first is False + assert ph.last is False -def test_bar_cycle_through_4_bars () -> None: - positions = [_make_builder_at_bar(bar).bar_cycle(4).bar for bar in range(8)] - assert positions == [0, 1, 2, 3, 0, 1, 2, 3] +def test_bar_cycle_last_of_16() -> None: + # bar=15 with phrase(16) → last bar before every 16th + assert _make_builder_at_bar(15).bar_cycle(16).last is True -def test_build_velocity_ramp_returns_int_list () -> None: - _, builder = _make_builder(default_grid=4, length=4) - result = builder.build_velocity_ramp(0, 100, "linear") - assert len(result) == 4 - assert all(isinstance(v, int) for v in result) - assert result[0] == 0 - assert result[-1] == 100 +def test_bar_cycle_through_4_bars() -> None: + positions = [_make_builder_at_bar(bar).bar_cycle(4).bar for bar in range(8)] + assert positions == [0, 1, 2, 3, 0, 1, 2, 3] -def test_build_velocity_ramp_clamped () -> None: - _, builder = _make_builder(default_grid=4, length=4) - result = builder.build_velocity_ramp(0, 200, "linear") - assert all(0 <= v <= 127 for v in result) +def test_build_velocity_ramp_returns_int_list() -> None: + _, builder = _make_builder(default_grid=4, length=4) + result = builder.build_velocity_ramp(0, 100, "linear") + assert len(result) == 4 + assert all(isinstance(v, int) for v in result) + assert result[0] == 0 + assert result[-1] == 100 -def test_build_velocity_ramp_uses_grid () -> None: - _, builder = _make_builder(default_grid=8, length=8) - result = builder.build_velocity_ramp(50, 100) - assert len(result) == 8 +def test_build_velocity_ramp_clamped() -> None: + _, builder = _make_builder(default_grid=4, length=4) + result = builder.build_velocity_ramp(0, 200, "linear") + assert all(0 <= v <= 127 for v in result) -def test_no_overlap_rounding_edge_case () -> None: - """_has_pitch_at_beat must use the same int() truncation as add_note_beats. - At a beat where beat*PPQ == N+0.5, int() gives N but int()+0.5 rounds to N+1, - so the lookup would miss the placed note and no_overlap=True would fail.""" - _, builder = _make_builder(default_grid=16, length=4) - # 11.5/480 * 480 == 11.5 → int() truncates to 11, int(+0.5) rounds to 12 - beat = 11.5 / subsequence.constants.MIDI_QUARTER_NOTE - # Place a note at the edge-case beat position via add_note_beats - builder._pattern.add_note_beats(beat, 60, 100, 0.25) - # _has_pitch_at_beat must find it (would return False under the old + 0.5 rounding) - assert builder._has_pitch_at_beat(60, beat), "_has_pitch_at_beat missed a note placed at the same beat (rounding mismatch)" +def test_build_velocity_ramp_uses_grid() -> None: + _, builder = _make_builder(default_grid=8, length=8) + result = builder.build_velocity_ramp(50, 100) + assert len(result) == 8 -def test_portamento_wrap_reset_respects_first_onset () -> None: - """The wrap reset must land on the next cycle's FIRST ONSET, not pulse 0. +def test_no_overlap_rounding_edge_case() -> None: + """_has_pitch_at_beat must use the same int() truncation as add_note_beats. + At a beat where beat*PPQ == N+0.5, int() gives N but int()+0.5 rounds to N+1, + so the lookup would miss the placed note and no_overlap=True would fail.""" + _, builder = _make_builder(default_grid=16, length=4) + # 11.5/480 * 480 == 11.5 → int() truncates to 11, int(+0.5) rounds to 12 + beat = 11.5 / subsequence.constants.MIDI_QUARTER_NOTE + # Place a note at the edge-case beat position via add_note_beats + builder._pattern.add_note_beats(beat, 60, 100, 0.25) + # _has_pitch_at_beat must find it (would return False under the old + 0.5 rounding) + assert builder._has_pitch_at_beat(60, beat), ( + "_has_pitch_at_beat missed a note placed at the same beat (rounding mismatch)" + ) - Regression: with a first onset later than pulse 0, the pulse-0 reset fired - while the wrap-spilled glide was still in flight, so the destination note - played fully bent. - """ - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=6, pitch=40, velocity=80, duration=40) - builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=44) +def test_portamento_wrap_reset_respects_first_onset() -> None: + """The wrap reset must land on the next cycle's FIRST ONSET, not pulse 0. - builder.portamento(time=0.25, resolution=1, wrap=True) + Regression: with a first onset later than pulse 0, the pulse-0 reset fired + while the wrap-spilled glide was still in flight, so the destination note + played fully bent. + """ - bend_events = [e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel'] - resets = [e for e in bend_events if e.value == 0 and e.pulse >= 96] + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=6, pitch=40, velocity=80, duration=40) + builder._pattern.add_note(position=48, pitch=42, velocity=80, duration=44) - # total_pulses (96) + first onset (6) = 102. - assert any(e.pulse == 102 for e in resets) - assert not any(e.pulse == 96 for e in resets) + builder.portamento(time=0.25, resolution=1, wrap=True) + bend_events = [ + e for e in builder._pattern.cc_events if e.message_type == "pitchwheel" + ] + resets = [e for e in bend_events if e.value == 0 and e.pulse >= 96] -def test_slide_extend_glide_lands_on_target_onset () -> None: + # total_pulses (96) + first onset (6) = 102. + assert any(e.pulse == 102 for e in resets) + assert not any(e.pulse == 96 for e in resets) - """With extend=True and SHORT notes, the glide must end at the target onset. - Regression: the glide window was computed from the pre-extension duration, - so a short note produced a near-instant bend at its start followed by a - long flat hold - the opposite of a slide. - """ +def test_slide_extend_glide_lands_on_target_onset() -> None: + """With extend=True and SHORT notes, the glide must end at the target onset. - _, builder = _make_builder(length=4) - builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=2) - builder._pattern.add_note(position=24, pitch=42, velocity=80, duration=2) + Regression: the glide window was computed from the pre-extension duration, + so a short note produced a near-instant bend at its start followed by a + long flat hold - the opposite of a slide. + """ - builder.slide(notes=[1], time=0.5, wrap=False) + _, builder = _make_builder(length=4) + builder._pattern.add_note(position=0, pitch=40, velocity=80, duration=2) + builder._pattern.add_note(position=24, pitch=42, velocity=80, duration=2) - ramp_events = sorted( - (e for e in builder._pattern.cc_events if e.message_type == 'pitchwheel' and e.value != 0), - key=lambda e: e.pulse, - ) + builder.slide(notes=[1], time=0.5, wrap=False) - # Extended duration = 24; glide occupies [12, 24] and lands on the target. - assert ramp_events - assert ramp_events[0].pulse >= 12 - assert ramp_events[-1].pulse == 24 + ramp_events = sorted( + ( + e + for e in builder._pattern.cc_events + if e.message_type == "pitchwheel" and e.value != 0 + ), + key=lambda e: e.pulse, + ) + # Extended duration = 24; glide occupies [12, 24] and lands on the target. + assert ramp_events + assert ramp_events[0].pulse >= 12 + assert ramp_events[-1].pulse == 24 -def test_stretch_does_not_clip_ring_past_end () -> None: - """stretch() must not clip durations at the pattern boundary. +def test_stretch_does_not_clip_ring_past_end() -> None: + """stretch() must not clip durations at the pattern boundary. - A note deliberately ringing past the end (legato/drone style) must survive - stretch unchanged in proportion - and stretch(1.0) must be a true no-op. - """ + A note deliberately ringing past the end (legato/drone style) must survive + stretch unchanged in proportion - and stretch(1.0) must be a true no-op. + """ - pattern, builder = _make_builder(length=4) - builder.note(60, beat=3.9, duration=2.0) + pattern, builder = _make_builder(length=4) + builder.note(60, beat=3.9, duration=2.0) - before = [n.duration for s in pattern.steps.values() for n in s.notes] - builder.stretch(1.0) - after = [n.duration for s in pattern.steps.values() for n in s.notes] + before = [n.duration for s in pattern.steps.values() for n in s.notes] + builder.stretch(1.0) + after = [n.duration for s in pattern.steps.values() for n in s.notes] - assert after == before + assert after == before diff --git a/tests/test_phrases.py b/tests/test_phrases.py index bebbabd..c8a8e63 100644 --- a/tests/test_phrases.py +++ b/tests/test_phrases.py @@ -18,499 +18,526 @@ PPQ = subsequence.constants.MIDI_QUARTER_NOTE -def steps_of (m: "subsequence.Motif") -> typing.List[typing.Optional[int]]: +def steps_of(m: "subsequence.Motif") -> typing.List[typing.Optional[int]]: + """Degree steps per event (None for rests) — the melodic shape.""" - """Degree steps per event (None for rests) — the melodic shape.""" + return [event.pitch.step if event.pitch is not None else None for event in m.events] - return [event.pitch.step if event.pitch is not None else None for event in m.events] +def rhythm_of(value: typing.Any) -> typing.List[typing.Tuple[float, float]]: + """(beat, duration) pairs — the rhythm skeleton.""" -def rhythm_of (value: typing.Any) -> typing.List[typing.Tuple[float, float]]: + flat = value.flatten() if hasattr(value, "flatten") else value - """(beat, duration) pairs — the rhythm skeleton.""" + return [(event.beat, event.duration) for event in flat.events] - flat = value.flatten() if hasattr(value, "flatten") else value - return [(event.beat, event.duration) for event in flat.events] +def _builder( + cycle: int = 0, + length: float = 4.0, + section: typing.Any = None, + registry: typing.Any = None, +) -> subsequence.pattern_builder.PatternBuilder: + """A standalone builder over a fresh pattern.""" + pattern = subsequence.pattern.Pattern(channel=0, length=length) -def _builder (cycle: int = 0, length: float = 4.0, section: typing.Any = None, registry: typing.Any = None) -> subsequence.pattern_builder.PatternBuilder: + return subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=cycle, + key="A", + scale="minor", + rng=random.Random(1), + section=section, + section_motifs=registry, + ) - """A standalone builder over a fresh pattern.""" - pattern = subsequence.pattern.Pattern(channel=0, length=length) +def placed( + p: subsequence.pattern_builder.PatternBuilder, +) -> typing.List[typing.Tuple[float, int]]: + """(beat, pitch) pairs for every placed note, in time order.""" - return subsequence.pattern_builder.PatternBuilder( - pattern = pattern, - cycle = cycle, - key = "A", - scale = "minor", - rng = random.Random(1), - section = section, - section_motifs = registry, - ) + out = [] + for pulse in sorted(p._pattern.steps): + for note in p._pattern.steps[pulse].notes: + out.append((pulse / PPQ, note.pitch)) -def placed (p: subsequence.pattern_builder.PatternBuilder) -> typing.List[typing.Tuple[float, int]]: + return out - """(beat, pitch) pairs for every placed note, in time order.""" - out = [] - - for pulse in sorted(p._pattern.steps): - for note in p._pattern.steps[pulse].notes: - out.append((pulse / PPQ, note.pitch)) - - return out - - -CALL = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) # 8 beats, one per beat +CALL = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) # 8 beats, one per beat # --------------------------------------------------------------------------- # Motif.vary() # --------------------------------------------------------------------------- -def test_vary_changes_only_pitches_preserving_rhythm () -> None: - - """Exactly N pitches move; rhythm, rests, length, controls untouched.""" - - varied = CALL.vary(notes=2, seed=3) - - assert rhythm_of(varied) == rhythm_of(CALL) - assert varied.length == CALL.length - # The rest (beat 4) stays an absence — no event appears there. - assert [event.beat for event in varied.events] == [0.0, 1.0, 2.0, 3.0, 5.0, 6.0, 7.0] +def test_vary_changes_only_pitches_preserving_rhythm() -> None: + """Exactly N pitches move; rhythm, rests, length, controls untouched.""" - changed = sum( - 1 for before, after in zip(steps_of(CALL), steps_of(varied)) - if before != after - ) - assert changed == 2 + varied = CALL.vary(notes=2, seed=3) + assert rhythm_of(varied) == rhythm_of(CALL) + assert varied.length == CALL.length -def test_vary_positions () -> None: + # The rest (beat 4) stays an absence — no event appears there. + assert [event.beat for event in varied.events] == [ + 0.0, + 1.0, + 2.0, + 3.0, + 5.0, + 6.0, + 7.0, + ] - """end varies the tail; start the head; anywhere draws from the stream.""" + changed = sum( + 1 for before, after in zip(steps_of(CALL), steps_of(varied)) if before != after + ) + assert changed == 2 - tail = CALL.vary(notes=1, position="end", seed=3) - head = CALL.vary(notes=1, position="start", seed=3) - assert steps_of(tail)[:-1] == steps_of(CALL)[:-1] and steps_of(tail)[-1] != 3 - assert steps_of(head)[1:] == steps_of(CALL)[1:] and steps_of(head)[0] != 5 +def test_vary_positions() -> None: + """end varies the tail; start the head; anywhere draws from the stream.""" + tail = CALL.vary(notes=1, position="end", seed=3) + head = CALL.vary(notes=1, position="start", seed=3) -def test_vary_is_deterministic_and_clamps () -> None: + assert steps_of(tail)[:-1] == steps_of(CALL)[:-1] and steps_of(tail)[-1] != 3 + assert steps_of(head)[1:] == steps_of(CALL)[1:] and steps_of(head)[0] != 5 - """Same seed, same variation; notes clamps to what exists; 0 is identity.""" - assert steps_of(CALL.vary(notes=2, seed=9)) == steps_of(CALL.vary(notes=2, seed=9)) - assert CALL.vary(notes=99, seed=1).length == CALL.length - assert CALL.vary(notes=0, seed=1) == CALL +def test_vary_is_deterministic_and_clamps() -> None: + """Same seed, same variation; notes clamps to what exists; 0 is identity.""" + assert steps_of(CALL.vary(notes=2, seed=9)) == steps_of(CALL.vary(notes=2, seed=9)) + assert CALL.vary(notes=99, seed=1).length == CALL.length + assert CALL.vary(notes=0, seed=1) == CALL -def test_vary_drums_raise_and_no_seed_warns () -> None: - """A varied drum is a different instrument; the seed policy applies.""" +def test_vary_drums_raise_and_no_seed_warns() -> None: + """A varied drum is a different instrument; the seed policy applies.""" - kick = M.hits("kick", beats=[0, 2], length=4) + kick = M.hits("kick", beats=[0, 2], length=4) - with pytest.raises(TypeError, match="instrument"): - kick.vary(notes=1, seed=1) + with pytest.raises(TypeError, match="instrument"): + kick.vary(notes=1, seed=1) - with pytest.warns(UserWarning, match="seed"): - CALL.vary(notes=1) + with pytest.warns(UserWarning, match="seed"): + CALL.vary(notes=1) -def test_vary_degree_floor () -> None: +def test_vary_degree_floor() -> None: + """Varied degrees never drop below 1.""" - """Varied degrees never drop below 1.""" + low = subsequence.motif([1, 1, 1, 1]) - low = subsequence.motif([1, 1, 1, 1]) - - for seed in range(20): - assert all(step >= 1 for step in steps_of(low.vary(notes=4, position="anywhere", seed=seed)) if step) + for seed in range(20): + assert all( + step >= 1 + for step in steps_of(low.vary(notes=4, position="anywhere", seed=seed)) + if step + ) # --------------------------------------------------------------------------- # Motif.answer() # --------------------------------------------------------------------------- -def test_answer_reaims_the_tail_home () -> None: - - """The last pitched note lands on degree 1; everything else is untouched.""" - response = CALL.answer() +def test_answer_reaims_the_tail_home() -> None: + """The last pitched note lands on degree 1; everything else is untouched.""" - assert steps_of(response) == [5, 6, 5, 3, 1, 2, 1] # the rest holds no event - assert rhythm_of(response) == rhythm_of(CALL) + response = CALL.answer() + assert steps_of(response) == [5, 6, 5, 3, 1, 2, 1] # the rest holds no event + assert rhythm_of(response) == rhythm_of(CALL) -def test_answer_half_close_and_register () -> None: - """to=5 half-closes; the original note's octave survives the re-aim.""" +def test_answer_half_close_and_register() -> None: + """to=5 half-closes; the original note's octave survives the re-aim.""" - high_tail = M.degrees([5, Degree(3, octave=1)]) - answered = high_tail.answer(to=5) + high_tail = M.degrees([5, Degree(3, octave=1)]) + answered = high_tail.answer(to=5) - assert answered.events[-1].pitch == Degree(5, octave=1) + assert answered.events[-1].pitch == Degree(5, octave=1) -def test_answer_non_degree_content_raises () -> None: +def test_answer_non_degree_content_raises() -> None: + """Absolute MIDI has no degrees to re-aim.""" - """Absolute MIDI has no degrees to re-aim.""" - - with pytest.raises(TypeError, match="degrees"): - M.notes([60, 64, 67]).answer() + with pytest.raises(TypeError, match="degrees"): + M.notes([60, 64, 67]).answer() # --------------------------------------------------------------------------- # Phrase.develop() # --------------------------------------------------------------------------- -def test_develop_label_plan () -> None: - - """First label = the motif; repeats restate; new labels contrast (same rhythm).""" - - phrase = subsequence.Phrase.develop(CALL, bars=8, plan=["a", "a", "a", "b"], seed=11) - - assert len(phrase.segments) == 4 - assert phrase.segments[0] == CALL - assert phrase.segments[1] == CALL and phrase.segments[2] == CALL - - contrast = phrase.segments[3] - assert contrast != CALL - assert rhythm_of(contrast) == rhythm_of(CALL) - assert phrase.length == 32.0 - assert phrase.recipe is not None +def test_develop_label_plan() -> None: + """First label = the motif; repeats restate; new labels contrast (same rhythm).""" -def test_develop_call_response_recipe () -> None: + phrase = subsequence.Phrase.develop( + CALL, bars=8, plan=["a", "a", "a", "b"], seed=11 + ) - """call_response = call, answer, call, varied answer.""" + assert len(phrase.segments) == 4 + assert phrase.segments[0] == CALL + assert phrase.segments[1] == CALL and phrase.segments[2] == CALL - phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) + contrast = phrase.segments[3] + assert contrast != CALL + assert rhythm_of(contrast) == rhythm_of(CALL) + assert phrase.length == 32.0 + assert phrase.recipe is not None - assert phrase.segments[0] == CALL - assert phrase.segments[1] == CALL.answer() - assert phrase.segments[2] == CALL - assert phrase.segments[3] != CALL.answer() # the tail varied - assert rhythm_of(phrase.segments[3]) == rhythm_of(CALL) +def test_develop_call_response_recipe() -> None: + """call_response = call, answer, call, varied answer.""" -def test_develop_letter_string_fails_name_lookup_with_list_hint () -> None: + phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) - """plan="aaab" is not a plan — the error suggests the list (decision 16).""" + assert phrase.segments[0] == CALL + assert phrase.segments[1] == CALL.answer() + assert phrase.segments[2] == CALL + assert phrase.segments[3] != CALL.answer() # the tail varied + assert rhythm_of(phrase.segments[3]) == rhythm_of(CALL) - with pytest.raises(ValueError, match=r"plan=\['a', 'a', 'a', 'b'\]"): - subsequence.Phrase.develop(CALL, bars=8, plan="aaab", seed=1) - with pytest.raises(ValueError, match="Known recipes"): - subsequence.Phrase.develop(CALL, bars=8, plan="no_such_recipe", seed=1) +def test_develop_letter_string_fails_name_lookup_with_list_hint() -> None: + """plan="aaab" is not a plan — the error suggests the list (decision 16).""" + with pytest.raises(ValueError, match=r"plan=\['a', 'a', 'a', 'b'\]"): + subsequence.Phrase.develop(CALL, bars=8, plan="aaab", seed=1) -def test_develop_validates_bars_against_units () -> None: + with pytest.raises(ValueError, match="Known recipes"): + subsequence.Phrase.develop(CALL, bars=8, plan="no_such_recipe", seed=1) - """Uneven bars or a mismatched motif length fail with the numbers.""" - with pytest.raises(ValueError, match="evenly"): - subsequence.Phrase.develop(CALL, bars=7, plan=["a", "b"], seed=1) +def test_develop_validates_bars_against_units() -> None: + """Uneven bars or a mismatched motif length fail with the numbers.""" - with pytest.raises(ValueError, match="beats"): - subsequence.Phrase.develop(CALL, bars=2, plan=["a", "b"], seed=1) # 4-beat units vs an 8-beat motif + with pytest.raises(ValueError, match="evenly"): + subsequence.Phrase.develop(CALL, bars=7, plan=["a", "b"], seed=1) + with pytest.raises(ValueError, match="beats"): + subsequence.Phrase.develop( + CALL, bars=2, plan=["a", "b"], seed=1 + ) # 4-beat units vs an 8-beat motif -def test_develop_is_deterministic_and_warns_without_seed () -> None: - """Same seed, same phrase; the generator seed policy applies.""" +def test_develop_is_deterministic_and_warns_without_seed() -> None: + """Same seed, same phrase; the generator seed policy applies.""" - first = subsequence.Phrase.develop(CALL, bars=8, plan=["a", "b", "a", "b"], seed=5) - second = subsequence.Phrase.develop(CALL, bars=8, plan=["a", "b", "a", "b"], seed=5) + first = subsequence.Phrase.develop(CALL, bars=8, plan=["a", "b", "a", "b"], seed=5) + second = subsequence.Phrase.develop(CALL, bars=8, plan=["a", "b", "a", "b"], seed=5) - assert first.segments == second.segments + assert first.segments == second.segments - with pytest.warns(UserWarning, match="seed"): - subsequence.Phrase.develop(CALL, bars=8, plan=["a", "a", "a", "b"]) + with pytest.warns(UserWarning, match="seed"): + subsequence.Phrase.develop(CALL, bars=8, plan=["a", "a", "a", "b"]) -def test_develop_requires_a_plan () -> None: +def test_develop_requires_a_plan() -> None: + """No silent default plan.""" - """No silent default plan.""" - - with pytest.raises(ValueError, match="plan="): - subsequence.Phrase.develop(CALL, bars=8, seed=1) + with pytest.raises(ValueError, match="plan="): + subsequence.Phrase.develop(CALL, bars=8, seed=1) # --------------------------------------------------------------------------- # Phrase.reroll() # --------------------------------------------------------------------------- -def test_reroll_changes_only_the_named_bar () -> None: - - """Bar 7 re-rolls; every other bar is untouched; rhythm survives everywhere.""" - - phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) - rerolled = phrase.reroll(bar=7, seed=4) - - assert rhythm_of(rerolled) == rhythm_of(phrase) - original = phrase.flatten().events - changed = rerolled.flatten().events - bar_window = lambda event: 24.0 <= event.beat < 28.0 # bar 7 of 8 (4 beats each) +def test_reroll_changes_only_the_named_bar() -> None: + """Bar 7 re-rolls; every other bar is untouched; rhythm survives everywhere.""" - outside_before = [event for event in original if not bar_window(event)] - outside_after = [event for event in changed if not bar_window(event)] - assert outside_before == outside_after + phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) + rerolled = phrase.reroll(bar=7, seed=4) - inside_before = [event for event in original if bar_window(event)] - inside_after = [event for event in changed if bar_window(event)] - assert inside_before != inside_after + assert rhythm_of(rerolled) == rhythm_of(phrase) + original = phrase.flatten().events + changed = rerolled.flatten().events + bar_window = lambda event: 24.0 <= event.beat < 28.0 # bar 7 of 8 (4 beats each) -def test_reroll_keeps_boundary_pitches () -> None: + outside_before = [event for event in original if not bar_window(event)] + outside_after = [event for event in changed if not bar_window(event)] + assert outside_before == outside_after - """The first and last pitched notes of the bar are pins.""" + inside_before = [event for event in original if bar_window(event)] + inside_after = [event for event in changed if bar_window(event)] + assert inside_before != inside_after - phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) - rerolled = phrase.reroll(bar=7, seed=4) - def bar_events (value: "subsequence.Phrase") -> typing.List[typing.Any]: - return [e for e in value.flatten().events if 24.0 <= e.beat < 28.0 and e.pitch is not None] +def test_reroll_keeps_boundary_pitches() -> None: + """The first and last pitched notes of the bar are pins.""" - before, after = bar_events(phrase), bar_events(rerolled) + phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) + rerolled = phrase.reroll(bar=7, seed=4) - assert after[0].pitch == before[0].pitch - assert after[-1].pitch == before[-1].pitch + def bar_events(value: "subsequence.Phrase") -> typing.List[typing.Any]: + return [ + e + for e in value.flatten().events + if 24.0 <= e.beat < 28.0 and e.pitch is not None + ] + before, after = bar_events(phrase), bar_events(rerolled) -def test_reroll_composes_and_is_deterministic () -> None: + assert after[0].pitch == before[0].pitch + assert after[-1].pitch == before[-1].pitch - """The recipe survives a reroll, so rerolls chain; same seed, same roll.""" - phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) +def test_reroll_composes_and_is_deterministic() -> None: + """The recipe survives a reroll, so rerolls chain; same seed, same roll.""" - once = phrase.reroll(bar=7, seed=4) - again = phrase.reroll(bar=7, seed=4) - assert once.flatten().events == again.flatten().events + phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) - chained = once.reroll(bars=[3, 4], seed=9) - assert chained.recipe is phrase.recipe + once = phrase.reroll(bar=7, seed=4) + again = phrase.reroll(bar=7, seed=4) + assert once.flatten().events == again.flatten().events + chained = once.reroll(bars=[3, 4], seed=9) + assert chained.recipe is phrase.recipe -def test_reroll_literal_raises_loudly () -> None: - """A hand-written phrase has no recipe to regenerate from.""" +def test_reroll_literal_raises_loudly() -> None: + """A hand-written phrase has no recipe to regenerate from.""" - literal = subsequence.Phrase([CALL, CALL.answer()]) + literal = subsequence.Phrase([CALL, CALL.answer()]) - with pytest.raises(ValueError, match="recipe"): - literal.reroll(bar=1, seed=1) + with pytest.raises(ValueError, match="recipe"): + literal.reroll(bar=1, seed=1) -def test_reroll_transformed_phrase_raises () -> None: +def test_reroll_transformed_phrase_raises() -> None: + """Transforms drop the recipe — the notes no longer come from it.""" - """Transforms drop the recipe — the notes no longer come from it.""" + phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) - phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) + with pytest.raises(ValueError, match="recipe"): + phrase.stretch(2.0).reroll(bar=1, seed=1) - with pytest.raises(ValueError, match="recipe"): - phrase.stretch(2.0).reroll(bar=1, seed=1) +def test_reroll_validates_region_arguments() -> None: + """bar=/bars= are exclusive; bars are 1-based and in range.""" -def test_reroll_validates_region_arguments () -> None: + phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) - """bar=/bars= are exclusive; bars are 1-based and in range.""" - - phrase = subsequence.Phrase.develop(CALL, bars=8, plan="call_response", seed=11) - - with pytest.raises(ValueError, match="exactly one"): - phrase.reroll(bar=1, bars=[2], seed=1) - with pytest.raises(ValueError, match="exactly one"): - phrase.reroll(seed=1) - with pytest.raises(ValueError, match="outside"): - phrase.reroll(bar=9, seed=1) - with pytest.raises(ValueError, match="outside"): - phrase.reroll(bar=0, seed=1) + with pytest.raises(ValueError, match="exactly one"): + phrase.reroll(bar=1, bars=[2], seed=1) + with pytest.raises(ValueError, match="exactly one"): + phrase.reroll(seed=1) + with pytest.raises(ValueError, match="outside"): + phrase.reroll(bar=9, seed=1) + with pytest.raises(ValueError, match="outside"): + phrase.reroll(bar=0, seed=1) # --------------------------------------------------------------------------- # p.phrase() — stateless placement # --------------------------------------------------------------------------- -def test_phrase_position_walks_with_the_cycle () -> None: - - """A 4-beat pattern walks an 8-beat phrase: first half, then second, then loops.""" - value = subsequence.Phrase([M.degrees([1, 2, 3, 4]), M.degrees([5, 6, 7, 8])]) +def test_phrase_position_walks_with_the_cycle() -> None: + """A 4-beat pattern walks an 8-beat phrase: first half, then second, then loops.""" - first = _builder(cycle=0); first.phrase(value, root=60) - second = _builder(cycle=1); second.phrase(value, root=60) - third = _builder(cycle=2); third.phrase(value, root=60) + value = subsequence.Phrase([M.degrees([1, 2, 3, 4]), M.degrees([5, 6, 7, 8])]) - assert [pitch for _, pitch in placed(first)] == [pitch for _, pitch in placed(third)] - assert placed(first) != placed(second) - assert len(placed(second)) == 4 + first = _builder(cycle=0) + first.phrase(value, root=60) + second = _builder(cycle=1) + second.phrase(value, root=60) + third = _builder(cycle=2) + third.phrase(value, root=60) + assert [pitch for _, pitch in placed(first)] == [ + pitch for _, pitch in placed(third) + ] + assert placed(first) != placed(second) + assert len(placed(second)) == 4 -def test_phrase_wraps_across_its_end () -> None: - """A cycle window straddling the phrase end loops to the start.""" +def test_phrase_wraps_across_its_end() -> None: + """A cycle window straddling the phrase end loops to the start.""" - value = subsequence.Phrase([M.degrees([1, 2, 3, 4, 5, 6], length=6.0)]) + value = subsequence.Phrase([M.degrees([1, 2, 3, 4, 5, 6], length=6.0)]) - wrap = _builder(cycle=1) # beats 4..8 of a 6-beat phrase → [4,6) then [0,2) - wrap.phrase(value, root=60) + wrap = _builder(cycle=1) # beats 4..8 of a 6-beat phrase → [4,6) then [0,2) + wrap.phrase(value, root=60) - notes = placed(wrap) - assert len(notes) == 4 - assert [beat for beat, _ in notes] == [0.0, 1.0, 2.0, 3.0] + notes = placed(wrap) + assert len(notes) == 4 + assert [beat for beat, _ in notes] == [0.0, 1.0, 2.0, 3.0] - tonic = _builder(cycle=0); tonic.phrase(value, root=60) - assert notes[2][1] == placed(tonic)[0][1] # beat 2 of the window = the phrase's start + tonic = _builder(cycle=0) + tonic.phrase(value, root=60) + assert ( + notes[2][1] == placed(tonic)[0][1] + ) # beat 2 of the window = the phrase's start -def test_phrase_offset_and_section_alignment () -> None: +def test_phrase_offset_and_section_alignment() -> None: + """offset= phase-shifts; align="section" follows the section bar.""" - """offset= phase-shifts; align="section" follows the section bar.""" + value = subsequence.Phrase([M.degrees([1, 2, 3, 4]), M.degrees([5, 6, 7, 8])]) - value = subsequence.Phrase([M.degrees([1, 2, 3, 4]), M.degrees([5, 6, 7, 8])]) + shifted = _builder(cycle=0) + shifted.phrase(value, root=60, offset=4.0) + plain = _builder(cycle=1) + plain.phrase(value, root=60) + assert placed(shifted) == placed(plain) - shifted = _builder(cycle=0) - shifted.phrase(value, root=60, offset=4.0) - plain = _builder(cycle=1) - plain.phrase(value, root=60) - assert placed(shifted) == placed(plain) + class FakeSection: + name = "verse" + bar = 1 # 0-indexed: the second bar of the section - class FakeSection: - name = "verse" - bar = 1 # 0-indexed: the second bar of the section + sectioned = _builder(cycle=99, section=FakeSection()) + sectioned.phrase(value, root=60, align="section") + assert placed(sectioned) == placed(plain) - sectioned = _builder(cycle=99, section=FakeSection()) - sectioned.phrase(value, root=60, align="section") - assert placed(sectioned) == placed(plain) + with pytest.raises(ValueError, match="form"): + _builder(cycle=0).phrase(value, root=60, align="section") - with pytest.raises(ValueError, match="form"): - _builder(cycle=0).phrase(value, root=60, align="section") +def test_phrase_accepts_motifs_and_validates() -> None: + """Motifs duck-type in; garbage and empties fail loudly.""" -def test_phrase_accepts_motifs_and_validates () -> None: + walker = _builder(cycle=0) + walker.phrase(M.degrees([1, 2, 3, 4]), root=60) + assert len(placed(walker)) == 4 - """Motifs duck-type in; garbage and empties fail loudly.""" - - walker = _builder(cycle=0) - walker.phrase(M.degrees([1, 2, 3, 4]), root=60) - assert len(placed(walker)) == 4 - - with pytest.raises(TypeError, match="Phrase-like"): - _builder().phrase(42) + with pytest.raises(TypeError, match="Phrase-like"): + _builder().phrase(42) # --------------------------------------------------------------------------- # section_motifs registry + phrase_part + sketch (b) # --------------------------------------------------------------------------- -def test_section_motif_reads_the_registry () -> None: - - """p.section_motif() returns the binding for the current section and part.""" - - class FakeSection: - name = "verse" - bar = 0 - - registry = {("verse", "lead"): CALL, ("verse", None): CALL.answer()} - - p = _builder(section=FakeSection(), registry=registry) - - assert p.section_motif("lead") == CALL - assert p.section_motif() == CALL.answer() - assert p.section_motif("bass") is None - assert _builder().section_motif("lead") is None # no form → None +def test_section_motif_reads_the_registry() -> None: + """p.section_motif() returns the binding for the current section and part.""" -def test_section_motifs_binder_validates (patch_midi: None) -> None: + class FakeSection: + name = "verse" + bar = 0 - """Unknown form sections and non-values are rejected.""" + registry = {("verse", "lead"): CALL, ("verse", None): CALL.answer()} - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="A", scale="minor") - composition.form({"verse": (4, [("verse", 1)])}, start="verse") + p = _builder(section=FakeSection(), registry=registry) - with pytest.raises(ValueError, match="not found"): - composition.section_motifs("bridge", CALL) - with pytest.raises(TypeError, match="Motif/Phrase"): - composition.section_motifs("verse", [1, 2, 3]) + assert p.section_motif("lead") == CALL + assert p.section_motif() == CALL.answer() + assert p.section_motif("bass") is None + assert _builder().section_motif("lead") is None # no form → None - composition.section_motifs("verse", CALL, part="lead") - assert composition._section_motifs[("verse", "lead")] == CALL +def test_section_motifs_binder_validates(patch_midi: None) -> None: + """Unknown form sections and non-values are rejected.""" -def test_phrase_part_follows_sections_and_silences_unbound (tmp_path: pathlib.Path, patch_midi: None) -> None: + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="A", scale="minor" + ) + composition.form({"verse": (4, [("verse", 1)])}, start="verse") - """Each section sounds its bound line; a section with no binding is silent.""" + with pytest.raises(ValueError, match="not found"): + composition.section_motifs("bridge", CALL) + with pytest.raises(TypeError, match="Motif/Phrase"): + composition.section_motifs("verse", [1, 2, 3]) - filename = str(tmp_path / "parts.mid") - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C") - composition.form([("verse", 1), ("chorus", 1), ("outro", 1)]) + composition.section_motifs("verse", CALL, part="lead") + assert composition._section_motifs[("verse", "lead")] == CALL - composition.section_motifs("verse", M.notes([60, 62, 64, 65]), part="lead") - composition.section_motifs("chorus", M.notes([72, 74, 76, 77]), part="lead") - # outro deliberately unbound — silence, by design - composition.phrase_part(channel=2, part="lead", beats=4) +def test_phrase_part_follows_sections_and_silences_unbound( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Each section sounds its bound line; a section with no binding is silent.""" - composition.render(bars=3, filename=filename) + filename = str(tmp_path / "parts.mid") + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="C") + composition.form([("verse", 1), ("chorus", 1), ("outro", 1)]) - mid = mido.MidiFile(filename) - ticks_per_bar = mid.ticks_per_beat * 4 - by_bar: typing.Dict[int, typing.List[int]] = {} - for track in mid.tracks: - now = 0 - for msg in track: - now += msg.time - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0: - by_bar.setdefault(now // ticks_per_bar, []).append(msg.note) + composition.section_motifs("verse", M.notes([60, 62, 64, 65]), part="lead") + composition.section_motifs("chorus", M.notes([72, 74, 76, 77]), part="lead") + # outro deliberately unbound — silence, by design - assert sorted(by_bar[0]) == [60, 62, 64, 65] - assert sorted(by_bar[1]) == [72, 74, 76, 77] - assert 2 not in by_bar + composition.phrase_part(channel=2, part="lead", beats=4) + composition.render(bars=3, filename=filename) -def test_sketch_b_motif_to_phrase_to_placement_to_reroll (tmp_path: pathlib.Path, patch_midi: None) -> None: + mid = mido.MidiFile(filename) + ticks_per_bar = mid.ticks_per_beat * 4 + by_bar: typing.Dict[int, typing.List[int]] = {} + for track in mid.tracks: + now = 0 + for msg in track: + now += msg.time + if ( + not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + ): + by_bar.setdefault(now // ticks_per_bar, []).append(msg.note) - """Sketch (b): hand-written 2-bar motif → 8-bar phrase → placement → reroll bar 7.""" + assert sorted(by_bar[0]) == [60, 62, 64, 65] + assert sorted(by_bar[1]) == [72, 74, 76, 77] + assert 2 not in by_bar - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=480, key="A", scale="minor", seed=42) - call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]).accent(0) - lead_line = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) - lead_line = lead_line.reroll(bar=7, seed=4) +def test_sketch_b_motif_to_phrase_to_placement_to_reroll( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """Sketch (b): hand-written 2-bar motif → 8-bar phrase → placement → reroll bar 7.""" - heard: typing.List[typing.Tuple[int, typing.Tuple[typing.Tuple[float, int], ...]]] = [] + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=480, key="A", scale="minor", seed=42 + ) - @composition.pattern(channel=4, bars=2) - def lead (p) -> None: - p.phrase(lead_line, root=72, fit=0.8) - p.legato(0.9) - heard.append(p.cycle) + call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]).accent(0) + lead_line = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) + lead_line = lead_line.reroll(bar=7, seed=4) - composition.render(bars=8, filename=str(tmp_path / "sketch_b.mid")) + heard: typing.List[ + typing.Tuple[int, typing.Tuple[typing.Tuple[float, int], ...]] + ] = [] - # Four cycles walked the whole phrase (a fifth rebuild may prepare the - # cycle after the render window — lookahead). - assert heard[:4] == [0, 1, 2, 3] + @composition.pattern(channel=4, bars=2) + def lead(p) -> None: + p.phrase(lead_line, root=72, fit=0.8) + p.legato(0.9) + heard.append(p.cycle) - mid = mido.MidiFile(str(tmp_path / "sketch_b.mid")) - window = mid.ticks_per_beat * 32 # the 8 rendered bars + composition.render(bars=8, filename=str(tmp_path / "sketch_b.mid")) - count = 0 - for track in mid.tracks: - now = 0 - for msg in track: - now += msg.time - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0 and now < window: - count += 1 + # Four cycles walked the whole phrase (a fifth rebuild may prepare the + # cycle after the render window — lookahead). + assert heard[:4] == [0, 1, 2, 3] - # 8 bars x (4 notes per bar minus the rest every 2 bars) = 28 notes - # inside the rendered window (lookahead may queue the next cycle's first - # note exactly on the boundary). - assert count == 28 + mid = mido.MidiFile(str(tmp_path / "sketch_b.mid")) + window = mid.ticks_per_beat * 32 # the 8 rendered bars + + count = 0 + for track in mid.tracks: + now = 0 + for msg in track: + now += msg.time + if ( + not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + and now < window + ): + count += 1 + + # 8 bars x (4 notes per bar minus the rest every 2 bars) = 28 notes + # inside the rendered window (lookahead may queue the next cycle's first + # note exactly on the boundary). + assert count == 28 diff --git a/tests/test_presets.py b/tests/test_presets.py index 51a70a8..6f9a629 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -23,33 +23,38 @@ # --------------------------------------------------------------------------- -def test_every_preset_parses_and_resolves () -> None: +def test_every_preset_parses_and_resolves() -> None: + """Every genre preset is a valid, resolvable progression.""" - """Every genre preset is a valid, resolvable progression.""" + for name in subsequence.progressions._PRESETS: + value = subsequence.progression(name) + assert isinstance(value, subsequence.Progression) + resolved = value.resolve("C", "major") + assert resolved.is_concrete + assert len(resolved.chords) == len(value.spans) - for name in subsequence.progressions._PRESETS: - value = subsequence.progression(name) - assert isinstance(value, subsequence.Progression) - resolved = value.resolve("C", "major") - assert resolved.is_concrete - assert len(resolved.chords) == len(value.spans) +def test_named_presets_resolve_as_expected() -> None: + """A few presets resolve to their known chords.""" -def test_named_presets_resolve_as_expected () -> None: + assert [ + c.name() + for c in subsequence.progression("trance_epic").resolve("A", "aeolian").chords + ] == ["Am", "F", "C", "G"] + assert [ + c.name() for c in subsequence.progression("pop_axis").resolve("C").chords + ] == ["C", "G", "Am", "F"] + assert [ + c.name() + for c in subsequence.progression("twelve_bar_blues").resolve("E").chords + ][:4] == ["E7", "E7", "E7", "E7"] - """A few presets resolve to their known chords.""" - assert [c.name() for c in subsequence.progression("trance_epic").resolve("A", "aeolian").chords] == ["Am", "F", "C", "G"] - assert [c.name() for c in subsequence.progression("pop_axis").resolve("C").chords] == ["C", "G", "Am", "F"] - assert [c.name() for c in subsequence.progression("twelve_bar_blues").resolve("E").chords][:4] == ["E7", "E7", "E7", "E7"] +def test_unknown_preset_lists_the_table() -> None: + """An unknown name raises, naming the known presets.""" - -def test_unknown_preset_lists_the_table () -> None: - - """An unknown name raises, naming the known presets.""" - - with pytest.raises(ValueError, match="Unknown progression preset"): - subsequence.progression("not_a_genre") + with pytest.raises(ValueError, match="Unknown progression preset"): + subsequence.progression("not_a_genre") # --------------------------------------------------------------------------- @@ -57,37 +62,36 @@ def test_unknown_preset_lists_the_table () -> None: # --------------------------------------------------------------------------- -def test_sieve_union_builds_scales_and_pools () -> None: - - """The primary union form builds scales, whole-tone, and non-octave pools.""" - - major = su.sieve([(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12) - assert major == [0, 2, 4, 5, 7, 9, 11] - - assert su.sieve([(2, 0)], hi=12) == [0, 2, 4, 6, 8, 10] # whole-tone +def test_sieve_union_builds_scales_and_pools() -> None: + """The primary union form builds scales, whole-tone, and non-octave pools.""" - pool = su.sieve([(5, 0), (7, 1)], lo=60, hi=96) # a non-octave pool - assert pool == sorted(pool) and pool[0] >= 60 and pool[-1] < 96 - assert 60 in pool and 64 in pool # 5·0 and 7·1+... members + major = su.sieve( + [(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12 + ) + assert major == [0, 2, 4, 5, 7, 9, 11] + assert su.sieve([(2, 0)], hi=12) == [0, 2, 4, 6, 8, 10] # whole-tone -def test_sieve_validates_modulus () -> None: + pool = su.sieve([(5, 0), (7, 1)], lo=60, hi=96) # a non-octave pool + assert pool == sorted(pool) and pool[0] >= 60 and pool[-1] < 96 + assert 60 in pool and 64 in pool # 5·0 and 7·1+... members - """A modulus below 1 raises.""" - with pytest.raises(ValueError, match="modulus"): - su.sieve([(0, 0)], hi=8) +def test_sieve_validates_modulus() -> None: + """A modulus below 1 raises.""" + with pytest.raises(ValueError, match="modulus"): + su.sieve([(0, 0)], hi=8) -def test_sieve_algebra () -> None: - """residual_class composes under | & ~.""" +def test_sieve_algebra() -> None: + """residual_class composes under | & ~.""" - rc = su.residual_class - assert (rc(2, 0) | rc(3, 0)).evaluate(hi=12) == [0, 2, 3, 4, 6, 8, 9, 10] - assert (rc(2, 0) & rc(3, 0)).evaluate(hi=12) == [0, 6] # multiples of 6 - assert (~rc(2, 0)).evaluate(hi=8) == [1, 3, 5, 7] # the odds - assert 4 in rc(2, 0) and 5 not in rc(2, 0) + rc = su.residual_class + assert (rc(2, 0) | rc(3, 0)).evaluate(hi=12) == [0, 2, 3, 4, 6, 8, 9, 10] + assert (rc(2, 0) & rc(3, 0)).evaluate(hi=12) == [0, 6] # multiples of 6 + assert (~rc(2, 0)).evaluate(hi=8) == [1, 3, 5, 7] # the odds + assert 4 in rc(2, 0) and 5 not in rc(2, 0) # --------------------------------------------------------------------------- @@ -95,33 +99,32 @@ def test_sieve_algebra () -> None: # --------------------------------------------------------------------------- -def test_evenness_picks_out_euclidean_rhythms () -> None: +def test_evenness_picks_out_euclidean_rhythms() -> None: + """A maximally-even (Euclidean) rhythm scores higher than a clustered one.""" - """A maximally-even (Euclidean) rhythm scores higher than a clustered one.""" + assert su.rhythmic_evenness([0, 3, 6], 8) > su.rhythmic_evenness([0, 1, 2], 8) + assert su.rhythmic_evenness([0, 4, 8, 12], 16) == pytest.approx( + 1.0 + ) # perfectly even + assert su.rhythmic_evenness([0], 8) == 1.0 # degenerate - assert su.rhythmic_evenness([0, 3, 6], 8) > su.rhythmic_evenness([0, 1, 2], 8) - assert su.rhythmic_evenness([0, 4, 8, 12], 16) == pytest.approx(1.0) # perfectly even - assert su.rhythmic_evenness([0], 8) == 1.0 # degenerate +def test_offbeatness_counts_coprime_pulses() -> None: + """Four-on-the-floor is fully on-beat; off-beats land on coprime pulses.""" -def test_offbeatness_counts_coprime_pulses () -> None: + assert su.offbeatness([0, 4, 8, 12], 16) == 0 + assert su.offbeatness([1, 3, 5, 7, 9, 11, 13, 15], 16) == 8 # all odd = all coprime + assert su.offbeatness([0, 3, 6, 10, 13], 16) == 2 # bossa: pulses 3 and 13 - """Four-on-the-floor is fully on-beat; off-beats land on coprime pulses.""" - assert su.offbeatness([0, 4, 8, 12], 16) == 0 - assert su.offbeatness([1, 3, 5, 7, 9, 11, 13, 15], 16) == 8 # all odd = all coprime - assert su.offbeatness([0, 3, 6, 10, 13], 16) == 2 # bossa: pulses 3 and 13 +def test_syncopation_rises_off_the_beat() -> None: + """On-beat rhythms score low; weak-pulse rhythms score high.""" + assert su.syncopation([0], 16) == 0.0 + assert su.syncopation([3, 7, 11, 15], 16) > su.syncopation([0, 4, 8, 12], 16) -def test_syncopation_rises_off_the_beat () -> None: - - """On-beat rhythms score low; weak-pulse rhythms score high.""" - - assert su.syncopation([0], 16) == 0.0 - assert su.syncopation([3, 7, 11, 15], 16) > su.syncopation([0, 4, 8, 12], 16) - - with pytest.raises(ValueError, match="one value per grid"): - su.syncopation([0], 16, weights=[1.0, 0.5]) + with pytest.raises(ValueError, match="one value per grid"): + su.syncopation([0], 16, weights=[1.0, 0.5]) # --------------------------------------------------------------------------- @@ -129,61 +132,61 @@ def test_syncopation_rises_off_the_beat () -> None: # --------------------------------------------------------------------------- -def test_preset_places_clave_onsets () -> None: - - """son clave 3-2 onsets land at the catalogued pulses over one bar.""" - - clave = subsequence.Motif.preset("son_clave_3_2") - assert clave.onsets() == [0.0, 0.75, 1.5, 2.5, 3.0] # pulses 0,3,6,10,12 of 16 - assert clave.length == 4.0 - assert all(event.pitch == "claves" for event in clave.events) # default GM voice +def test_preset_places_clave_onsets() -> None: + """son clave 3-2 onsets land at the catalogued pulses over one bar.""" + clave = subsequence.Motif.preset("son_clave_3_2") + assert clave.onsets() == [0.0, 0.75, 1.5, 2.5, 3.0] # pulses 0,3,6,10,12 of 16 + assert clave.length == 4.0 + assert all(event.pitch == "claves" for event in clave.events) # default GM voice -def test_preset_default_voices_resolve_against_gm () -> None: - """The default voices are real GM drum names — a no-pitch preset sounds. +def test_preset_default_voices_resolve_against_gm() -> None: + """The default voices are real GM drum names — a no-pitch preset sounds. - Regression for the review's "silent default voice" finding: place the - preset against the GM map and assert the drum names resolve to notes. - """ + Regression for the review's "silent default voice" finding: place the + preset against the GM map and assert the drum names resolve to notes. + """ - import subsequence.constants.instruments.gm_drums as gm - import subsequence.pattern as pattern_mod - import subsequence.pattern_builder as pb + import subsequence.constants.instruments.gm_drums as gm + import subsequence.pattern as pattern_mod + import subsequence.pattern_builder as pb - for name in subsequence.motifs._WORLD_RHYTHMS: - _steps, _grid, voice = subsequence.motifs._WORLD_RHYTHMS[name] - assert voice in gm.GM_DRUM_MAP, f"{name}: default voice {voice!r} is not a GM drum" + for name in subsequence.motifs._WORLD_RHYTHMS: + _steps, _grid, voice = subsequence.motifs._WORLD_RHYTHMS[name] + assert voice in gm.GM_DRUM_MAP, ( + f"{name}: default voice {voice!r} is not a GM drum" + ) - # And the placed motif actually emits notes through the funnel. - builder = pb.PatternBuilder( - pattern=pattern_mod.Pattern(channel=9, length=4.0), - cycle=0, - drum_note_map=gm.GM_DRUM_MAP, - ) - builder.motif(subsequence.Motif.preset("son_clave_3_2")) - notes = [note.pitch for step in builder._pattern.steps.values() for note in step.notes] - assert notes == [75, 75, 75, 75, 75] # 5 claves hits, GM note 75 + # And the placed motif actually emits notes through the funnel. + builder = pb.PatternBuilder( + pattern=pattern_mod.Pattern(channel=9, length=4.0), + cycle=0, + drum_note_map=gm.GM_DRUM_MAP, + ) + builder.motif(subsequence.Motif.preset("son_clave_3_2")) + notes = [ + note.pitch for step in builder._pattern.steps.values() for note in step.notes + ] + assert notes == [75, 75, 75, 75, 75] # 5 claves hits, GM note 75 -def test_preset_pitch_override_and_12_pulse () -> None: +def test_preset_pitch_override_and_12_pulse() -> None: + """pitch= overrides the voice; a bell pattern uses its 12-pulse grid.""" - """pitch= overrides the voice; a bell pattern uses its 12-pulse grid.""" + clave = subsequence.Motif.preset("son_clave_3_2", pitch="rim") + assert all(event.pitch == "rim" for event in clave.events) - clave = subsequence.Motif.preset("son_clave_3_2", pitch="rim") - assert all(event.pitch == "rim" for event in clave.events) + bembe = subsequence.Motif.preset("bembe") + assert len(bembe.onsets()) == 7 + assert bembe.onsets()[1] == pytest.approx(2 * 4.0 / 12) # pulse 2 of a 12-grid bar - bembe = subsequence.Motif.preset("bembe") - assert len(bembe.onsets()) == 7 - assert bembe.onsets()[1] == pytest.approx(2 * 4.0 / 12) # pulse 2 of a 12-grid bar +def test_preset_unknown_name_raises() -> None: + """An unknown rhythm name lists the table.""" -def test_preset_unknown_name_raises () -> None: - - """An unknown rhythm name lists the table.""" - - with pytest.raises(ValueError, match="Unknown rhythm preset"): - subsequence.Motif.preset("not_a_rhythm") + with pytest.raises(ValueError, match="Unknown rhythm preset"): + subsequence.Motif.preset("not_a_rhythm") # --------------------------------------------------------------------------- @@ -191,18 +194,17 @@ def test_preset_unknown_name_raises () -> None: # --------------------------------------------------------------------------- -def test_role_bundles_are_splattable_kwargs () -> None: - - """Roles are plain kwarg dicts over the placement surface.""" +def test_role_bundles_are_splattable_kwargs() -> None: + """Roles are plain kwarg dicts over the placement surface.""" - assert subsequence.roles.BASS["root"] == 36 - assert set(subsequence.roles.LEAD) <= {"root", "velocity", "fit"} - assert set(subsequence.roles.ROLES) == {"bass", "pad", "lead", "arp"} + assert subsequence.roles.BASS["root"] == 36 + assert set(subsequence.roles.LEAD) <= {"root", "velocity", "fit"} + assert set(subsequence.roles.ROLES) == {"bass", "pad", "lead", "arp"} - # Splatting into a placement call is the intended use. - composition = subsequence.Composition.__new__(subsequence.Composition) - merged = {**subsequence.roles.PAD, "root": 48} - assert merged["root"] == 48 and merged["fit"] == subsequence.roles.PAD["fit"] + # Splatting into a placement call is the intended use. + composition = subsequence.Composition.__new__(subsequence.Composition) + merged = {**subsequence.roles.PAD, "root": 48} + assert merged["root"] == 48 and merged["fit"] == subsequence.roles.PAD["fit"] # --------------------------------------------------------------------------- @@ -210,28 +212,28 @@ def test_role_bundles_are_splattable_kwargs () -> None: # --------------------------------------------------------------------------- -def test_hooktheory_style_builds_and_walks (patch_midi: None) -> None: +def test_hooktheory_style_builds_and_walks(patch_midi: None) -> None: + """The corpus-weighted major style is reachable by name and walks diatonic chords.""" - """The corpus-weighted major style is reachable by name and walks diatonic chords.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", bpm=120, key="C", seed=3 + ) + composition.harmony(style="hooktheory_major", cycle_beats=4) - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C", seed=3) - composition.harmony(style="hooktheory_major", cycle_beats=4) + frozen = composition.freeze(8) + names = {c.name() for c in frozen.chords} - frozen = composition.freeze(8) - names = {c.name() for c in frozen.chords} + # Everything it draws is C-major diatonic (triads + the V7 colour). + diatonic = {"C", "Dm", "Em", "F", "G", "Am", "Bdim", "G7"} + assert names <= diatonic + assert "C" in names # the tonic appears - # Everything it draws is C-major diatonic (triads + the V7 colour). - diatonic = {"C", "Dm", "Em", "F", "G", "Am", "Bdim", "G7"} - assert names <= diatonic - assert "C" in names # the tonic appears +def test_pop_major_alias(patch_midi: None) -> None: + """'pop_major' is an alias for the Hooktheory style.""" -def test_pop_major_alias (patch_midi: None) -> None: - - """'pop_major' is an alias for the Hooktheory style.""" - - hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="pop_major") - assert hs.current_chord.name() == "C" + hs = subsequence.harmonic_state.HarmonicState(key_name="C", graph_style="pop_major") + assert hs.current_chord.name() == "C" # --------------------------------------------------------------------------- @@ -239,82 +241,86 @@ def test_pop_major_alias (patch_midi: None) -> None: # --------------------------------------------------------------------------- -def test_elaborate_depth_0_is_identity () -> None: - - """depth=0 returns the progression unchanged.""" - - blues = subsequence.progression("twelve_bar_blues").resolve("C") - assert blues.elaborate(0) is blues - - -def test_elaborate_inserts_secondary_dominants () -> None: - - """depth=1 puts a V7 before each chord; depth=2 a secondary ii-V.""" - - one_bar = subsequence.progression(["C"]).resolve("C") # a single C +def test_elaborate_depth_0_is_identity() -> None: + """depth=0 returns the progression unchanged.""" - d1 = one_bar.elaborate(1) - assert [c.name() for c in d1.chords] == ["G7", "C"] # V7 → I + blues = subsequence.progression("twelve_bar_blues").resolve("C") + assert blues.elaborate(0) is blues - d2 = one_bar.elaborate(2) - assert [c.name() for c in d2.chords] == ["Dm7", "G7", "C"] # ii-V-I - # Spans subdivide the original bar evenly. - assert d2.spans[0].beats == pytest.approx(4.0 / 3) +def test_elaborate_inserts_secondary_dominants() -> None: + """depth=1 puts a V7 before each chord; depth=2 a secondary ii-V.""" + one_bar = subsequence.progression(["C"]).resolve("C") # a single C -def test_elaborate_subdivides_and_preserves_decorations () -> None: + d1 = one_bar.elaborate(1) + assert [c.name() for c in d1.chords] == ["G7", "C"] # V7 → I - """Each target keeps its decorations on its (subdivided) sub-span.""" + d2 = one_bar.elaborate(2) + assert [c.name() for c in d2.chords] == ["Dm7", "G7", "C"] # ii-V-I - value = subsequence.progression([("Cmaj7", 4)]).resolve("C").extend(9) - d1 = value.elaborate(1) + # Spans subdivide the original bar evenly. + assert d2.spans[0].beats == pytest.approx(4.0 / 3) - assert len(d1.spans) == 2 - assert d1.spans[0].beats == pytest.approx(2.0) and d1.spans[1].beats == pytest.approx(2.0) - assert d1.spans[1].extensions == (9,) # the C kept its 9th - assert d1.spans[0].extensions == () # the inserted G7 is bare +def test_elaborate_subdivides_and_preserves_decorations() -> None: + """Each target keeps its decorations on its (subdivided) sub-span.""" -def test_elaborate_determinism_and_seed_warning () -> None: + value = subsequence.progression([("Cmaj7", 4)]).resolve("C").extend(9) + d1 = value.elaborate(1) - """depth<3 is deterministic; depth>=3 is seeded (and warns without a seed).""" + assert len(d1.spans) == 2 + assert d1.spans[0].beats == pytest.approx(2.0) and d1.spans[ + 1 + ].beats == pytest.approx(2.0) + assert d1.spans[1].extensions == (9,) # the C kept its 9th + assert d1.spans[0].extensions == () # the inserted G7 is bare - blues = subsequence.progression("twelve_bar_blues").resolve("C") - # Deterministic for shallow depth (no random choices). - assert [c.name() for c in blues.elaborate(2).chords] == [c.name() for c in blues.elaborate(2).chords] +def test_elaborate_determinism_and_seed_warning() -> None: + """depth<3 is deterministic; depth>=3 is seeded (and warns without a seed).""" - # Seeded reproducibility at depth 3. - assert [c.name() for c in blues.elaborate(3, seed=7).chords] == [c.name() for c in blues.elaborate(3, seed=7).chords] + blues = subsequence.progression("twelve_bar_blues").resolve("C") - with pytest.warns(UserWarning, match="tritone"): - blues.elaborate(3) + # Deterministic for shallow depth (no random choices). + assert [c.name() for c in blues.elaborate(2).chords] == [ + c.name() for c in blues.elaborate(2).chords + ] + # Seeded reproducibility at depth 3. + assert [c.name() for c in blues.elaborate(3, seed=7).chords] == [ + c.name() for c in blues.elaborate(3, seed=7).chords + ] -def test_elaborate_requires_concrete_rooted_chords () -> None: + with pytest.warns(UserWarning, match="tritone"): + blues.elaborate(3) - """A key-relative progression, or a PitchSet, raises.""" - with pytest.raises(ValueError, match="key-relative"): - subsequence.progression([1, 4, 5]).elaborate(1) +def test_elaborate_requires_concrete_rooted_chords() -> None: + """A key-relative progression, or a PitchSet, raises.""" - pitchset = subsequence.progressions.Progression( - spans=(subsequence.progressions.ChordSpan(chord=subsequence.progressions.PitchSet([60, 63, 67]), beats=4.0),) - ) - with pytest.raises(ValueError, match="rooted chords"): - pitchset.elaborate(1) + with pytest.raises(ValueError, match="key-relative"): + subsequence.progression([1, 4, 5]).elaborate(1) - with pytest.raises(ValueError, match="depth"): - subsequence.progression(["C"]).resolve("C").elaborate(-1) + pitchset = subsequence.progressions.Progression( + spans=( + subsequence.progressions.ChordSpan( + chord=subsequence.progressions.PitchSet([60, 63, 67]), beats=4.0 + ), + ) + ) + with pytest.raises(ValueError, match="rooted chords"): + pitchset.elaborate(1) + with pytest.raises(ValueError, match="depth"): + subsequence.progression(["C"]).resolve("C").elaborate(-1) -def test_elaborate_blues_depth_per_chorus () -> None: - """The flagship: a 12-bar blues grows more ii-Vs each chorus.""" +def test_elaborate_blues_depth_per_chorus() -> None: + """The flagship: a 12-bar blues grows more ii-Vs each chorus.""" - blues = subsequence.progression("twelve_bar_blues").resolve("C") + blues = subsequence.progression("twelve_bar_blues").resolve("C") - assert len(blues.chords) == 12 - assert len(blues.elaborate(1).chords) == 24 # +1 approach per bar - assert len(blues.elaborate(2).chords) == 36 # +2 per bar + assert len(blues.chords) == 12 + assert len(blues.elaborate(1).chords) == 24 # +1 approach per bar + assert len(blues.elaborate(2).chords) == 36 # +2 per bar diff --git a/tests/test_progressions.py b/tests/test_progressions.py index 1794993..ecadba7 100644 --- a/tests/test_progressions.py +++ b/tests/test_progressions.py @@ -18,1001 +18,1018 @@ WHOLE = 4.0 -def chord (name: str) -> subsequence.chords.Chord: +def chord(name: str) -> subsequence.chords.Chord: + """Shorthand for parse_chord in expectations.""" - """Shorthand for parse_chord in expectations.""" - - return subsequence.chords.parse_chord(name) + return subsequence.chords.parse_chord(name) # --------------------------------------------------------------------------- # parse_roman — the music21-semantics grammar # --------------------------------------------------------------------------- -def test_roman_case_is_quality () -> None: - - """Uppercase is major, lowercase is minor.""" - - major, _ = subsequence.progressions.parse_roman("IV") - minor, _ = subsequence.progressions.parse_roman("iv") - - assert major.degree == 4 and major.quality == "major" - assert minor.degree == 4 and minor.quality == "minor" - -def test_roman_sevenths_follow_case () -> None: +def test_roman_case_is_quality() -> None: + """Uppercase is major, lowercase is minor.""" - """V7 is a dominant seventh; v7 a minor seventh; Imaj7 a major seventh.""" + major, _ = subsequence.progressions.parse_roman("IV") + minor, _ = subsequence.progressions.parse_roman("iv") - dominant, _ = subsequence.progressions.parse_roman("V7") - minor7, _ = subsequence.progressions.parse_roman("v7") - major7, _ = subsequence.progressions.parse_roman("Imaj7") + assert major.degree == 4 and major.quality == "major" + assert minor.degree == 4 and minor.quality == "minor" - assert dominant.quality == "dominant_7th" - assert minor7.quality == "minor_7th" - assert major7.quality == "major_7th" +def test_roman_sevenths_follow_case() -> None: + """V7 is a dominant seventh; v7 a minor seventh; Imaj7 a major seventh.""" -def test_roman_diminished_and_half_diminished () -> None: + dominant, _ = subsequence.progressions.parse_roman("V7") + minor7, _ = subsequence.progressions.parse_roman("v7") + major7, _ = subsequence.progressions.parse_roman("Imaj7") - """vii° is a diminished triad, vii°7 fully diminished, viiø7 half-diminished.""" + assert dominant.quality == "dominant_7th" + assert minor7.quality == "minor_7th" + assert major7.quality == "major_7th" - triad, _ = subsequence.progressions.parse_roman("vii°") - full, _ = subsequence.progressions.parse_roman("vii°7") - half, _ = subsequence.progressions.parse_roman("viiø7") - assert triad.quality == "diminished" - assert full.quality == "diminished_7th" - assert half.quality == "half_diminished_7th" +def test_roman_diminished_and_half_diminished() -> None: + """vii° is a diminished triad, vii°7 fully diminished, viiø7 half-diminished.""" + triad, _ = subsequence.progressions.parse_roman("vii°") + full, _ = subsequence.progressions.parse_roman("vii°7") + half, _ = subsequence.progressions.parse_roman("viiø7") -def test_roman_augmented () -> None: + assert triad.quality == "diminished" + assert full.quality == "diminished_7th" + assert half.quality == "half_diminished_7th" - """III+ is an augmented triad.""" - aug, _ = subsequence.progressions.parse_roman("III+") +def test_roman_augmented() -> None: + """III+ is an augmented triad.""" - assert aug.degree == 3 and aug.quality == "augmented" + aug, _ = subsequence.progressions.parse_roman("III+") + assert aug.degree == 3 and aug.quality == "augmented" -def test_roman_figures_give_inversions () -> None: - """6/64 invert triads; 65/43/42 invert seventh chords.""" +def test_roman_figures_give_inversions() -> None: + """6/64 invert triads; 65/43/42 invert seventh chords.""" - assert subsequence.progressions.parse_roman("I6")[1] == 1 - assert subsequence.progressions.parse_roman("I64")[1] == 2 - assert subsequence.progressions.parse_roman("ii65")[1] == 1 - assert subsequence.progressions.parse_roman("V43")[1] == 2 - assert subsequence.progressions.parse_roman("V42")[1] == 3 - assert subsequence.progressions.parse_roman("V2")[1] == 3 + assert subsequence.progressions.parse_roman("I6")[1] == 1 + assert subsequence.progressions.parse_roman("I64")[1] == 2 + assert subsequence.progressions.parse_roman("ii65")[1] == 1 + assert subsequence.progressions.parse_roman("V43")[1] == 2 + assert subsequence.progressions.parse_roman("V42")[1] == 3 + assert subsequence.progressions.parse_roman("V2")[1] == 3 - seventh, _ = subsequence.progressions.parse_roman("ii65") - assert seventh.quality == "minor_7th" + seventh, _ = subsequence.progressions.parse_roman("ii65") + assert seventh.quality == "minor_7th" -def test_roman_accidental_prefix () -> None: +def test_roman_accidental_prefix() -> None: + """bVII reads against the major scale — the whole step below tonic.""" - """bVII reads against the major scale — the whole step below tonic.""" + flat, _ = subsequence.progressions.parse_roman("bVII") - flat, _ = subsequence.progressions.parse_roman("bVII") + assert flat.degree == 7 and flat.accidental == -1 and flat.quality == "major" - assert flat.degree == 7 and flat.accidental == -1 and flat.quality == "major" +def test_roman_secondary_function() -> None: + """V/V parses one level of secondary function.""" -def test_roman_secondary_function () -> None: + secondary, _ = subsequence.progressions.parse_roman("V7/V") - """V/V parses one level of secondary function.""" + assert ( + secondary.degree == 5 + and secondary.of == 5 + and secondary.quality == "dominant_7th" + ) - secondary, _ = subsequence.progressions.parse_roman("V7/V") - assert secondary.degree == 5 and secondary.of == 5 and secondary.quality == "dominant_7th" +def test_roman_rejects_two_levels_of_secondary() -> None: + """Only one /x level is supported — V/V/V raises.""" + with pytest.raises(ValueError, match="one level"): + subsequence.progressions.parse_roman("V/V/V") -def test_roman_rejects_two_levels_of_secondary () -> None: - """Only one /x level is supported — V/V/V raises.""" +def test_roman_rejects_mixed_case_and_garbage() -> None: + """Mixed-case numerals and non-numerals fail loudly.""" - with pytest.raises(ValueError, match="one level"): - subsequence.progressions.parse_roman("V/V/V") - - -def test_roman_rejects_mixed_case_and_garbage () -> None: - - """Mixed-case numerals and non-numerals fail loudly.""" - - with pytest.raises(ValueError): - subsequence.progressions.parse_roman("Iv") - with pytest.raises(ValueError): - subsequence.progressions.parse_roman("VIII") - with pytest.raises(ValueError): - subsequence.progressions.parse_roman("xyz") + with pytest.raises(ValueError): + subsequence.progressions.parse_roman("Iv") + with pytest.raises(ValueError): + subsequence.progressions.parse_roman("VIII") + with pytest.raises(ValueError): + subsequence.progressions.parse_roman("xyz") # --------------------------------------------------------------------------- # parse_element — per-element dispatch (decision 16) # --------------------------------------------------------------------------- -def test_element_int_is_degree () -> None: - - """A bare int is a 1-based diatonic degree, quality inferred later.""" - span = subsequence.progressions.parse_element(6) +def test_element_int_is_degree() -> None: + """A bare int is a 1-based diatonic degree, quality inferred later.""" - assert isinstance(span.chord, subsequence.progressions.RomanChord) - assert span.chord.degree == 6 and span.chord.quality is None + span = subsequence.progressions.parse_element(6) + assert isinstance(span.chord, subsequence.progressions.RomanChord) + assert span.chord.degree == 6 and span.chord.quality is None -def test_element_note_letter_string_is_chord_name () -> None: - """Strings starting with a note letter parse as chord names.""" +def test_element_note_letter_string_is_chord_name() -> None: + """Strings starting with a note letter parse as chord names.""" - span = subsequence.progressions.parse_element("Am") + span = subsequence.progressions.parse_element("Am") - assert span.chord == chord("Am") + assert span.chord == chord("Am") -def test_element_roman_string () -> None: +def test_element_roman_string() -> None: + """Other strings parse as romans — including figured inversions.""" - """Other strings parse as romans — including figured inversions.""" + span = subsequence.progressions.parse_element("bVII7") - span = subsequence.progressions.parse_element("bVII7") + assert isinstance(span.chord, subsequence.progressions.RomanChord) + assert span.chord.accidental == -1 and span.chord.quality == "dominant_7th" - assert isinstance(span.chord, subsequence.progressions.RomanChord) - assert span.chord.accidental == -1 and span.chord.quality == "dominant_7th" + inverted = subsequence.progressions.parse_element("I6") + assert inverted.inversion == 1 - inverted = subsequence.progressions.parse_element("I6") - assert inverted.inversion == 1 +def test_element_tuple_sets_beats() -> None: + """(element, beats) tuples carry per-chord durations.""" -def test_element_tuple_sets_beats () -> None: + span = subsequence.progressions.parse_element(("Am", 2)) - """(element, beats) tuples carry per-chord durations.""" + assert span.chord == chord("Am") and span.beats == 2.0 - span = subsequence.progressions.parse_element(("Am", 2)) - assert span.chord == chord("Am") and span.beats == 2.0 +def test_element_pitchset_passthrough() -> None: + """A PitchSet is a valid span chord.""" + cluster = subsequence.progressions.PitchSet([60, 61, 65]) + span = subsequence.progressions.parse_element(cluster) -def test_element_pitchset_passthrough () -> None: + assert span.chord is cluster - """A PitchSet is a valid span chord.""" - cluster = subsequence.progressions.PitchSet([60, 61, 65]) - span = subsequence.progressions.parse_element(cluster) +def test_element_rejects_bool_and_garbage() -> None: + """Bools and unknown types fail loudly.""" - assert span.chord is cluster + with pytest.raises(TypeError): + subsequence.progressions.parse_element(True) + with pytest.raises(TypeError): + subsequence.progressions.parse_element(3.5) -def test_element_rejects_bool_and_garbage () -> None: +def test_degree_must_be_one_based() -> None: + """Degree 0 is a hard error (1-based, the musician count).""" - """Bools and unknown types fail loudly.""" - - with pytest.raises(TypeError): - subsequence.progressions.parse_element(True) - with pytest.raises(TypeError): - subsequence.progressions.parse_element(3.5) - - -def test_degree_must_be_one_based () -> None: - - """Degree 0 is a hard error (1-based, the musician count).""" - - with pytest.raises(ValueError): - subsequence.progressions.parse_element(0) + with pytest.raises(ValueError): + subsequence.progressions.parse_element(0) # --------------------------------------------------------------------------- # The factory # --------------------------------------------------------------------------- -def test_factory_degree_list_resolves_in_a_minor () -> None: - - """progression([1, 6, 3, 7]) means i–VI–III–VII in A minor.""" - - value = subsequence.progressions.progression([1, 6, 3, 7]).resolve("A", "minor") - - assert value.chords == (chord("Am"), chord("F"), chord("C"), chord("G")) - - -def test_factory_blues_skeleton () -> None: - """The 12-bar blues writes its structure in the code itself.""" +def test_factory_degree_list_resolves_in_a_minor() -> None: + """progression([1, 6, 3, 7]) means i–VI–III–VII in A minor.""" - value = subsequence.progressions.progression( - ["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"] - ).resolve("C") + value = subsequence.progressions.progression([1, 6, 3, 7]).resolve("A", "minor") - assert len(value) == 12 - assert value.chords[0] == chord("C7") - assert value.chords[4] == chord("F7") - assert value.chords[8] == chord("G7") - assert value.length == 48.0 + assert value.chords == (chord("Am"), chord("F"), chord("C"), chord("G")) -def test_factory_beats_scalar_and_list () -> None: +def test_factory_blues_skeleton() -> None: + """The 12-bar blues writes its structure in the code itself.""" - """beats= shapes the harmonic rhythm — scalar for all, list cycled.""" + value = subsequence.progressions.progression( + ["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"] + ).resolve("C") - even = subsequence.progressions.progression(["Am", "F"], beats=2) - shaped = subsequence.progressions.progression(["Am", "F", "C", "G"], beats=[4, 2]) + assert len(value) == 12 + assert value.chords[0] == chord("C7") + assert value.chords[4] == chord("F7") + assert value.chords[8] == chord("G7") + assert value.length == 48.0 - assert [span.beats for span in even.spans] == [2.0, 2.0] - assert [span.beats for span in shaped.spans] == [4.0, 2.0, 4.0, 2.0] +def test_factory_beats_scalar_and_list() -> None: + """beats= shapes the harmonic rhythm — scalar for all, list cycled.""" -def test_factory_tuple_beats_override_list () -> None: + even = subsequence.progressions.progression(["Am", "F"], beats=2) + shaped = subsequence.progressions.progression(["Am", "F", "C", "G"], beats=[4, 2]) - """A per-element (chord, beats) tuple wins over the beats= list.""" + assert [span.beats for span in even.spans] == [2.0, 2.0] + assert [span.beats for span in shaped.spans] == [4.0, 2.0, 4.0, 2.0] - value = subsequence.progressions.progression([("Am", 6), "F"], beats=4) - assert [span.beats for span in value.spans] == [6.0, 4.0] +def test_factory_tuple_beats_override_list() -> None: + """A per-element (chord, beats) tuple wins over the beats= list.""" + value = subsequence.progressions.progression([("Am", 6), "F"], beats=4) -def test_factory_unknown_preset_raises () -> None: + assert [span.beats for span in value.spans] == [6.0, 4.0] - """A bare string is a preset name; unknown names fail with the list hint.""" - with pytest.raises(ValueError, match="Unknown progression preset"): - subsequence.progressions.progression("not_a_real_preset") +def test_factory_unknown_preset_raises() -> None: + """A bare string is a preset name; unknown names fail with the list hint.""" - # A known preset resolves to its genre loop (the table ships in stage 8). - assert [c.name() for c in subsequence.progressions.progression("trance_epic").resolve("A", "aeolian").chords] == ["Am", "F", "C", "G"] + with pytest.raises(ValueError, match="Unknown progression preset"): + subsequence.progressions.progression("not_a_real_preset") + # A known preset resolves to its genre loop (the table ships in stage 8). + assert [ + c.name() + for c in subsequence.progressions.progression("trance_epic") + .resolve("A", "aeolian") + .chords + ] == ["Am", "F", "C", "G"] -def test_factory_style_generation_is_seeded () -> None: - """style= generates deterministically under a seed.""" +def test_factory_style_generation_is_seeded() -> None: + """style= generates deterministically under a seed.""" - first = subsequence.progressions.progression(style="aeolian_minor", key="A", bars=8, seed=7) - second = subsequence.progressions.progression(style="aeolian_minor", key="A", bars=8, seed=7) + first = subsequence.progressions.progression( + style="aeolian_minor", key="A", bars=8, seed=7 + ) + second = subsequence.progressions.progression( + style="aeolian_minor", key="A", bars=8, seed=7 + ) - assert first.chords == second.chords - assert len(first) == 8 - assert first.is_concrete + assert first.chords == second.chords + assert len(first) == 8 + assert first.is_concrete -def test_factory_style_without_seed_warns () -> None: +def test_factory_style_without_seed_warns() -> None: + """A standalone generated value without a seed warns (live-reload contract).""" - """A standalone generated value without a seed warns (live-reload contract).""" + with pytest.warns(UserWarning, match="seed"): + subsequence.progressions.progression(style="aeolian_minor", key="A", bars=2) - with pytest.warns(UserWarning, match="seed"): - subsequence.progressions.progression(style="aeolian_minor", key="A", bars=2) +def test_factory_style_and_source_conflict() -> None: + """Passing both a source and style= is an error.""" -def test_factory_style_and_source_conflict () -> None: + with pytest.raises(ValueError, match="not both"): + subsequence.progressions.progression(["Am"], style="aeolian_minor", key="A") - """Passing both a source and style= is an error.""" - with pytest.raises(ValueError, match="not both"): - subsequence.progressions.progression(["Am"], style="aeolian_minor", key="A") +def test_factory_empty_list_raises() -> None: + """An empty progression is an error, not a silent no-op.""" - -def test_factory_empty_list_raises () -> None: - - """An empty progression is an error, not a silent no-op.""" - - with pytest.raises(ValueError, match="empty"): - subsequence.progressions.progression([]) + with pytest.raises(ValueError, match="empty"): + subsequence.progressions.progression([]) # --------------------------------------------------------------------------- # Resolution — romans stay relative until query time # --------------------------------------------------------------------------- -def test_flat_seven_resolves_against_the_scale_itself () -> None: - - """bVII in C major is Bb; VII in A minor is already G (no accidental).""" - - in_major = subsequence.progressions.progression(["bVII"]).resolve("C") - in_minor = subsequence.progressions.progression(["VII"]).resolve("A", "minor") - assert in_major.chords[0] == chord("Bb") - assert in_minor.chords[0] == chord("G") +def test_flat_seven_resolves_against_the_scale_itself() -> None: + """bVII in C major is Bb; VII in A minor is already G (no accidental).""" + in_major = subsequence.progressions.progression(["bVII"]).resolve("C") + in_minor = subsequence.progressions.progression(["VII"]).resolve("A", "minor") -def test_change_the_key_once_everything_follows () -> None: + assert in_major.chords[0] == chord("Bb") + assert in_minor.chords[0] == chord("G") - """The same value resolves differently under different keys — P2.""" - value = subsequence.progressions.progression([1, 6]) +def test_change_the_key_once_everything_follows() -> None: + """The same value resolves differently under different keys — P2.""" - assert value.resolve("A", "minor").chords == (chord("Am"), chord("F")) - assert value.resolve("E", "minor").chords == (chord("Em"), chord("C")) + value = subsequence.progressions.progression([1, 6]) + assert value.resolve("A", "minor").chords == (chord("Am"), chord("F")) + assert value.resolve("E", "minor").chords == (chord("Em"), chord("C")) -def test_secondary_dominant_resolves_to_five_of_five () -> None: - """V7/V in C is D7 — the dominant of the dominant.""" +def test_secondary_dominant_resolves_to_five_of_five() -> None: + """V7/V in C is D7 — the dominant of the dominant.""" - value = subsequence.progressions.progression(["V7/V"]).resolve("C") + value = subsequence.progressions.progression(["V7/V"]).resolve("C") - assert value.chords[0] == chord("D7") + assert value.chords[0] == chord("D7") -def test_inference_needs_scale_qualities () -> None: +def test_inference_needs_scale_qualities() -> None: + """Bare-int degrees on a quality-less scale fail with the register_scale hint.""" - """Bare-int degrees on a quality-less scale fail with the register_scale hint.""" + value = subsequence.progressions.progression([1]) - value = subsequence.progressions.progression([1]) + with pytest.raises(ValueError, match="qualities"): + value.resolve("C", "hirajoshi") - with pytest.raises(ValueError, match="qualities"): - value.resolve("C", "hirajoshi") +def test_iteration_of_relative_progression_raises_with_hint() -> None: + """Iterating an unresolved value names the relative spans and the fix.""" -def test_iteration_of_relative_progression_raises_with_hint () -> None: + value = subsequence.progressions.progression([1, "Am"]) - """Iterating an unresolved value names the relative spans and the fix.""" - - value = subsequence.progressions.progression([1, "Am"]) - - with pytest.raises(ValueError, match="resolve"): - list(value) + with pytest.raises(ValueError, match="resolve"): + list(value) # --------------------------------------------------------------------------- # The ChordEvent iteration contract (ChordTimeline's, preserved) # --------------------------------------------------------------------------- -def test_iteration_unpacks_as_chord_start_length () -> None: - - """for chord, start, length in progression — the placement idiom.""" - value = subsequence.progressions.progression(["Am", ("F", 2), "C"]) - events = list(value) +def test_iteration_unpacks_as_chord_start_length() -> None: + """for chord, start, length in progression — the placement idiom.""" - assert [(e.chord.name(), e.start, e.length) for e in events] == [ - ("Am", 0.0, 4.0), - ("F", 4.0, 2.0), - ("C", 6.0, 4.0), - ] + value = subsequence.progressions.progression(["Am", ("F", 2), "C"]) + events = list(value) - first_chord, first_start, first_length = events[0] - assert first_chord == chord("Am") and first_start == 0.0 and first_length == 4.0 + assert [(e.chord.name(), e.start, e.length) for e in events] == [ + ("Am", 0.0, 4.0), + ("F", 4.0, 2.0), + ("C", 6.0, 4.0), + ] + first_chord, first_start, first_length = events[0] + assert first_chord == chord("Am") and first_start == 0.0 and first_length == 4.0 -def test_span_at_wraps_modulo_length () -> None: - """span_at() serves looped playback by wrapping the beat.""" +def test_span_at_wraps_modulo_length() -> None: + """span_at() serves looped playback by wrapping the beat.""" - value = subsequence.progressions.progression(["Am", "F"], beats=4) + value = subsequence.progressions.progression(["Am", "F"], beats=4) - span, start, end = value.span_at(5.0) - assert span.chord == chord("F") and (start, end) == (4.0, 8.0) + span, start, end = value.span_at(5.0) + assert span.chord == chord("F") and (start, end) == (4.0, 8.0) - wrapped, _, _ = value.span_at(9.0) - assert wrapped.chord == chord("Am") + wrapped, _, _ = value.span_at(9.0) + assert wrapped.chord == chord("Am") # --------------------------------------------------------------------------- # Algebra — the governing family # --------------------------------------------------------------------------- -def test_plus_concatenates_and_star_tiles () -> None: - """+ joins progressions; * repeats them.""" +def test_plus_concatenates_and_star_tiles() -> None: + """+ joins progressions; * repeats them.""" - a = subsequence.progressions.progression(["Am", "F"]) - b = subsequence.progressions.progression(["C", "G"]) + a = subsequence.progressions.progression(["Am", "F"]) + b = subsequence.progressions.progression(["C", "G"]) - combined = a + b - tiled = a * 2 + combined = a + b + tiled = a * 2 - assert combined.chords == (chord("Am"), chord("F"), chord("C"), chord("G")) - assert tiled.chords == (chord("Am"), chord("F"), chord("Am"), chord("F")) - assert tiled.length == 16.0 + assert combined.chords == (chord("Am"), chord("F"), chord("C"), chord("G")) + assert tiled.chords == (chord("Am"), chord("F"), chord("Am"), chord("F")) + assert tiled.length == 16.0 -def test_parallel_merge_is_a_type_error () -> None: +def test_parallel_merge_is_a_type_error() -> None: + """There is one current chord — & on progressions raises by design.""" - """There is one current chord — & on progressions raises by design.""" + a = subsequence.progressions.progression(["Am"]) + b = subsequence.progressions.progression(["F"]) - a = subsequence.progressions.progression(["Am"]) - b = subsequence.progressions.progression(["F"]) - - with pytest.raises(TypeError, match="one current chord"): - a & b + with pytest.raises(TypeError, match="one current chord"): + a & b # --------------------------------------------------------------------------- # Spice operators — decoration on spans, never on chords (§8.11) # --------------------------------------------------------------------------- -def test_is_decorated_false_for_bare_span () -> None: - - """A span holding just a chord and a duration carries no decoration.""" - - span = subsequence.progressions.ChordSpan(chord=chord("Am"), beats=4.0) - - assert span.is_decorated is False - - -def test_is_decorated_true_for_each_decoration_kind () -> None: - - """Extensions, a bass, an inversion, or a spread each mark the span decorated.""" - - bare = dict(chord=chord("Am"), beats=4.0) - - assert subsequence.progressions.ChordSpan(**bare, extensions=(7,)).is_decorated is True - assert subsequence.progressions.ChordSpan(**bare, bass=4).is_decorated is True - assert subsequence.progressions.ChordSpan(**bare, inversion=1).is_decorated is True - assert subsequence.progressions.ChordSpan(**bare, spread="open").is_decorated is True - - -def test_extend_decorates_without_touching_the_chord () -> None: - - """extend(9) keeps the bare triad as the chord and decorates the span.""" - value = subsequence.progressions.progression(["Am"]).extend(9) - span = value.spans[0] +def test_is_decorated_false_for_bare_span() -> None: + """A span holding just a chord and a duration carries no decoration.""" - assert span.chord == chord("Am") - assert span.extensions == (9,) - assert span.decorated_intervals() == [0, 3, 7, 10, 14] - assert span.label() == "Am9" + span = subsequence.progressions.ChordSpan(chord=chord("Am"), beats=4.0) + assert span.is_decorated is False -def test_extend_diatonic_degree_gets_scale_true_seventh () -> None: - """extend(7) on degree 5 in C major yields the dominant seventh (F natural).""" +def test_is_decorated_true_for_each_decoration_kind() -> None: + """Extensions, a bass, an inversion, or a spread each mark the span decorated.""" - value = subsequence.progressions.progression([5]).extend(7).resolve("C") - span = value.spans[0] + bare = dict(chord=chord("Am"), beats=4.0) - assert span.chord == chord("G") - assert span.decorated_intervals() == [0, 4, 7, 10] + assert ( + subsequence.progressions.ChordSpan(**bare, extensions=(7,)).is_decorated is True + ) + assert subsequence.progressions.ChordSpan(**bare, bass=4).is_decorated is True + assert subsequence.progressions.ChordSpan(**bare, inversion=1).is_decorated is True + assert ( + subsequence.progressions.ChordSpan(**bare, spread="open").is_decorated is True + ) -def test_extend_concrete_major_uses_its_own_colour () -> None: +def test_extend_decorates_without_touching_the_chord() -> None: + """extend(9) keeps the bare triad as the chord and decorates the span.""" - """extend(7) on a concrete major chord deepens in its own colour (maj7).""" + value = subsequence.progressions.progression(["Am"]).extend(9) + span = value.spans[0] - value = subsequence.progressions.progression(["G"]).extend(7) + assert span.chord == chord("Am") + assert span.extensions == (9,) + assert span.decorated_intervals() == [0, 3, 7, 10, 14] + assert span.label() == "Am9" - assert value.spans[0].decorated_intervals() == [0, 4, 7, 11] +def test_extend_diatonic_degree_gets_scale_true_seventh() -> None: + """extend(7) on degree 5 in C major yields the dominant seventh (F natural).""" -def test_extend_only_restricts_slots () -> None: + value = subsequence.progressions.progression([5]).extend(7).resolve("C") + span = value.spans[0] - """only=[...] spices the named 1-based slots and leaves the rest bare.""" + assert span.chord == chord("G") + assert span.decorated_intervals() == [0, 4, 7, 10] - value = subsequence.progressions.progression(["Am", "F", "C"]).extend(7, only=[2]) - assert value.spans[0].extensions == () - assert value.spans[1].extensions == (7,) - assert value.spans[2].extensions == () +def test_extend_concrete_major_uses_its_own_colour() -> None: + """extend(7) on a concrete major chord deepens in its own colour (maj7).""" + value = subsequence.progressions.progression(["G"]).extend(7) -def test_extend_sus4_replaces_the_third () -> None: + assert value.spans[0].decorated_intervals() == [0, 4, 7, 11] - """sus4 swaps the third for the fourth.""" - value = subsequence.progressions.progression(["C"]).extend("sus4") +def test_extend_only_restricts_slots() -> None: + """only=[...] spices the named 1-based slots and leaves the rest bare.""" - assert value.spans[0].decorated_intervals() == [0, 5, 7] - assert value.spans[0].label() == "Csus4" + value = subsequence.progressions.progression(["Am", "F", "C"]).extend(7, only=[2]) + assert value.spans[0].extensions == () + assert value.spans[1].extensions == (7,) + assert value.spans[2].extensions == () -def test_unknown_extension_raises () -> None: - """Unknown extension markers fail at construction.""" +def test_extend_sus4_replaces_the_third() -> None: + """sus4 swaps the third for the fourth.""" - with pytest.raises(ValueError, match="extension"): - subsequence.progressions.progression(["C"]).extend(15) + value = subsequence.progressions.progression(["C"]).extend("sus4") + assert value.spans[0].decorated_intervals() == [0, 5, 7] + assert value.spans[0].label() == "Csus4" -def test_inversions_scalar_and_cycled_list () -> None: - """inversions() takes one int for all spans or a list cycled per span.""" +def test_unknown_extension_raises() -> None: + """Unknown extension markers fail at construction.""" - value = subsequence.progressions.progression(["Am", "F", "C"]) + with pytest.raises(ValueError, match="extension"): + subsequence.progressions.progression(["C"]).extend(15) - assert [s.inversion for s in value.inversions(1).spans] == [1, 1, 1] - assert [s.inversion for s in value.inversions([0, 2]).spans] == [0, 2, 0] +def test_inversions_scalar_and_cycled_list() -> None: + """inversions() takes one int for all spans or a list cycled per span.""" -def test_spread_open_is_drop_two () -> None: + value = subsequence.progressions.progression(["Am", "F", "C"]) - """spread('open') lowers the second-from-top voice an octave.""" + assert [s.inversion for s in value.inversions(1).spans] == [1, 1, 1] + assert [s.inversion for s in value.inversions([0, 2]).spans] == [0, 2, 0] - value = subsequence.progressions.progression(["C"]).spread("open") - tones = value.spans[0].tones(60) - assert tones == sorted(tones) - assert tones == [52, 60, 67] # E below, C, G +def test_spread_open_is_drop_two() -> None: + """spread('open') lowers the second-from-top voice an octave.""" + value = subsequence.progressions.progression(["C"]).spread("open") + tones = value.spans[0].tones(60) -def test_over_tonic_pedal_resolves_at_query_time () -> None: + assert tones == sorted(tones) + assert tones == [52, 60, 67] # E below, C, G - """over('tonic') is key-relative — the pedal follows the key.""" - value = subsequence.progressions.progression(["Am", "F"]).over("tonic") +def test_over_tonic_pedal_resolves_at_query_time() -> None: + """over('tonic') is key-relative — the pedal follows the key.""" - resolved = value.resolve("A", "minor") - assert [span.bass for span in resolved.spans] == [9, 9] + value = subsequence.progressions.progression(["Am", "F"]).over("tonic") - tones = resolved.spans[1].tones(60) - assert tones[0] % 12 == 9 and tones[0] < min(tones[1:]) - assert resolved.spans[1].label() == "F/A" + resolved = value.resolve("A", "minor") + assert [span.bass for span in resolved.spans] == [9, 9] + tones = resolved.spans[1].tones(60) + assert tones[0] % 12 == 9 and tones[0] < min(tones[1:]) + assert resolved.spans[1].label() == "F/A" -def test_over_validates_note_names_early () -> None: - """A typo'd bass name fails at the call, not at resolution.""" +def test_over_validates_note_names_early() -> None: + """A typo'd bass name fails at the call, not at resolution.""" - with pytest.raises(ValueError): - subsequence.progressions.progression(["Am"]).over("H") + with pytest.raises(ValueError): + subsequence.progressions.progression(["Am"]).over("H") -def test_borrow_resolves_against_the_parallel_scale () -> None: +def test_borrow_resolves_against_the_parallel_scale() -> None: + """borrow(6) in C major swaps degree 6 to the parallel minor's Ab.""" - """borrow(6) in C major swaps degree 6 to the parallel minor's Ab.""" + value = subsequence.progressions.progression([1, 6]).borrow(2).resolve("C") - value = subsequence.progressions.progression([1, 6]).borrow(2).resolve("C") + assert value.chords == (chord("C"), chord("Ab")) - assert value.chords == (chord("C"), chord("Ab")) +def test_borrow_concrete_chord_raises() -> None: + """There is nothing relative to borrow on a concrete chord.""" -def test_borrow_concrete_chord_raises () -> None: + with pytest.raises(ValueError, match="relative"): + subsequence.progressions.progression(["Am"]).borrow(1) - """There is nothing relative to borrow on a concrete chord.""" - with pytest.raises(ValueError, match="relative"): - subsequence.progressions.progression(["Am"]).borrow(1) +def test_replace_keeps_the_slot_beats() -> None: + """replace() swaps the chord; the span keeps its duration.""" + value = subsequence.progressions.progression([("Am", 6), "F"]).replace(1, "C") -def test_replace_keeps_the_slot_beats () -> None: + assert value.spans[0].chord == chord("C") + assert value.spans[0].beats == 6.0 - """replace() swaps the chord; the span keeps its duration.""" - value = subsequence.progressions.progression([("Am", 6), "F"]).replace(1, "C") +def test_with_rhythm_reshapes_spans() -> None: + """with_rhythm() rewrites span lengths, cycling a list.""" - assert value.spans[0].chord == chord("C") - assert value.spans[0].beats == 6.0 + value = subsequence.progressions.progression(["Am", "F", "C", "G"]).with_rhythm( + [4, 4, 2, 6] + ) + assert [span.beats for span in value.spans] == [4.0, 4.0, 2.0, 6.0] + assert value.length == 16.0 -def test_with_rhythm_reshapes_spans () -> None: - """with_rhythm() rewrites span lengths, cycling a list.""" +def test_slot_arguments_are_one_based() -> None: + """Slot 0 raises — chords are counted, not indexed.""" - value = subsequence.progressions.progression(["Am", "F", "C", "G"]).with_rhythm([4, 4, 2, 6]) + value = subsequence.progressions.progression(["Am", "F"]) - assert [span.beats for span in value.spans] == [4.0, 4.0, 2.0, 6.0] - assert value.length == 16.0 + with pytest.raises(ValueError): + value.replace(0, "C") + with pytest.raises(ValueError): + value.extend(7, only=[3]) -def test_slot_arguments_are_one_based () -> None: +def test_spice_preserves_trailing_history() -> None: + """Engine continuity metadata survives decoration.""" - """Slot 0 raises — chords are counted, not indexed.""" + value = subsequence.progressions.Progression( + spans=(subsequence.progressions.ChordSpan(chord=chord("Am"), beats=4.0),), + trailing_history=(chord("G"),), + ) - value = subsequence.progressions.progression(["Am", "F"]) - - with pytest.raises(ValueError): - value.replace(0, "C") - with pytest.raises(ValueError): - value.extend(7, only=[3]) - - -def test_spice_preserves_trailing_history () -> None: - - """Engine continuity metadata survives decoration.""" - - value = subsequence.progressions.Progression( - spans = (subsequence.progressions.ChordSpan(chord=chord("Am"), beats=4.0),), - trailing_history = (chord("G"),), - ) - - assert value.extend(7).trailing_history == (chord("G"),) + assert value.extend(7).trailing_history == (chord("G"),) # --------------------------------------------------------------------------- # DecoratedChord — the voicing-layer wrapper # --------------------------------------------------------------------------- -def test_decorated_chord_duck_types_the_chord_protocol () -> None: - - """tones/intervals/name/root_note/bass_note all answer.""" - - span = subsequence.progressions.progression(["Am"]).extend(9).spans[0] - decorated = subsequence.progressions.DecoratedChord(span) - - assert decorated.intervals() == [0, 3, 7, 10, 14] - assert decorated.name() == "Am9" - assert decorated.root_note(60) == 57 - assert decorated.bass_note(60) == 45 - assert decorated.base == chord("Am") - assert 57 in decorated.tones(60) - -def test_iteration_yields_decorated_chords_where_spiced () -> None: +def test_decorated_chord_duck_types_the_chord_protocol() -> None: + """tones/intervals/name/root_note/bass_note all answer.""" - """Placement loops over a spiced progression hear the decoration.""" + span = subsequence.progressions.progression(["Am"]).extend(9).spans[0] + decorated = subsequence.progressions.DecoratedChord(span) - value = subsequence.progressions.progression(["Am", "F"]).extend(7, only=[1]) - events = list(value) + assert decorated.intervals() == [0, 3, 7, 10, 14] + assert decorated.name() == "Am9" + assert decorated.root_note(60) == 57 + assert decorated.bass_note(60) == 45 + assert decorated.base == chord("Am") + assert 57 in decorated.tones(60) - assert isinstance(events[0].chord, subsequence.progressions.DecoratedChord) - assert isinstance(events[1].chord, subsequence.chords.Chord) +def test_iteration_yields_decorated_chords_where_spiced() -> None: + """Placement loops over a spiced progression hear the decoration.""" -def test_slash_bass_note () -> None: + value = subsequence.progressions.progression(["Am", "F"]).extend(7, only=[1]) + events = list(value) - """bass_note() lands on the slash bass pitch class when one is set.""" + assert isinstance(events[0].chord, subsequence.progressions.DecoratedChord) + assert isinstance(events[1].chord, subsequence.chords.Chord) - span = subsequence.progressions.progression(["C"]).over("G").spans[0].resolve(0) - decorated = subsequence.progressions.DecoratedChord(span) - assert decorated.bass_note(60) % 12 == 7 - assert decorated.bass_note(60) == 43 # G2 — same low register as a plain root bass +def test_slash_bass_note() -> None: + """bass_note() lands on the slash bass pitch class when one is set.""" + span = subsequence.progressions.progression(["C"]).over("G").spans[0].resolve(0) + decorated = subsequence.progressions.DecoratedChord(span) -def test_concrete_note_name_slash_bass_voices_without_manual_resolve () -> None: + assert decorated.bass_note(60) % 12 == 7 + assert decorated.bass_note(60) == 43 # G2 — same low register as a plain root bass - """A note-name slash bass sounds on the plain concrete path (no resolve() call). - Regression: ``over("G")`` on a concrete progression used to be dropped from - the voicing because a string bass left the span "concrete" and unresolved. - Note names are key-independent and now resolve eagerly, so the bass sounds - whether the chord is iterated directly or laid out by ``realize()``. - """ +def test_concrete_note_name_slash_bass_voices_without_manual_resolve() -> None: + """A note-name slash bass sounds on the plain concrete path (no resolve() call). - concrete = subsequence.progressions.progression(["C"]).over("G") + Regression: ``over("G")`` on a concrete progression used to be dropped from + the voicing because a string bass left the span "concrete" and unresolved. + Note names are key-independent and now resolve eagerly, so the bass sounds + whether the chord is iterated directly or laid out by ``realize()``. + """ - iterated = list(concrete)[0].chord.tones(60) - assert 55 in iterated # G3 is voiced below the triad - assert iterated == [55, 60, 64, 67] + concrete = subsequence.progressions.progression(["C"]).over("G") - realized = subsequence.progressions.realize(concrete, 4.0, "C", 4.0, random.Random(0)) - assert list(realized)[0].chord.tones(60) == [55, 60, 64, 67] + iterated = list(concrete)[0].chord.tones(60) + assert 55 in iterated # G3 is voiced below the triad + assert iterated == [55, 60, 64, 67] + realized = subsequence.progressions.realize( + concrete, 4.0, "C", 4.0, random.Random(0) + ) + assert list(realized)[0].chord.tones(60) == [55, 60, 64, 67] -def test_tonic_pedal_voices_through_realize_with_a_key () -> None: - """A ``"tonic"`` pedal follows the key and sounds when realised with one.""" +def test_tonic_pedal_voices_through_realize_with_a_key() -> None: + """A ``"tonic"`` pedal follows the key and sounds when realised with one.""" - value = subsequence.progressions.progression(["F"]).over("tonic") + value = subsequence.progressions.progression(["F"]).over("tonic") - realized = subsequence.progressions.realize(value, 4.0, "C", 4.0, random.Random(0)) - tones = list(realized)[0].chord.tones(60) + realized = subsequence.progressions.realize(value, 4.0, "C", 4.0, random.Random(0)) + tones = list(realized)[0].chord.tones(60) - assert tones[0] % 12 == 0 # C (the tonic) sits in the bass - assert tones[0] == min(tones) + assert tones[0] % 12 == 0 # C (the tonic) sits in the bass + assert tones[0] == min(tones) -def test_tonic_pedal_without_a_key_raises_rather_than_dropping () -> None: +def test_tonic_pedal_without_a_key_raises_rather_than_dropping() -> None: + """A keyless ``"tonic"`` pedal fails loudly instead of silently vanishing.""" - """A keyless ``"tonic"`` pedal fails loudly instead of silently vanishing.""" + value = subsequence.progressions.progression(["F"]).over("tonic") - value = subsequence.progressions.progression(["F"]).over("tonic") - - with pytest.raises(ValueError): - list(subsequence.progressions.realize(value, 4.0, None, 4.0, random.Random(0))) + with pytest.raises(ValueError): + list(subsequence.progressions.realize(value, 4.0, None, 4.0, random.Random(0))) # --------------------------------------------------------------------------- # PitchSet # --------------------------------------------------------------------------- -def test_pitchset_tones_are_absolute () -> None: - - """tones() ignores root — the pitches chose their own register.""" - - cluster = subsequence.progressions.PitchSet([65, 60, 61]) - assert cluster.pitches == (60, 61, 65) - assert cluster.tones(root=80) == [60, 61, 65] - assert cluster.tones(count=5) == [60, 61, 65, 72, 73] - assert cluster.intervals() == [0, 1, 5] +def test_pitchset_tones_are_absolute() -> None: + """tones() ignores root — the pitches chose their own register.""" + cluster = subsequence.progressions.PitchSet([65, 60, 61]) -def test_pitchset_forces_loop_on_exhaustion () -> None: + assert cluster.pitches == (60, 61, 65) + assert cluster.tones(root=80) == [60, 61, 65] + assert cluster.tones(count=5) == [60, 61, 65, 72, 73] + assert cluster.intervals() == [0, 1, 5] - """A progression containing a PitchSet must loop, never fall through.""" - value = subsequence.progressions.progression([ - "Am", - subsequence.progressions.PitchSet([60, 61, 65]), - ]) +def test_pitchset_forces_loop_on_exhaustion() -> None: + """A progression containing a PitchSet must loop, never fall through.""" - assert value.loops_on_exhaustion - assert not subsequence.progressions.progression(["Am"]).loops_on_exhaustion + value = subsequence.progressions.progression( + [ + "Am", + subsequence.progressions.PitchSet([60, 61, 65]), + ] + ) + assert value.loops_on_exhaustion + assert not subsequence.progressions.progression(["Am"]).loops_on_exhaustion -def test_empty_pitchset_raises () -> None: - """A PitchSet needs at least one pitch.""" +def test_empty_pitchset_raises() -> None: + """A PitchSet needs at least one pitch.""" - with pytest.raises(ValueError): - subsequence.progressions.PitchSet([]) + with pytest.raises(ValueError): + subsequence.progressions.PitchSet([]) # --------------------------------------------------------------------------- # describe() # --------------------------------------------------------------------------- -def test_describe_prints_romans_unbound_and_names_under_a_key () -> None: - """Unbound: as written. Keyed: concrete names. Printed names re-enter.""" +def test_describe_prints_romans_unbound_and_names_under_a_key() -> None: + """Unbound: as written. Keyed: concrete names. Printed names re-enter.""" - value = subsequence.progressions.progression([1, "bVII7"]) + value = subsequence.progressions.progression([1, "bVII7"]) - unbound = value.describe() - keyed = value.describe(key="A", scale="minor") + unbound = value.describe() + keyed = value.describe(key="A", scale="minor") - assert "1" in unbound and "bVII7" in unbound - assert "Am" in keyed and "G7" in keyed + assert "1" in unbound and "bVII7" in unbound + assert "Am" in keyed and "G7" in keyed - # A printed concrete name re-enters as a list element. - reentered = subsequence.progressions.progression(["G7"]) - assert reentered.chords[0] == chord("G7") + # A printed concrete name re-enters as a list element. + reentered = subsequence.progressions.progression(["G7"]) + assert reentered.chords[0] == chord("G7") # --------------------------------------------------------------------------- # realize() — the breathing path (absorbed from progression.py) # --------------------------------------------------------------------------- -def test_realize_explicit_list_trims_to_length () -> None: - - """The final chord is trimmed so the timeline loops cleanly.""" - - value = subsequence.progressions.realize( - ["Am", "F", "C", "G"], WHOLE, None, 14.0, random.Random(0) - ) - - events = list(value) - assert value.length == 14.0 - assert events[-1].length == 2.0 - assert events[-1].start == 12.0 - - -def test_realize_list_rhythm_cycles () -> None: - - """A list harmonic rhythm cycles per chord.""" - - value = subsequence.progressions.realize( - ["Am", "F"], [2.0, 4.0], None, 12.0, random.Random(0) - ) - assert [event.length for event in value] == [2.0, 4.0, 2.0, 4.0] +def test_realize_explicit_list_trims_to_length() -> None: + """The final chord is trimmed so the timeline loops cleanly.""" + value = subsequence.progressions.realize( + ["Am", "F", "C", "G"], WHOLE, None, 14.0, random.Random(0) + ) -def test_realize_style_is_deterministic_under_a_seed () -> None: + events = list(value) + assert value.length == 14.0 + assert events[-1].length == 2.0 + assert events[-1].start == 12.0 - """Same seed, same walk.""" - first = subsequence.progressions.realize("phrygian_minor", WHOLE, "C", 32.0, random.Random(7)) - second = subsequence.progressions.realize("phrygian_minor", WHOLE, "C", 32.0, random.Random(7)) +def test_realize_list_rhythm_cycles() -> None: + """A list harmonic rhythm cycles per chord.""" - assert first.chords == second.chords + value = subsequence.progressions.realize( + ["Am", "F"], [2.0, 4.0], None, 12.0, random.Random(0) + ) + assert [event.length for event in value] == [2.0, 4.0, 2.0, 4.0] -def test_realize_style_needs_key () -> None: - """A graph style without a key is an error.""" +def test_realize_style_is_deterministic_under_a_seed() -> None: + """Same seed, same walk.""" - with pytest.raises(ValueError, match="key"): - subsequence.progressions.realize("phrygian_minor", WHOLE, None, 16.0, random.Random(0)) + first = subsequence.progressions.realize( + "phrygian_minor", WHOLE, "C", 32.0, random.Random(7) + ) + second = subsequence.progressions.realize( + "phrygian_minor", WHOLE, "C", 32.0, random.Random(7) + ) + assert first.chords == second.chords -def test_realize_relative_elements_need_key () -> None: - """Degree elements in the breathing path resolve against the given key.""" +def test_realize_style_needs_key() -> None: + """A graph style without a key is an error.""" - value = subsequence.progressions.realize([1, 6], WHOLE, "A", 8.0, random.Random(0), scale="minor") - assert value.chords == (chord("Am"), chord("F")) + with pytest.raises(ValueError, match="key"): + subsequence.progressions.realize( + "phrygian_minor", WHOLE, None, 16.0, random.Random(0) + ) - with pytest.raises(ValueError, match="key"): - subsequence.progressions.realize([1, 6], WHOLE, None, 8.0, random.Random(0)) +def test_realize_relative_elements_need_key() -> None: + """Degree elements in the breathing path resolve against the given key.""" -def test_realize_progression_value_tiles_with_its_own_spans () -> None: + value = subsequence.progressions.realize( + [1, 6], WHOLE, "A", 8.0, random.Random(0), scale="minor" + ) + assert value.chords == (chord("Am"), chord("F")) - """A Progression source cycles its spans across the part.""" + with pytest.raises(ValueError, match="key"): + subsequence.progressions.realize([1, 6], WHOLE, None, 8.0, random.Random(0)) - source = subsequence.progressions.progression(["Am", "F"], beats=2) - value = subsequence.progressions.realize(source, WHOLE, None, 8.0, random.Random(0)) - # harmonic_rhythm shapes lengths; the source supplies the chord cycle. - assert value.chords == (chord("Am"), chord("F")) +def test_realize_progression_value_tiles_with_its_own_spans() -> None: + """A Progression source cycles its spans across the part.""" + source = subsequence.progressions.progression(["Am", "F"], beats=2) + value = subsequence.progressions.realize(source, WHOLE, None, 8.0, random.Random(0)) -def test_realize_ambiguous_tuple_rhythm_raises () -> None: + # harmonic_rhythm shapes lengths; the source supplies the chord cycle. + assert value.chords == (chord("Am"), chord("F")) - """(low, high) tuples must be spelled between(...) here.""" - with pytest.raises(ValueError, match="between"): - subsequence.progressions.realize(["Am"], (2, 4), None, 8.0, random.Random(0)) # type: ignore[arg-type] +def test_realize_ambiguous_tuple_rhythm_raises() -> None: + """(low, high) tuples must be spelled between(...) here.""" + with pytest.raises(ValueError, match="between"): + subsequence.progressions.realize(["Am"], (2, 4), None, 8.0, random.Random(0)) # type: ignore[arg-type] -def test_realize_between_rhythm_is_bounded () -> None: - """between(...) lengths stay within their bounds.""" +def test_realize_between_rhythm_is_bounded() -> None: + """between(...) lengths stay within their bounds.""" - spec = subsequence.harmonic_rhythm.between(2, 4) - value = subsequence.progressions.realize("phrygian_minor", spec, "C", 64.0, random.Random(2)) + spec = subsequence.harmonic_rhythm.between(2, 4) + value = subsequence.progressions.realize( + "phrygian_minor", spec, "C", 64.0, random.Random(2) + ) - for event in value: - assert 2.0 <= event.length <= 4.0 or event.start + event.length == 64.0 + for event in value: + assert 2.0 <= event.length <= 4.0 or event.start + event.length == 64.0 -def test_resolve_voices_draws_in_range () -> None: +def test_resolve_voices_draws_in_range() -> None: + """(low, high) voicing draws stay within bounds; ints pass through.""" - """(low, high) voicing draws stay within bounds; ints pass through.""" + rng = random.Random(0) - rng = random.Random(0) + assert subsequence.progressions.resolve_voices(3, rng) == 3 + for _ in range(20): + assert 3 <= subsequence.progressions.resolve_voices((3, 5), rng) <= 5 - assert subsequence.progressions.resolve_voices(3, rng) == 3 - for _ in range(20): - assert 3 <= subsequence.progressions.resolve_voices((3, 5), rng) <= 5 - - with pytest.raises(ValueError): - subsequence.progressions.resolve_voices(0, rng) + with pytest.raises(ValueError): + subsequence.progressions.resolve_voices(0, rng) # --------------------------------------------------------------------------- # Progression.generate() — the hybrid generator (stage 3) # --------------------------------------------------------------------------- -def test_generate_end_lands_the_cadential_dominant () -> None: - - """end="V" — the chromatic major dominant in minor — lands on the last bar, every seed.""" - - for seed in range(10): - value = subsequence.progressions.Progression.generate( - style="aeolian_minor", bars=4, end="V", seed=seed, key="A", scale="minor", - ) - - assert len(value) == 4 - assert value.chords[-1] == chord("E") # major V in A minor - assert value.chords[0] == chord("Am") # the walk still starts home - - -def test_generate_keyless_is_relative_and_resolves_anywhere () -> None: - - """Sketch (a): no key= → a key-relative value that prints romans and binds later.""" - - value = subsequence.progressions.Progression.generate( - style="aeolian_minor", bars=4, end="V", seed=7, - ) - - assert not value.is_concrete - - unbound = value.describe() - assert "i" in unbound # the tonic prints as a roman - - in_a = value.resolve("A", "minor") - in_e = value.resolve("E", "minor") - - assert in_a.chords[0] == chord("Am") and in_a.chords[-1] == chord("E") - assert in_e.chords[0] == chord("Em") and in_e.chords[-1] == chord("B") - - -def test_generate_is_key_invariant () -> None: - - """The same seed in two keys walks the same shape, transposed — the relative emission's premise.""" - - in_c = subsequence.progressions.Progression.generate(style="aeolian_minor", bars=8, seed=11, key="C", scale="minor") - in_g = subsequence.progressions.Progression.generate(style="aeolian_minor", bars=8, seed=11, key="G", scale="minor") - - for c_chord, g_chord in zip(in_c.chords, in_g.chords): - assert (g_chord.root_pc - c_chord.root_pc) % 12 == 7 - assert g_chord.quality == c_chord.quality - - -def test_generate_relative_resolution_matches_concrete_generation () -> None: - """Keyless-then-resolved equals generating concretely in that key (round trip).""" +def test_generate_end_lands_the_cadential_dominant() -> None: + """end="V" — the chromatic major dominant in minor — lands on the last bar, every seed.""" - relative = subsequence.progressions.Progression.generate(style="aeolian_minor", bars=8, seed=3) - concrete = subsequence.progressions.Progression.generate(style="aeolian_minor", bars=8, seed=3, key="A") + for seed in range(10): + value = subsequence.progressions.Progression.generate( + style="aeolian_minor", + bars=4, + end="V", + seed=seed, + key="A", + scale="minor", + ) - assert relative.resolve("A", "minor").chords == concrete.chords + assert len(value) == 4 + assert value.chords[-1] == chord("E") # major V in A minor + assert value.chords[0] == chord("Am") # the walk still starts home -def test_generate_pins_fix_interior_bars () -> None: +def test_generate_keyless_is_relative_and_resolves_anywhere() -> None: + """Sketch (a): no key= → a key-relative value that prints romans and binds later.""" - """pins={bar: spec} compile into the walk — int degrees and romans both.""" + value = subsequence.progressions.Progression.generate( + style="aeolian_minor", + bars=4, + end="V", + seed=7, + ) - value = subsequence.progressions.Progression.generate( - style="functional_major", bars=6, key="C", pins={3: 4, 5: "V7"}, seed=2, - ) + assert not value.is_concrete - assert value.chords[2] == chord("F") # degree 4 in C major - assert value.chords[4] == chord("G7") + unbound = value.describe() + assert "i" in unbound # the tonic prints as a roman + in_a = value.resolve("A", "minor") + in_e = value.resolve("E", "minor") -def test_generate_pin_on_bar_one_overrides_the_tonic_start () -> None: + assert in_a.chords[0] == chord("Am") and in_a.chords[-1] == chord("E") + assert in_e.chords[0] == chord("Em") and in_e.chords[-1] == chord("B") - """pins={1: ...} sets where the walk begins.""" - value = subsequence.progressions.Progression.generate( - style="functional_major", bars=4, key="C", pins={1: 6}, seed=5, - ) +def test_generate_is_key_invariant() -> None: + """The same seed in two keys walks the same shape, transposed — the relative emission's premise.""" - assert value.chords[0] == chord("Am") + in_c = subsequence.progressions.Progression.generate( + style="aeolian_minor", bars=8, seed=11, key="C", scale="minor" + ) + in_g = subsequence.progressions.Progression.generate( + style="aeolian_minor", bars=8, seed=11, key="G", scale="minor" + ) + for c_chord, g_chord in zip(in_c.chords, in_g.chords): + assert (g_chord.root_pc - c_chord.root_pc) % 12 == 7 + assert g_chord.quality == c_chord.quality -def test_generate_avoid_excludes_everywhere () -> None: - """avoid=[...] never sounds, under any seed.""" +def test_generate_relative_resolution_matches_concrete_generation() -> None: + """Keyless-then-resolved equals generating concretely in that key (round trip).""" - banned = chord("Am") + relative = subsequence.progressions.Progression.generate( + style="aeolian_minor", bars=8, seed=3 + ) + concrete = subsequence.progressions.Progression.generate( + style="aeolian_minor", bars=8, seed=3, key="A" + ) - for seed in range(10): - value = subsequence.progressions.Progression.generate( - style="functional_major", bars=8, key="C", avoid=["vi"], seed=seed, - ) - assert banned not in value.chords + assert relative.resolve("A", "minor").chords == concrete.chords -def test_generate_unsatisfiable_constraints_raise () -> None: +def test_generate_pins_fix_interior_bars() -> None: + """pins={bar: spec} compile into the walk — int degrees and romans both.""" - """Contradictory constraints fail loudly before any chord is drawn.""" + value = subsequence.progressions.Progression.generate( + style="functional_major", + bars=6, + key="C", + pins={3: 4, 5: "V7"}, + seed=2, + ) - with pytest.raises(ValueError): - subsequence.progressions.Progression.generate( - style="functional_major", bars=4, key="C", end="V", avoid=["V"], seed=1, - ) + assert value.chords[2] == chord("F") # degree 4 in C major + assert value.chords[4] == chord("G7") -def test_generate_pin_outside_vocabulary_raises () -> None: +def test_generate_pin_on_bar_one_overrides_the_tonic_start() -> None: + """pins={1: ...} sets where the walk begins.""" - """A pin the style can never sound is named in the error.""" + value = subsequence.progressions.Progression.generate( + style="functional_major", + bars=4, + key="C", + pins={1: 6}, + seed=5, + ) - with pytest.raises(ValueError, match="vocabulary"): - subsequence.progressions.Progression.generate( - style="functional_major", bars=4, key="C", pins={3: "F#m"}, seed=1, - ) + assert value.chords[0] == chord("Am") -def test_generate_beats_shape_spans () -> None: +def test_generate_avoid_excludes_everywhere() -> None: + """avoid=[...] never sounds, under any seed.""" - """beats= cycles per span, exactly as the factory's.""" + banned = chord("Am") - value = subsequence.progressions.Progression.generate( - style="functional_major", bars=4, beats=[4, 2], key="C", seed=1, - ) + for seed in range(10): + value = subsequence.progressions.Progression.generate( + style="functional_major", + bars=8, + key="C", + avoid=["vi"], + seed=seed, + ) + assert banned not in value.chords - assert [span.beats for span in value.spans] == [4.0, 2.0, 4.0, 2.0] +def test_generate_unsatisfiable_constraints_raise() -> None: + """Contradictory constraints fail loudly before any chord is drawn.""" -def test_generate_without_seed_warns () -> None: + with pytest.raises(ValueError): + subsequence.progressions.Progression.generate( + style="functional_major", + bars=4, + key="C", + end="V", + avoid=["V"], + seed=1, + ) - """The standalone-generator seed policy applies.""" - with pytest.warns(UserWarning, match="seed"): - subsequence.progressions.Progression.generate(style="functional_major", bars=2, key="C") +def test_generate_pin_outside_vocabulary_raises() -> None: + """A pin the style can never sound is named in the error.""" + with pytest.raises(ValueError, match="vocabulary"): + subsequence.progressions.Progression.generate( + style="functional_major", + bars=4, + key="C", + pins={3: "F#m"}, + seed=1, + ) -def test_factory_forwards_hybrid_constraints () -> None: - """progression(style=, end=, pins=, avoid=) is the same generator.""" +def test_generate_beats_shape_spans() -> None: + """beats= cycles per span, exactly as the factory's.""" - via_factory = subsequence.progressions.progression( - style="aeolian_minor", bars=4, end="V", seed=7, - ) - via_generate = subsequence.progressions.Progression.generate( - style="aeolian_minor", bars=4, end="V", seed=7, - ) + value = subsequence.progressions.Progression.generate( + style="functional_major", + bars=4, + beats=[4, 2], + key="C", + seed=1, + ) - assert via_factory.resolve("A", "minor").chords == via_generate.resolve("A", "minor").chords + assert [span.beats for span in value.spans] == [4.0, 2.0, 4.0, 2.0] -def test_generate_unconstrained_matches_stage_two_walk () -> None: +def test_generate_without_seed_warns() -> None: + """The standalone-generator seed policy applies.""" - """No constraints → the walk consumes the RNG exactly as the plain engine would.""" + with pytest.warns(UserWarning, match="seed"): + subsequence.progressions.Progression.generate( + style="functional_major", bars=2, key="C" + ) - import subsequence.harmonic_state - value = subsequence.progressions.Progression.generate( - style="aeolian_minor", bars=6, key="A", seed=9, - ) +def test_factory_forwards_hybrid_constraints() -> None: + """progression(style=, end=, pins=, avoid=) is the same generator.""" - state = subsequence.harmonic_state.HarmonicState( - key_name="A", graph_style="aeolian_minor", rng=random.Random(9), - ) - expected = [state.current_chord] - for _ in range(5): - expected.append(state.step()) + via_factory = subsequence.progressions.progression( + style="aeolian_minor", + bars=4, + end="V", + seed=7, + ) + via_generate = subsequence.progressions.Progression.generate( + style="aeolian_minor", + bars=4, + end="V", + seed=7, + ) - assert list(value.chords) == expected + assert ( + via_factory.resolve("A", "minor").chords + == via_generate.resolve("A", "minor").chords + ) + + +def test_generate_unconstrained_matches_stage_two_walk() -> None: + """No constraints → the walk consumes the RNG exactly as the plain engine would.""" + + import subsequence.harmonic_state + + value = subsequence.progressions.Progression.generate( + style="aeolian_minor", + bars=6, + key="A", + seed=9, + ) + + state = subsequence.harmonic_state.HarmonicState( + key_name="A", + graph_style="aeolian_minor", + rng=random.Random(9), + ) + expected = [state.current_chord] + for _ in range(5): + expected.append(state.step()) + + assert list(value.chords) == expected diff --git a/tests/test_render.py b/tests/test_render.py index 8930e13..18e96fc 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -12,171 +12,179 @@ # Argument validation # --------------------------------------------------------------------------- -def test_render_raises_when_both_limits_are_none (patch_midi: None) -> None: - """render(bars=None, max_minutes=None) must raise ValueError immediately.""" +def test_render_raises_when_both_limits_are_none(patch_midi: None) -> None: + """render(bars=None, max_minutes=None) must raise ValueError immediately.""" - composition = subsequence.Composition(bpm=120) + composition = subsequence.Composition(bpm=120) - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - pass + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + pass - with pytest.raises(ValueError, match="at least one limit"): - composition.render(bars=None, max_minutes=None) + with pytest.raises(ValueError, match="at least one limit"): + composition.render(bars=None, max_minutes=None) -def test_render_accepts_bars_only (tmp_path: pathlib.Path, patch_midi: None) -> None: +def test_render_accepts_bars_only(tmp_path: pathlib.Path, patch_midi: None) -> None: + """render(bars=N, max_minutes=None) completes without error and stops at bar N.""" - """render(bars=N, max_minutes=None) completes without error and stops at bar N.""" + filename = str(tmp_path / "out.mid") + composition = subsequence.Composition(bpm=480) - filename = str(tmp_path / "out.mid") - composition = subsequence.Composition(bpm=480) + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + pass - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - pass + # Should complete without raising — output file may be empty (no recorded notes + # with patch_midi) but no exception means the bar limit worked correctly. + composition.render(bars=4, max_minutes=None, filename=filename) + assert composition._sequencer.current_bar >= 4 - # Should complete without raising — output file may be empty (no recorded notes - # with patch_midi) but no exception means the bar limit worked correctly. - composition.render(bars=4, max_minutes=None, filename=filename) - assert composition._sequencer.current_bar >= 4 +def test_render_accepts_max_minutes_only( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """render(bars=None, max_minutes=M) completes without error and stops at the time cap.""" -def test_render_accepts_max_minutes_only (tmp_path: pathlib.Path, patch_midi: None) -> None: + filename = str(tmp_path / "out.mid") + composition = subsequence.Composition(bpm=120) - """render(bars=None, max_minutes=M) completes without error and stops at the time cap.""" + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + pass - filename = str(tmp_path / "out.mid") - composition = subsequence.Composition(bpm=120) - - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - pass - - # A tiny cap (0.001 min = 0.06 s of MIDI) stops quickly without error. - composition.render(bars=None, max_minutes=0.001, filename=filename) - # The render stopped, so elapsed time should be very small - assert composition._sequencer._render_elapsed_seconds <= 0.1 + # A tiny cap (0.001 min = 0.06 s of MIDI) stops quickly without error. + composition.render(bars=None, max_minutes=0.001, filename=filename) + # The render stopped, so elapsed time should be very small + assert composition._sequencer._render_elapsed_seconds <= 0.1 # --------------------------------------------------------------------------- # Default values # --------------------------------------------------------------------------- -def test_render_default_max_minutes_is_60 (patch_midi: None) -> None: - """The sequencer receives render_max_seconds = 3600 when max_minutes is unset.""" +def test_render_default_max_minutes_is_60(patch_midi: None) -> None: + """The sequencer receives render_max_seconds = 3600 when max_minutes is unset.""" - composition = subsequence.Composition(bpm=120) + composition = subsequence.Composition(bpm=120) - # Peek at what render() would pass: call the sequencer setup path without - # actually running the async loop by inspecting the attribute assignment. - # We trigger the ValueError path to confirm the default is NOT None. - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - pass + # Peek at what render() would pass: call the sequencer setup path without + # actually running the async loop by inspecting the attribute assignment. + # We trigger the ValueError path to confirm the default is NOT None. + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + pass - # Monkey-patch asyncio.run to intercept without running - import asyncio - import unittest.mock as mock + # Monkey-patch asyncio.run to intercept without running + import asyncio + import unittest.mock as mock - with mock.patch("asyncio.run", side_effect=lambda coro: coro.close()): - composition.render(filename="dummy.mid") + with mock.patch("asyncio.run", side_effect=lambda coro: coro.close()): + composition.render(filename="dummy.mid") - assert composition._sequencer.render_max_seconds == pytest.approx(3600.0) + assert composition._sequencer.render_max_seconds == pytest.approx(3600.0) -def test_render_default_bars_is_none (patch_midi: None) -> None: +def test_render_default_bars_is_none(patch_midi: None) -> None: + """render() with no arguments sets render_bars = 0 (unlimited) on the sequencer.""" - """render() with no arguments sets render_bars = 0 (unlimited) on the sequencer.""" + composition = subsequence.Composition(bpm=120) - composition = subsequence.Composition(bpm=120) + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + pass - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - pass + import asyncio + import unittest.mock as mock - import asyncio - import unittest.mock as mock + with mock.patch("asyncio.run", side_effect=lambda coro: coro.close()): + composition.render(filename="dummy.mid") - with mock.patch("asyncio.run", side_effect=lambda coro: coro.close()): - composition.render(filename="dummy.mid") - - assert composition._sequencer.render_bars == 0 + assert composition._sequencer.render_bars == 0 # --------------------------------------------------------------------------- # Recorded output # --------------------------------------------------------------------------- -def test_render_writes_pattern_notes (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """A note placed by a pattern ends up in the rendered MIDI file as a note_on.""" - filename = str(tmp_path / "notes.mid") - composition = subsequence.Composition(bpm=480) +def test_render_writes_pattern_notes(tmp_path: pathlib.Path, patch_midi: None) -> None: + """A note placed by a pattern ends up in the rendered MIDI file as a note_on.""" - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - p.note(60, beat=0) + filename = str(tmp_path / "notes.mid") + composition = subsequence.Composition(bpm=480) - composition.render(bars=1, filename=filename) + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + p.note(60, beat=0) - mid = mido.MidiFile(filename) - note_ons = [ - msg for track in mid.tracks for msg in track - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0 - ] + composition.render(bars=1, filename=filename) - assert any(msg.note == 60 for msg in note_ons) + mid = mido.MidiFile(filename) + note_ons = [ + msg + for track in mid.tracks + for msg in track + if not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + ] + assert any(msg.note == 60 for msg in note_ons) -def test_render_does_not_leak_the_next_bar_downbeat (tmp_path: pathlib.Path, patch_midi: None) -> None: - """render(bars=N) stops exactly at the limit — bar N's downbeat is not rendered. +def test_render_does_not_leak_the_next_bar_downbeat( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """render(bars=N) stops exactly at the limit — bar N's downbeat is not rendered. - Regression: the bar limit tripped inside _check_bar_change but the loop still - dispatched that pulse, so the first beat of the next (unrendered) bar leaked - into the file. - """ + Regression: the bar limit tripped inside _check_bar_change but the loop still + dispatched that pulse, so the first beat of the next (unrendered) bar leaked + into the file. + """ - filename = str(tmp_path / "limit.mid") - composition = subsequence.Composition(bpm=480) + filename = str(tmp_path / "limit.mid") + composition = subsequence.Composition(bpm=480) - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - p.note(60, beat=0) # a note on every bar's downbeat + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + p.note(60, beat=0) # a note on every bar's downbeat - composition.render(bars=2, filename=filename) + composition.render(bars=2, filename=filename) - mid = mido.MidiFile(filename) - note_ons = [ - msg for track in mid.tracks for msg in track - if not isinstance(msg, mido.MetaMessage) and msg.type == "note_on" and msg.velocity > 0 - ] + mid = mido.MidiFile(filename) + note_ons = [ + msg + for track in mid.tracks + for msg in track + if not isinstance(msg, mido.MetaMessage) + and msg.type == "note_on" + and msg.velocity > 0 + ] - assert len(note_ons) == 2 # bars 0 and 1 only — no third note at bar 2's downbeat + assert len(note_ons) == 2 # bars 0 and 1 only — no third note at bar 2's downbeat # --------------------------------------------------------------------------- # Time cap stops render # --------------------------------------------------------------------------- -def test_render_stops_at_time_cap (tmp_path: pathlib.Path, patch_midi: None) -> None: - """A very small max_minutes cap stops the render before reaching many bars.""" +def test_render_stops_at_time_cap(tmp_path: pathlib.Path, patch_midi: None) -> None: + """A very small max_minutes cap stops the render before reaching many bars.""" - filename = str(tmp_path / "short.mid") - # At 120 BPM, 1 bar = 2 seconds. A 0.01-minute (0.6 s) cap should stop - # the render well before bar 100. - composition = subsequence.Composition(bpm=120) + filename = str(tmp_path / "short.mid") + # At 120 BPM, 1 bar = 2 seconds. A 0.01-minute (0.6 s) cap should stop + # the render well before bar 100. + composition = subsequence.Composition(bpm=120) - @composition.pattern(channel=1, beats=4) - def p (p) -> None: - pass + @composition.pattern(channel=1, beats=4) + def p(p) -> None: + pass - composition.render(bars=100, max_minutes=0.01, filename=filename) + composition.render(bars=100, max_minutes=0.01, filename=filename) - # The sequencer should have stopped early (elapsed < 100 bars). - assert composition._sequencer.current_bar < 100 + # The sequencer should have stopped early (elapsed < 100 bars). + assert composition._sequencer.current_bar < 100 diff --git a/tests/test_rescheduling.py b/tests/test_rescheduling.py index 87c7063..b04c9ba 100644 --- a/tests/test_rescheduling.py +++ b/tests/test_rescheduling.py @@ -4,168 +4,164 @@ import subsequence.sequencer -def _has_note_on_at_pulse (events: list[subsequence.sequencer.MidiEvent], pulse: int, note: int) -> bool: +def _has_note_on_at_pulse( + events: list[subsequence.sequencer.MidiEvent], pulse: int, note: int +) -> bool: + """Check whether a note_on event exists at a given pulse.""" - """Check whether a note_on event exists at a given pulse.""" + for event in events: + if event.pulse != pulse: + continue - for event in events: + if event.message_type != "note_on": + continue - if event.pulse != pulse: - continue + if event.note != note: + continue - if event.message_type != 'note_on': - continue + return True - if event.note != note: - continue - - return True - - return False + return False @pytest.mark.asyncio -async def test_reschedule_triggers_and_uses_updated_notes (patch_midi: None) -> None: - - """Ensure rescheduling triggers at lookahead and uses updated pattern state.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - - class TestPattern (subsequence.pattern.Pattern): - - """Pattern that changes its notes when rescheduled.""" - - def __init__ (self) -> None: +async def test_reschedule_triggers_and_uses_updated_notes(patch_midi: None) -> None: + """Ensure rescheduling triggers at lookahead and uses updated pattern state.""" - """Initialize the test pattern with a short cycle.""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - super().__init__(channel=0, length=4, reschedule_lookahead=1) + class TestPattern(subsequence.pattern.Pattern): + """Pattern that changes its notes when rescheduled.""" - self.reschedule_calls = 0 - self._build(initial=True) + def __init__(self) -> None: + """Initialize the test pattern with a short cycle.""" + super().__init__(channel=0, length=4, reschedule_lookahead=1) - def _build (self, initial: bool) -> None: + self.reschedule_calls = 0 + self._build(initial=True) - """Build either the initial or rescheduled note set.""" + def _build(self, initial: bool) -> None: + """Build either the initial or rescheduled note set.""" - self.steps = {} + self.steps = {} - if initial: - self.add_note(position=0, pitch=60, velocity=100, duration=6) + if initial: + self.add_note(position=0, pitch=60, velocity=100, duration=6) - else: - self.add_note(position=12, pitch=61, velocity=100, duration=6) + else: + self.add_note(position=12, pitch=61, velocity=100, duration=6) + def on_reschedule(self) -> None: + """Switch to the rescheduled note layout.""" - def on_reschedule (self) -> None: + self.reschedule_calls += 1 + self._build(initial=False) - """Switch to the rescheduled note layout.""" + pattern = TestPattern() + length_pulses = pattern.length * sequencer.pulses_per_beat + lookahead_pulses = pattern.reschedule_lookahead * sequencer.pulses_per_beat + reschedule_pulse = length_pulses - lookahead_pulses - self.reschedule_calls += 1 - self._build(initial=False) + await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) + await sequencer._maybe_reschedule_patterns(reschedule_pulse - 1) + assert pattern.reschedule_calls == 0 - pattern = TestPattern() - length_pulses = pattern.length * sequencer.pulses_per_beat - lookahead_pulses = pattern.reschedule_lookahead * sequencer.pulses_per_beat - reschedule_pulse = length_pulses - lookahead_pulses + await sequencer._maybe_reschedule_patterns(reschedule_pulse) + assert pattern.reschedule_calls == 1 - await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) + next_start = length_pulses + expected_note_pulse = next_start + 12 - await sequencer._maybe_reschedule_patterns(reschedule_pulse - 1) - assert pattern.reschedule_calls == 0 - - await sequencer._maybe_reschedule_patterns(reschedule_pulse) - assert pattern.reschedule_calls == 1 - - next_start = length_pulses - expected_note_pulse = next_start + 12 - - events = list(sequencer.event_queue) - assert _has_note_on_at_pulse(events, expected_note_pulse, 61) + events = list(sequencer.event_queue) + assert _has_note_on_at_pulse(events, expected_note_pulse, 61) @pytest.mark.asyncio -async def test_reschedule_lookahead_validation (patch_midi: None) -> None: +async def test_reschedule_lookahead_validation(patch_midi: None) -> None: + """Invalid lookahead values should raise when scheduling repeating patterns.""" - """Invalid lookahead values should raise when scheduling repeating patterns.""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern(channel=0, length=2, reschedule_lookahead=3) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=2, reschedule_lookahead=3) + with pytest.raises(ValueError): + await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) - with pytest.raises(ValueError): - await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) @pytest.mark.asyncio -async def test_failing_reschedule_is_contained (patch_midi: None) -> None: - - """A pattern whose rebuild raises must lose its cycle, not kill the clock. +async def test_failing_reschedule_is_contained(patch_midi: None) -> None: + """A pattern whose rebuild raises must lose its cycle, not kill the clock. - Regression: the reschedule loop had no containment, so a raising - on_reschedule() (or a set_length() below the lookahead) propagated up and - stopped every pattern. - """ + Regression: the reschedule loop had no containment, so a raising + on_reschedule() (or a set_length() below the lookahead) propagated up and + stopped every pattern. + """ - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - class BadPattern (subsequence.pattern.Pattern): + class BadPattern(subsequence.pattern.Pattern): + """Pattern that shrinks itself below the reschedule lookahead.""" - """Pattern that shrinks itself below the reschedule lookahead.""" + def __init__(self) -> None: + super().__init__(channel=0, length=4, reschedule_lookahead=1) + self.add_note(position=0, pitch=60, velocity=100, duration=6) - def __init__ (self) -> None: - super().__init__(channel=0, length=4, reschedule_lookahead=1) - self.add_note(position=0, pitch=60, velocity=100, duration=6) + def on_reschedule(self) -> None: + # Below the 1-beat lookahead - _get_pattern_timing raises. + self.length = 0.5 - def on_reschedule (self) -> None: - # Below the 1-beat lookahead - _get_pattern_timing raises. - self.length = 0.5 + class GoodPattern(subsequence.pattern.Pattern): + """Healthy sibling that must keep rescheduling.""" - class GoodPattern (subsequence.pattern.Pattern): + def __init__(self) -> None: + super().__init__(channel=0, length=4, reschedule_lookahead=1) + self.reschedule_calls = 0 + self.add_note(position=0, pitch=62, velocity=100, duration=6) - """Healthy sibling that must keep rescheduling.""" + def on_reschedule(self) -> None: + self.reschedule_calls += 1 - def __init__ (self) -> None: - super().__init__(channel=0, length=4, reschedule_lookahead=1) - self.reschedule_calls = 0 - self.add_note(position=0, pitch=62, velocity=100, duration=6) + bad = BadPattern() + good = GoodPattern() - def on_reschedule (self) -> None: - self.reschedule_calls += 1 + await sequencer.schedule_pattern_repeating(bad, start_pulse=0) + await sequencer.schedule_pattern_repeating(good, start_pulse=0) - bad = BadPattern() - good = GoodPattern() + reschedule_pulse = 4 * sequencer.pulses_per_beat - 1 * sequencer.pulses_per_beat - await sequencer.schedule_pattern_repeating(bad, start_pulse=0) - await sequencer.schedule_pattern_repeating(good, start_pulse=0) + # Must not raise, and the healthy pattern must still rebuild. + await sequencer._maybe_reschedule_patterns(reschedule_pulse) - reschedule_pulse = 4 * sequencer.pulses_per_beat - 1 * sequencer.pulses_per_beat + assert good.reschedule_calls == 1 - # Must not raise, and the healthy pattern must still rebuild. - await sequencer._maybe_reschedule_patterns(reschedule_pulse) - - assert good.reschedule_calls == 1 - - # The failing pattern keeps its previous timing and stays in rotation. - queued = [entry[2].pattern for entry in sequencer.reschedule_queue] - assert bad in queued + # The failing pattern keeps its previous timing and stays in rotation. + queued = [entry[2].pattern for entry in sequencer.reschedule_queue] + assert bad in queued @pytest.mark.asyncio -async def test_stop_survives_crashed_loop_task (patch_midi: None) -> None: - - """stop() must run its cleanup even when the loop task died with an exception. +async def test_stop_survives_crashed_loop_task(patch_midi: None) -> None: + """stop() must run its cleanup even when the loop task died with an exception. - Regression: stop() awaited the task unguarded, so a crashed loop aborted - shutdown before panic / port close / recording save. - """ + Regression: stop() awaited the task unguarded, so a crashed loop aborted + shutdown before panic / port close / recording save. + """ - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - async def _doomed () -> None: - raise RuntimeError("loop died") + async def _doomed() -> None: + raise RuntimeError("loop died") - sequencer.task = __import__("asyncio").get_event_loop().create_task(_doomed()) + sequencer.task = __import__("asyncio").get_event_loop().create_task(_doomed()) - # Must not raise. - await sequencer.stop() + # Must not raise. + await sequencer.stop() diff --git a/tests/test_review_fixes.py b/tests/test_review_fixes.py index 8622853..8ddd4ca 100644 --- a/tests/test_review_fixes.py +++ b/tests/test_review_fixes.py @@ -17,250 +17,301 @@ import subsequence.sequencer -def _builder ( - drum_note_map: typing.Optional[typing.Dict[str, int]] = None, - length: float = 4.0, - cycle: int = 0, - data: typing.Optional[dict] = None, -) -> typing.Tuple[subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder]: - - """A Pattern/PatternBuilder pair for unit-level builder tests.""" - - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - pattern = subsequence.pattern.Pattern(channel=0, length=length) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=cycle, - default_grid=default_grid, - drum_note_map=drum_note_map, - data=data if data is not None else {}, - ) - return pattern, builder +def _builder( + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + length: float = 4.0, + cycle: int = 0, + data: typing.Optional[dict] = None, +) -> typing.Tuple[ + subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder +]: + """A Pattern/PatternBuilder pair for unit-level builder tests.""" + + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pattern = subsequence.pattern.Pattern(channel=0, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=cycle, + default_grid=default_grid, + drum_note_map=drum_note_map, + data=data if data is not None else {}, + ) + return pattern, builder class _FakeChord: + """Minimal chord stand-in exposing the ``tones`` interface chord()/strum() use.""" - """Minimal chord stand-in exposing the ``tones`` interface chord()/strum() use.""" - - def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: - return [root, root + 4, root + 7] + def tones( + self, root: int, inversion: int = 0, count: typing.Optional[int] = None + ) -> typing.List[int]: + return [root, root + 4, root + 7] # ── M4: cc() clamps its value to the 7-bit range ───────────────────────────── -def test_cc_clamps_out_of_range_value () -> None: - pattern, builder = _builder() - builder.cc(74, 200) - builder.cc(74, -5, beat=1) +def test_cc_clamps_out_of_range_value() -> None: + pattern, builder = _builder() + + builder.cc(74, 200) + builder.cc(74, -5, beat=1) - assert [e.value for e in pattern.cc_events] == [127, 0] + assert [e.value for e in pattern.cc_events] == [127, 0] # ── M3: fibonacci_rhythm spreads (golden angle), it doesn't cluster ────────── -def test_fibonacci_rhythm_spreads_not_clusters () -> None: - positions = subsequence.sequence_utils.fibonacci_rhythm(8, length=4.0) - gaps = [b - a for a, b in zip(positions, positions[1:])] - # The corrected frac(i·φ)·length form keeps a min gap ~0.36; the old - # (i·φ) % length clustered two notes to within ~0.09. - assert min(gaps) > 0.3 +def test_fibonacci_rhythm_spreads_not_clusters() -> None: + positions = subsequence.sequence_utils.fibonacci_rhythm(8, length=4.0) + gaps = [b - a for a, b in zip(positions, positions[1:])] + + # The corrected frac(i·φ)·length form keeps a min gap ~0.36; the old + # (i·φ) % length clustered two notes to within ~0.09. + assert min(gaps) > 0.3 # ── M1: bar math honours the time signature, not a hardcoded 4 ─────────────── -def test_resolve_length_uses_beats_per_bar () -> None: - assert subsequence.Composition._resolve_length(None, 2, None, None, beats_per_bar=3)[0] == 6.0 - assert subsequence.Composition._resolve_length(None, 2, None, None, beats_per_bar=4)[0] == 8.0 - assert subsequence.Composition._resolve_length(None, 2, None, None)[0] == 8.0 # default 4/4 +def test_resolve_length_uses_beats_per_bar() -> None: + assert ( + subsequence.Composition._resolve_length(None, 2, None, None, beats_per_bar=3)[0] + == 6.0 + ) + assert ( + subsequence.Composition._resolve_length(None, 2, None, None, beats_per_bar=4)[0] + == 8.0 + ) + assert ( + subsequence.Composition._resolve_length(None, 2, None, None)[0] == 8.0 + ) # default 4/4 -def test_set_target_bpm_respects_time_signature (patch_midi: None) -> None: - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120, time_signature=(3, 4)) - seq.set_target_bpm(140, bars=2) +def test_set_target_bpm_respects_time_signature(patch_midi: None) -> None: + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120, time_signature=(3, 4) + ) - assert seq._bpm_transition is not None - assert seq._bpm_transition.total_pulses == 2 * seq.pulses_per_beat * 3 + seq.set_target_bpm(140, bars=2) + + assert seq._bpm_transition is not None + assert seq._bpm_transition.total_pulses == 2 * seq.pulses_per_beat * 3 # ── H2: evolve() keys its buffer on seed content, not object identity ──────── -def test_evolve_buffer_key_is_content_stable_across_cycles () -> None: - shared: dict = {} - # A fresh seed literal every cycle (the documented idiom). - for cycle in range(4): - _, builder = _builder(cycle=cycle, data=shared) - builder.evolve([60, 62, 64, 67], length=4, drift=0.2, spacing=0.5) +def test_evolve_buffer_key_is_content_stable_across_cycles() -> None: + shared: dict = {} - evolve_keys = [k for k in shared if k.startswith("_evolve_")] + # A fresh seed literal every cycle (the documented idiom). + for cycle in range(4): + _, builder = _builder(cycle=cycle, data=shared) + builder.evolve([60, 62, 64, 67], length=4, drift=0.2, spacing=0.5) - # One stable key reused across cycles — not a new (leaked) key each cycle, - # which was the id(pitches) bug that stopped drift accumulating. - assert len(evolve_keys) == 1 + evolve_keys = [k for k in shared if k.startswith("_evolve_")] + # One stable key reused across cycles — not a new (leaked) key each cycle, + # which was the id(pitches) bug that stopped drift accumulating. + assert len(evolve_keys) == 1 -# ── M2: no_overlap honoured in thue_morse's two-pitch branch ───────────────── -def test_thue_morse_two_pitch_honours_no_overlap () -> None: - pattern, builder = _builder(drum_note_map={"a": 36, "b": 38}) +# ── M2: no_overlap honoured in thue_morse's two-pitch branch ───────────────── - # Pre-fill voice "a" on every step (same grid thue_morse uses). - builder.hit_steps("a", list(range(16)), velocity=100) - builder.thue_morse("a", pitch_b="b", no_overlap=True) +def test_thue_morse_two_pitch_honours_no_overlap() -> None: + pattern, builder = _builder(drum_note_map={"a": 36, "b": 38}) - # no_overlap must stop a second "a" landing where one already sounds. - for step in pattern.steps.values(): - a_notes = [n for n in step.notes if n.pitch == 36] - assert len(a_notes) <= 1 + # Pre-fill voice "a" on every step (same grid thue_morse uses). + builder.hit_steps("a", list(range(16)), velocity=100) + builder.thue_morse("a", pitch_b="b", no_overlap=True) -# ── M5: chord/strum detached guard gives a clear error ─────────────────────── + # no_overlap must stop a second "a" landing where one already sounds. + for step in pattern.steps.values(): + a_notes = [n for n in step.notes if n.pitch == 36] + assert len(a_notes) <= 1 -def test_chord_detached_exceeding_length_raises_clear_error () -> None: - _, builder = _builder(length=4.0) - with pytest.raises(ValueError, match="detached"): - builder.chord(_FakeChord(), root=60, detached=5.0) # 5 > length 4 +# ── M5: chord/strum detached guard gives a clear error ─────────────────────── -def test_strum_detached_exceeding_length_raises_clear_error () -> None: - _, builder = _builder(length=4.0) +def test_chord_detached_exceeding_length_raises_clear_error() -> None: + _, builder = _builder(length=4.0) - with pytest.raises(ValueError, match="detached"): - builder.strum(_FakeChord(), root=60, detached=5.0, spacing=0.1) + with pytest.raises(ValueError, match="detached"): + builder.chord(_FakeChord(), root=60, detached=5.0) # 5 > length 4 -# ── Low: negative beats wrap from the end at any magnitude ─────────────────── +def test_strum_detached_exceeding_length_raises_clear_error() -> None: + _, builder = _builder(length=4.0) -def test_negative_beat_wraps_any_magnitude () -> None: - pattern, builder = _builder(length=4.0) + with pytest.raises(ValueError, match="detached"): + builder.strum(_FakeChord(), root=60, detached=5.0, spacing=0.1) - builder.note(60, beat=-5.0) # -5 % 4 == 3.0 - pulse = int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE) - assert pulse in pattern.steps - assert pattern.steps[pulse].notes[0].pitch == 60 +# ── Low: negative beats wrap from the end at any magnitude ─────────────────── -# ── Low: set_length() chains ───────────────────────────────────────────────── +def test_negative_beat_wraps_any_magnitude() -> None: + pattern, builder = _builder(length=4.0) -def test_set_length_returns_self_for_chaining () -> None: - pattern, builder = _builder() + builder.note(60, beat=-5.0) # -5 % 4 == 3.0 - result = builder.set_length(8.0) + pulse = int(3.0 * subsequence.constants.MIDI_QUARTER_NOTE) + assert pulse in pattern.steps + assert pattern.steps[pulse].notes[0].pitch == 60 - assert result is builder - assert pattern.length == 8.0 +# ── Low: set_length() chains ───────────────────────────────────────────────── -# ── Test gap: p.sysex() emits the event with its payload ───────────────────── -def test_sysex_emits_event_with_payload () -> None: - pattern, builder = _builder() +def test_set_length_returns_self_for_chaining() -> None: + pattern, builder = _builder() - builder.sysex([0x7E, 0x7F, 0x09, 0x01], beat=0) + result = builder.set_length(8.0) - sysex_events = [e for e in pattern.cc_events if e.message_type == "sysex"] - assert len(sysex_events) == 1 - assert list(sysex_events[0].data) == [0x7E, 0x7F, 0x09, 0x01] + assert result is builder + assert pattern.length == 8.0 -# ── H1: each layer() gets a distinct, stable name (no collision) ───────────── +# ── Test gap: p.sysex() emits the event with its payload ───────────────────── -def test_layer_patterns_get_distinct_names (patch_midi: None) -> None: - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - def kick (p: typing.Any) -> None: ... - def hats (p: typing.Any) -> None: ... - def snare (p: typing.Any) -> None: ... - def claps (p: typing.Any) -> None: ... +def test_sysex_emits_event_with_payload() -> None: + pattern, builder = _builder() - comp.layer(kick, hats, channel=10, beats=4) - comp.layer(snare, claps, channel=11, beats=4) + builder.sysex([0x7E, 0x7F, 0x09, 0x01], beat=0) - names = [p.builder_fn.__name__ for p in comp._pending_patterns] + sysex_events = [e for e in pattern.cc_events if e.message_type == "sysex"] + assert len(sysex_events) == 1 + assert list(sysex_events[0].data) == [0x7E, 0x7F, 0x09, 0x01] - # The name suffix uses the RESOLVED 0-indexed channel: user channel 10 → ch9. - assert "kick+hats@ch9" in names - assert "snare+claps@ch10" in names - assert len(set(names)) == len(names) # no "merged_builder" collision +# ── H1: each layer() gets a distinct, stable name (no collision) ───────────── -# ── Low: cellular-automaton memoisation is correct (incremental == from-scratch) ── -def test_cellular_1d_memoised_matches_from_scratch () -> None: - import subsequence.sequence_utils as su +def test_layer_patterns_get_distinct_names(patch_midi: None) -> None: + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - su._ca_1d_cache.clear() - fresh = su.generate_cellular_automaton_1d(16, rule=30, generation=7, seed=1) + def kick(p: typing.Any) -> None: ... + def hats(p: typing.Any) -> None: ... + def snare(p: typing.Any) -> None: ... + def claps(p: typing.Any) -> None: ... - su._ca_1d_cache.clear() - incremental: typing.List[int] = [] - for gen in range(8): - incremental = su.generate_cellular_automaton_1d(16, rule=30, generation=gen, seed=1) + comp.layer(kick, hats, channel=10, beats=4) + comp.layer(snare, claps, channel=11, beats=4) - assert incremental == fresh + names = [p.builder_fn.__name__ for p in comp._pending_patterns] + # The name suffix uses the RESOLVED 0-indexed channel: user channel 10 → ch9. + assert "kick+hats@ch9" in names + assert "snare+claps@ch10" in names + assert len(set(names)) == len(names) # no "merged_builder" collision -def test_cellular_2d_memoised_matches_from_scratch () -> None: - import subsequence.sequence_utils as su - su._ca_2d_cache.clear() - fresh = su.generate_cellular_automaton_2d(rows=4, cols=8, rule="B3/S23", generation=6, seed=42) +# ── Low: cellular-automaton memoisation is correct (incremental == from-scratch) ── - su._ca_2d_cache.clear() - incremental: typing.List[typing.List[int]] = [] - for gen in range(7): - incremental = su.generate_cellular_automaton_2d(rows=4, cols=8, rule="B3/S23", generation=gen, seed=42) - assert incremental == fresh +def test_cellular_1d_memoised_matches_from_scratch() -> None: + import subsequence.sequence_utils as su + su._ca_1d_cache.clear() + fresh = su.generate_cellular_automaton_1d(16, rule=30, generation=7, seed=1) -# ── M6: previously-unreachable diatonic chords are now reachable from the tonic ── + su._ca_1d_cache.clear() + incremental: typing.List[int] = [] + for gen in range(8): + incremental = su.generate_cellular_automaton_1d( + 16, rule=30, generation=gen, seed=1 + ) -def _reachable_from_tonic (graph: typing.Any, tonic: typing.Any) -> typing.Set[typing.Any]: + assert incremental == fresh - """Set of chords reachable from *tonic* by following graph edges (BFS).""" - seen = {tonic} - frontier = [tonic] +def test_cellular_2d_memoised_matches_from_scratch() -> None: + import subsequence.sequence_utils as su - while frontier: - node = frontier.pop() - for target, _weight in graph.get_transitions(node): - if target not in seen: - seen.add(target) - frontier.append(target) + su._ca_2d_cache.clear() + fresh = su.generate_cellular_automaton_2d( + rows=4, cols=8, rule="B3/S23", generation=6, seed=42 + ) - return seen + su._ca_2d_cache.clear() + incremental: typing.List[typing.List[int]] = [] + for gen in range(7): + incremental = su.generate_cellular_automaton_2d( + rows=4, cols=8, rule="B3/S23", generation=gen, seed=42 + ) + assert incremental == fresh -def test_chord_graph_orphans_now_reachable () -> None: - import subsequence.chord_graphs.aeolian_minor - import subsequence.chord_graphs.dorian_minor - import subsequence.chord_graphs.functional_major - import subsequence.chord_graphs.lydian_major - import subsequence.chords - chord = subsequence.chords.Chord +# ── M6: previously-unreachable diatonic chords are now reachable from the tonic ── - # (built graph+tonic, chords that the review found unreachable) — key of C. - cases = [ - (subsequence.chord_graphs.functional_major.DiatonicMajor().build("C"), - [chord(root_pc=4, quality="minor"), chord(root_pc=11, quality="diminished")]), # iii, vii° - (subsequence.chord_graphs.aeolian_minor.AeolianMinor().build("C"), - [chord(root_pc=3, quality="major"), chord(root_pc=2, quality="diminished"), chord(root_pc=11, quality="diminished")]), # bIII, ii°, vii° - (subsequence.chord_graphs.dorian_minor.DorianMinor().build("C"), - [chord(root_pc=9, quality="diminished")]), # vi° - (subsequence.chord_graphs.lydian_major.LydianMajor().build("C"), - [chord(root_pc=6, quality="diminished"), chord(root_pc=11, quality="minor")]), # #iv°, vii - ] - for (graph, tonic), expected in cases: - reachable = _reachable_from_tonic(graph, tonic) - for orphan in expected: - assert orphan in reachable, f"{orphan} should now be reachable from the tonic" +def _reachable_from_tonic( + graph: typing.Any, tonic: typing.Any +) -> typing.Set[typing.Any]: + """Set of chords reachable from *tonic* by following graph edges (BFS).""" + + seen = {tonic} + frontier = [tonic] + + while frontier: + node = frontier.pop() + for target, _weight in graph.get_transitions(node): + if target not in seen: + seen.add(target) + frontier.append(target) + + return seen + + +def test_chord_graph_orphans_now_reachable() -> None: + import subsequence.chord_graphs.aeolian_minor + import subsequence.chord_graphs.dorian_minor + import subsequence.chord_graphs.functional_major + import subsequence.chord_graphs.lydian_major + import subsequence.chords + + chord = subsequence.chords.Chord + + # (built graph+tonic, chords that the review found unreachable) — key of C. + cases = [ + ( + subsequence.chord_graphs.functional_major.DiatonicMajor().build("C"), + [ + chord(root_pc=4, quality="minor"), + chord(root_pc=11, quality="diminished"), + ], + ), # iii, vii° + ( + subsequence.chord_graphs.aeolian_minor.AeolianMinor().build("C"), + [ + chord(root_pc=3, quality="major"), + chord(root_pc=2, quality="diminished"), + chord(root_pc=11, quality="diminished"), + ], + ), # bIII, ii°, vii° + ( + subsequence.chord_graphs.dorian_minor.DorianMinor().build("C"), + [chord(root_pc=9, quality="diminished")], + ), # vi° + ( + subsequence.chord_graphs.lydian_major.LydianMajor().build("C"), + [ + chord(root_pc=6, quality="diminished"), + chord(root_pc=11, quality="minor"), + ], + ), # #iv°, vii + ] + + for (graph, tonic), expected in cases: + reachable = _reachable_from_tonic(graph, tonic) + for orphan in expected: + assert orphan in reachable, ( + f"{orphan} should now be reachable from the tonic" + ) diff --git a/tests/test_review_fixes_2026_07.py b/tests/test_review_fixes_2026_07.py index a5608a8..29f5c80 100644 --- a/tests/test_review_fixes_2026_07.py +++ b/tests/test_review_fixes_2026_07.py @@ -38,270 +38,281 @@ import subsequence.web_ui -def _builder ( - drum_note_map: typing.Optional[typing.Dict[str, int]] = None, - length: float = 4.0, - conductor: typing.Optional[subsequence.conductor.Conductor] = None, - bar: int = 0, - time_signature: typing.Tuple[int, int] = (4, 4), -) -> typing.Tuple[subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder]: - - """A Pattern/PatternBuilder pair for unit-level builder tests.""" - - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - pattern = subsequence.pattern.Pattern(channel=0, length=length) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=0, - conductor=conductor, - bar=bar, - default_grid=default_grid, - drum_note_map=drum_note_map, - time_signature=time_signature, - ) - return pattern, builder +def _builder( + drum_note_map: typing.Optional[typing.Dict[str, int]] = None, + length: float = 4.0, + conductor: typing.Optional[subsequence.conductor.Conductor] = None, + bar: int = 0, + time_signature: typing.Tuple[int, int] = (4, 4), +) -> typing.Tuple[ + subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder +]: + """A Pattern/PatternBuilder pair for unit-level builder tests.""" + + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pattern = subsequence.pattern.Pattern(channel=0, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=0, + conductor=conductor, + bar=bar, + default_grid=default_grid, + drum_note_map=drum_note_map, + time_signature=time_signature, + ) + return pattern, builder # ── sequence_utils: negative pulses rejected with a clear message ───────────── -def test_euclidean_negative_pulses_raises () -> None: - """generate_euclidean_sequence(16, -1) raises instead of hanging/garbage.""" +def test_euclidean_negative_pulses_raises() -> None: + """generate_euclidean_sequence(16, -1) raises instead of hanging/garbage.""" - with pytest.raises(ValueError, match="zero or positive"): - subsequence.sequence_utils.generate_euclidean_sequence(16, -1) + with pytest.raises(ValueError, match="zero or positive"): + subsequence.sequence_utils.generate_euclidean_sequence(16, -1) -def test_bresenham_negative_pulses_raises () -> None: +def test_bresenham_negative_pulses_raises() -> None: + """generate_bresenham_sequence(16, -1) raises the same clear error.""" - """generate_bresenham_sequence(16, -1) raises the same clear error.""" - - with pytest.raises(ValueError, match="zero or positive"): - subsequence.sequence_utils.generate_bresenham_sequence(16, -1) + with pytest.raises(ValueError, match="zero or positive"): + subsequence.sequence_utils.generate_bresenham_sequence(16, -1) # ── sequence_utils: van der Corput guards its base ──────────────────────────── -def test_van_der_corput_invalid_base_raises () -> None: - - """base=0 (division by zero) and base=1 (infinite loop) both raise.""" - with pytest.raises(ValueError, match="at least 2"): - subsequence.sequence_utils.generate_van_der_corput_sequence(4, base=0) +def test_van_der_corput_invalid_base_raises() -> None: + """base=0 (division by zero) and base=1 (infinite loop) both raise.""" - with pytest.raises(ValueError, match="at least 2"): - subsequence.sequence_utils.generate_van_der_corput_sequence(4, base=1) + with pytest.raises(ValueError, match="at least 2"): + subsequence.sequence_utils.generate_van_der_corput_sequence(4, base=0) + with pytest.raises(ValueError, match="at least 2"): + subsequence.sequence_utils.generate_van_der_corput_sequence(4, base=1) -def test_van_der_corput_base_two_unchanged () -> None: - """The classic base-2 sequence still comes out of the guarded function.""" +def test_van_der_corput_base_two_unchanged() -> None: + """The classic base-2 sequence still comes out of the guarded function.""" - assert subsequence.sequence_utils.generate_van_der_corput_sequence(4) == [0.0, 0.5, 0.25, 0.75] + assert subsequence.sequence_utils.generate_van_der_corput_sequence(4) == [ + 0.0, + 0.5, + 0.25, + 0.75, + ] # ── progressions: accidental degrees are scale-proof under short scales ─────── -def test_accidental_degree_resolves_under_five_degree_scale () -> None: - """bVII reads the major scale, so it survives a 5-degree scale like hirajoshi.""" +def test_accidental_degree_resolves_under_five_degree_scale() -> None: + """bVII reads the major scale, so it survives a 5-degree scale like hirajoshi.""" - chord = subsequence.progressions.RomanChord(degree=7, accidental=-1, quality="major").resolve(0, "hirajoshi") + chord = subsequence.progressions.RomanChord( + degree=7, accidental=-1, quality="major" + ).resolve(0, "hirajoshi") - assert chord.root_pc == 10 + assert chord.root_pc == 10 -def test_plain_out_of_range_degree_still_raises () -> None: +def test_plain_out_of_range_degree_still_raises() -> None: + """An unprefixed degree beyond the scale keeps its out-of-range error.""" - """An unprefixed degree beyond the scale keeps its out-of-range error.""" - - with pytest.raises(ValueError, match="out of range"): - subsequence.progressions.RomanChord(degree=9).resolve(0, "ionian") + with pytest.raises(ValueError, match="out of range"): + subsequence.progressions.RomanChord(degree=9).resolve(0, "ionian") # ── progressions: extend(9) on a degree stacks the implied diatonic seventh ─── -def test_extend_nine_on_degree_stacks_implied_seventh () -> None: - """extend(9) on V in C yields a full ninth chord (7th AND 9th), not add9.""" +def test_extend_nine_on_degree_stacks_implied_seventh() -> None: + """extend(9) on V in C yields a full ninth chord (7th AND 9th), not add9.""" - resolved = subsequence.progression([5]).extend(9).resolve("C") + resolved = subsequence.progression([5]).extend(9).resolve("C") - # G B D F A — the diatonic seventh (F) is implied by the ninth. - assert resolved.spans[0].tones(60) == [55, 59, 62, 65, 69] + # G B D F A — the diatonic seventh (F) is implied by the ninth. + assert resolved.spans[0].tones(60) == [55, 59, 62, 65, 69] # ── progressions: generation-only kwargs on a concrete list raise ────────────── -def test_generation_only_kwargs_on_concrete_list_raise () -> None: - """cadence=/key= on a concrete progression name the style= path in the error.""" +def test_generation_only_kwargs_on_concrete_list_raise() -> None: + """cadence=/key= on a concrete progression name the style= path in the error.""" - with pytest.raises(ValueError, match="style="): - subsequence.progression(["Am", "F", "C", "G"], cadence="strong") + with pytest.raises(ValueError, match="style="): + subsequence.progression(["Am", "F", "C", "G"], cadence="strong") - with pytest.raises(ValueError, match="style="): - subsequence.progression(["Am", "F", "C", "G"], key="C") + with pytest.raises(ValueError, match="style="): + subsequence.progression(["Am", "F", "C", "G"], key="C") # ── pattern: sub-pulse durations clamp to one pulse instead of raising ───────── -def test_add_note_beats_subpulse_duration_clamps_to_one_pulse () -> None: - """A positive duration shorter than one pulse stores a 1-pulse note.""" +def test_add_note_beats_subpulse_duration_clamps_to_one_pulse() -> None: + """A positive duration shorter than one pulse stores a 1-pulse note.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4.0) + pattern = subsequence.pattern.Pattern(channel=0, length=4.0) - pattern.add_note_beats(0.0, 60, 100, duration_beats=0.001) + pattern.add_note_beats(0.0, 60, 100, duration_beats=0.001) - assert pattern.steps[0].notes[0].duration == 1 + assert pattern.steps[0].notes[0].duration == 1 # ── pattern_builder: grid=0 transforms are silent no-ops ─────────────────────── -def test_grid_zero_transforms_are_silent_noops () -> None: - """hit_steps/sequence/scale_velocities/rotate with grid=0 return the builder untouched.""" +def test_grid_zero_transforms_are_silent_noops() -> None: + """hit_steps/sequence/scale_velocities/rotate with grid=0 return the builder untouched.""" - pattern, builder = _builder() + pattern, builder = _builder() - assert builder.hit_steps(60, [0, 4], grid=0) is builder - assert builder.sequence([0, 4], 60, grid=0) is builder - assert builder.scale_velocities([1.0], grid=0) is builder - assert builder.rotate(1, grid=0) is builder + assert builder.hit_steps(60, [0, 4], grid=0) is builder + assert builder.sequence([0, 4], 60, grid=0) is builder + assert builder.scale_velocities([1.0], grid=0) is builder + assert builder.rotate(1, grid=0) is builder - assert pattern.steps == {} # nothing placed, nothing crashed + assert pattern.steps == {} # nothing placed, nothing crashed # ── pattern_builder: signal() honours the time signature ─────────────────────── -def test_signal_reads_bar_start_in_any_metre () -> None: - """In 3/4, bar 1 starts at beat 3 — signal() must read the conductor there.""" +def test_signal_reads_bar_start_in_any_metre() -> None: + """In 3/4, bar 1 starts at beat 3 — signal() must read the conductor there.""" - conductor = subsequence.conductor.Conductor() - conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=6.0) + conductor = subsequence.conductor.Conductor() + conductor.line("ramp", start_val=0.0, end_val=1.0, duration_beats=6.0) - _, builder = _builder(conductor=conductor, bar=1, time_signature=(3, 4)) + _, builder = _builder(conductor=conductor, bar=1, time_signature=(3, 4)) - # Beat 3 of a 6-beat line = 0.5. The old hardcoded 4 read beat 4 (≈0.667). - assert builder.signal("ramp") == pytest.approx(0.5) + # Beat 3 of a 6-beat line = 0.5. The old hardcoded 4 read beat 4 (≈0.667). + assert builder.signal("ramp") == pytest.approx(0.5) # ── pattern_midi: ramps emit their endpoint ──────────────────────────────────── -def test_cc_ramp_emits_endpoint_when_resolution_does_not_divide () -> None: - """A ramp whose resolution doesn't divide the span still reaches its target.""" +def test_cc_ramp_emits_endpoint_when_resolution_does_not_divide() -> None: + """A ramp whose resolution doesn't divide the span still reaches its target.""" - pattern, builder = _builder() + pattern, builder = _builder() - builder.cc_ramp(74, 0, 127, beat_end=1.0, resolution=5) + builder.cc_ramp(74, 0, 127, beat_end=1.0, resolution=5) - events = [e for e in pattern.cc_events if e.message_type == "control_change"] - last = max(events, key=lambda e: e.pulse) + events = [e for e in pattern.cc_events if e.message_type == "control_change"] + last = max(events, key=lambda e: e.pulse) - assert (last.pulse, last.value) == (24, 127) + assert (last.pulse, last.value) == (24, 127) # ── pattern_midi: bend_range must be positive ────────────────────────────────── -def test_zero_bend_range_raises () -> None: - """portamento()/slide() reject bend_range=0 with a clear message.""" +def test_zero_bend_range_raises() -> None: + """portamento()/slide() reject bend_range=0 with a clear message.""" - _, builder = _builder() + _, builder = _builder() - with pytest.raises(ValueError, match="bend_range"): - builder.portamento(bend_range=0) + with pytest.raises(ValueError, match="bend_range"): + builder.portamento(bend_range=0) - with pytest.raises(ValueError, match="bend_range"): - builder.slide(notes=[0], bend_range=0) + with pytest.raises(ValueError, match="bend_range"): + builder.slide(notes=[0], bend_range=0) # ── pattern_midi: sysex data bytes are validated to 7-bit ────────────────────── -def test_sysex_data_byte_out_of_range_raises () -> None: - """A sysex data byte over 127 raises instead of dying at dispatch time.""" +def test_sysex_data_byte_out_of_range_raises() -> None: + """A sysex data byte over 127 raises instead of dying at dispatch time.""" - _, builder = _builder() + _, builder = _builder() - with pytest.raises(ValueError, match="0-127"): - builder.sysex([0x7E, 200]) + with pytest.raises(ValueError, match="0-127"): + builder.sysex([0x7E, 200]) # ── motifs: the fume_fume preset carries the catalogued onsets ───────────────── -def test_preset_fume_fume_onsets () -> None: - """fume fume onsets land at pulses 0,2,4,7,9 of a 12-pulse bar.""" +def test_preset_fume_fume_onsets() -> None: + """fume fume onsets land at pulses 0,2,4,7,9 of a 12-pulse bar.""" - bell = subsequence.Motif.preset("fume_fume") + bell = subsequence.Motif.preset("fume_fume") - assert bell.onsets() == pytest.approx([p * 4.0 / 12 for p in (0, 2, 4, 7, 9)]) - assert bell.length == 4.0 + assert bell.onsets() == pytest.approx([p * 4.0 / 12 for p in (0, 2, 4, 7, 9)]) + assert bell.length == 4.0 # ── motifs: unseeded develop() is genuinely nondeterministic ─────────────────── -def _phrase_events (phrase: subsequence.Phrase) -> typing.List[typing.Tuple[typing.Any, ...]]: - - """Flatten a phrase to comparable (beat, pitch, velocity) tuples.""" - return [(e.beat, e.pitch, e.velocity) for segment in phrase.segments for e in segment.events] +def _phrase_events( + phrase: subsequence.Phrase, +) -> typing.List[typing.Tuple[typing.Any, ...]]: + """Flatten a phrase to comparable (beat, pitch, velocity) tuples.""" + return [ + (e.beat, e.pitch, e.velocity) + for segment in phrase.segments + for e in segment.events + ] -def test_develop_unseeded_differs_seeded_repeats () -> None: - """develop() without seed= draws a fresh salt (it used to silently repeat).""" +def test_develop_unseeded_differs_seeded_repeats() -> None: + """develop() without seed= draws a fresh salt (it used to silently repeat).""" - motif = subsequence.motif([1, 2, 3, 4]) + motif = subsequence.motif([1, 2, 3, 4]) - # Seed the module RNG only to make the two salt draws deterministic - # for the test — the two calls still draw DIFFERENT salts. - random.seed(123) + # Seed the module RNG only to make the two salt draws deterministic + # for the test — the two calls still draw DIFFERENT salts. + random.seed(123) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - first = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=None) - second = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=None) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + first = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=None) + second = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=None) - assert all("nondeterministic" in str(w.message) for w in caught) - assert len(caught) == 2 - assert _phrase_events(first) != _phrase_events(second) + assert all("nondeterministic" in str(w.message) for w in caught) + assert len(caught) == 2 + assert _phrase_events(first) != _phrase_events(second) - seeded_a = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=3) - seeded_b = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=3) + seeded_a = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=3) + seeded_b = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=3) - assert _phrase_events(seeded_a) == _phrase_events(seeded_b) + assert _phrase_events(seeded_a) == _phrase_events(seeded_b) -def test_sentence_and_reroll_share_the_salt_fix () -> None: +def test_sentence_and_reroll_share_the_salt_fix() -> None: + """sentence() and Phrase.reroll() follow the same unseeded-differs contract.""" - """sentence() and Phrase.reroll() follow the same unseeded-differs contract.""" + motif = subsequence.motif([1, 2, 3, 4]) + random.seed(123) - motif = subsequence.motif([1, 2, 3, 4]) - random.seed(123) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + s1 = subsequence.sentence(motif, bars=4, seed=None) + s2 = subsequence.sentence(motif, bars=4, seed=None) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - s1 = subsequence.sentence(motif, bars=4, seed=None) - s2 = subsequence.sentence(motif, bars=4, seed=None) + assert len(caught) == 2 + assert _phrase_events(s1) != _phrase_events(s2) - assert len(caught) == 2 - assert _phrase_events(s1) != _phrase_events(s2) + base = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=3) - base = subsequence.Phrase.develop(motif, bars=4, plan=["a", "b"], seed=3) + with warnings.catch_warnings(record=True) as caught_reroll: + warnings.simplefilter("always") + r1 = base.reroll(bar=2, seed=None) + r2 = base.reroll(bar=2, seed=None) - with warnings.catch_warnings(record=True) as caught_reroll: - warnings.simplefilter("always") - r1 = base.reroll(bar=2, seed=None) - r2 = base.reroll(bar=2, seed=None) - - assert len(caught_reroll) == 2 - assert _phrase_events(r1) != _phrase_events(r2) - assert _phrase_events(base.reroll(bar=2, seed=5)) == _phrase_events(base.reroll(bar=2, seed=5)) + assert len(caught_reroll) == 2 + assert _phrase_events(r1) != _phrase_events(r2) + assert _phrase_events(base.reroll(bar=2, seed=5)) == _phrase_events( + base.reroll(bar=2, seed=5) + ) # ── motifs: generate() with an explicit MIDI pool ────────────────────────────── @@ -309,472 +320,525 @@ def test_sentence_and_reroll_share_the_salt_fix () -> None: _MIDI_POOL: typing.List[int] = [60, 63, 65, 70] -def test_generate_midi_pool_cadence_raises () -> None: - - """cadence= names a scale degree — meaningless against a MIDI pool.""" - - with pytest.raises(ValueError, match="MIDI pool"): - subsequence.Motif.generate(rhythm=[0, 1, 2, 3], scale=_MIDI_POOL, seed=1, cadence="strong") +def test_generate_midi_pool_cadence_raises() -> None: + """cadence= names a scale degree — meaningless against a MIDI pool.""" + with pytest.raises(ValueError, match="MIDI pool"): + subsequence.Motif.generate( + rhythm=[0, 1, 2, 3], scale=_MIDI_POOL, seed=1, cadence="strong" + ) -def test_generate_midi_pool_degree_pin_raises () -> None: - """A Degree pin has no scale to read against an explicit MIDI pool.""" +def test_generate_midi_pool_degree_pin_raises() -> None: + """A Degree pin has no scale to read against an explicit MIDI pool.""" - with pytest.raises(ValueError, match="MIDI pool"): - subsequence.Motif.generate(rhythm=[0, 1, 2, 3], scale=_MIDI_POOL, seed=1, pins={-1: subsequence.Degree(5)}) + with pytest.raises(ValueError, match="MIDI pool"): + subsequence.Motif.generate( + rhythm=[0, 1, 2, 3], + scale=_MIDI_POOL, + seed=1, + pins={-1: subsequence.Degree(5)}, + ) -def test_generate_midi_pool_int_pin_is_exact_note () -> None: +def test_generate_midi_pool_int_pin_is_exact_note() -> None: + """An int pin against a MIDI pool pins that exact MIDI note.""" - """An int pin against a MIDI pool pins that exact MIDI note.""" + generated = subsequence.Motif.generate( + rhythm=[0, 1, 2, 3], scale=_MIDI_POOL, seed=1, pins={-1: 63} + ) - generated = subsequence.Motif.generate(rhythm=[0, 1, 2, 3], scale=_MIDI_POOL, seed=1, pins={-1: 63}) - - assert generated.events[-1].pitch == 63 + assert generated.events[-1].pitch == 63 # ── motifs: accent() clamps to a playable velocity ───────────────────────────── -def test_accent_negative_amount_clamps_to_playable_velocity () -> None: - """A heavy de-accent stores velocity 1, never zero or negative.""" +def test_accent_negative_amount_clamps_to_playable_velocity() -> None: + """A heavy de-accent stores velocity 1, never zero or negative.""" - accented = subsequence.motif([1, 2, 3], velocities=90).accent(0.0, amount=-200) + accented = subsequence.motif([1, 2, 3], velocities=90).accent(0.0, amount=-200) - assert [e.velocity for e in accented.events] == [1, 90, 90] + assert [e.velocity for e in accented.events] == [1, 90, 90] # ── motifs: join()/tiling no longer strip fit ────────────────────────────────── -def test_join_and_tiling_preserve_fit () -> None: - """then()-folding from empty() inherits the operand's fit instead of erasing it.""" +def test_join_and_tiling_preserve_fit() -> None: + """then()-folding from empty() inherits the operand's fit instead of erasing it.""" - generated = subsequence.Motif.generate(rhythm=[0, 1], seed=2) - assert generated.fit == 0.7 # generate() sets the chord-snapping dial + generated = subsequence.Motif.generate(rhythm=[0, 1], seed=2) + assert generated.fit == 0.7 # generate() sets the chord-snapping dial - assert subsequence.Motif.join([generated, generated]).fit == 0.7 - # m * 2 is a Phrase; flatten() folds via join()/then() — the path that used to strip fit. - assert (generated * 2).flatten().fit == 0.7 + assert subsequence.Motif.join([generated, generated]).fit == 0.7 + # m * 2 is a Phrase; flatten() folds via join()/then() — the path that used to strip fit. + assert (generated * 2).flatten().fit == 0.7 # ── composition._InjectedChord: reads never advance voice leading ────────────── -def test_injected_chord_reads_do_not_advance_voice_leading () -> None: - """root_note()/bass_note() are reads: correct pitches, state untouched.""" +def test_injected_chord_reads_do_not_advance_voice_leading() -> None: + """root_note()/bass_note() are reads: correct pitches, state untouched.""" - state = subsequence.voicings.VoiceLeadingState() + state = subsequence.voicings.VoiceLeadingState() - # Prime the state with a C-major voicing (tones() legitimately advances it). - c_chord = subsequence.composition._InjectedChord(subsequence.parse_chord("C"), state) - assert c_chord.tones(60) == [60, 64, 67] + # Prime the state with a C-major voicing (tones() legitimately advances it). + c_chord = subsequence.composition._InjectedChord( + subsequence.parse_chord("C"), state + ) + assert c_chord.tones(60) == [60, 64, 67] - primed = list(state.previous_voicing or []) + primed = list(state.previous_voicing or []) - f_chord = subsequence.composition._InjectedChord(subsequence.parse_chord("F"), state) + f_chord = subsequence.composition._InjectedChord( + subsequence.parse_chord("F"), state + ) - assert f_chord.root_note(60) == 65 - assert f_chord.bass_note(60) == 53 - assert state.previous_voicing == primed # reads left the voicing alone + assert f_chord.root_note(60) == 65 + assert f_chord.bass_note(60) == 53 + assert state.previous_voicing == primed # reads left the voicing alone # ── form_state: empty forms, graph validation, and post-finish queue_next ────── -def test_empty_form_constructs_and_never_advances () -> None: - - """FormState([], loop=True) is legal and simply stays finished.""" - - form = subsequence.form_state.FormState([], loop=True) - assert form.advance() is False - assert form.get_section_info() is None +def test_empty_form_constructs_and_never_advances() -> None: + """FormState([], loop=True) is legal and simply stays finished.""" + form = subsequence.form_state.FormState([], loop=True) -def test_graph_form_unknown_transition_target_raises_at_construction () -> None: + assert form.advance() is False + assert form.get_section_info() is None - """A typo'd transition target fails loudly at build time, naming the target.""" - with pytest.raises(ValueError, match="chorsu"): - subsequence.form_state.FormState({ - "verse": (4, [("chorsu", 1.0)]), - "chorus": (4, None), - }) +def test_graph_form_unknown_transition_target_raises_at_construction() -> None: + """A typo'd transition target fails loudly at build time, naming the target.""" + with pytest.raises(ValueError, match="chorsu"): + subsequence.form_state.FormState( + { + "verse": (4, [("chorsu", 1.0)]), + "chorus": (4, None), + } + ) -def test_sequence_form_queue_next_revives_finished_form () -> None: - """queue_next() after a sequence form finishes restarts it at the queued section.""" +def test_sequence_form_queue_next_revives_finished_form() -> None: + """queue_next() after a sequence form finishes restarts it at the queued section.""" - form = subsequence.form_state.FormState([ - subsequence.forms.Section("verse", 1), - subsequence.forms.Section("chorus", 1), - ]) + form = subsequence.form_state.FormState( + [ + subsequence.forms.Section("verse", 1), + subsequence.forms.Section("chorus", 1), + ] + ) - assert form.advance() is True # verse → chorus - assert form.advance() is True # chorus → finished - assert form.get_section_info() is None + assert form.advance() is True # verse → chorus + assert form.advance() is True # chorus → finished + assert form.get_section_info() is None - form.queue_next("verse") + form.queue_next("verse") - assert form.advance() is True - info = form.get_section_info() - assert info is not None and info.name == "verse" + assert form.advance() is True + info = form.get_section_info() + assert info is not None and info.name == "verse" -def test_graph_form_queue_next_revives_terminal_end () -> None: +def test_graph_form_queue_next_revives_terminal_end() -> None: + """queue_next() after a graph form ends at a terminal section revives it.""" - """queue_next() after a graph form ends at a terminal section revives it.""" + form = subsequence.form_state.FormState( + { + "verse": (1, [("outro", 1.0)]), + "outro": (1, None), + } + ) - form = subsequence.form_state.FormState({ - "verse": (1, [("outro", 1.0)]), - "outro": (1, None), - }) + assert form.advance() is True # verse → outro + assert form.advance() is True # outro is terminal → finished + assert form.get_section_info() is None - assert form.advance() is True # verse → outro - assert form.advance() is True # outro is terminal → finished - assert form.get_section_info() is None + form.queue_next("verse") - form.queue_next("verse") - - assert form.advance() is True - info = form.get_section_info() - assert info is not None and info.name == "verse" + assert form.advance() is True + info = form.get_section_info() + assert info is not None and info.name == "verse" # ── groove: .agr import — the tracked asset and the one-note-per-cell guard ──── -_AGR_ASSET = pathlib.Path(__file__).parent.parent / "examples" / "assets" / "Swing 16ths 57.agr" - - -def test_from_agr_tracked_asset_still_parses () -> None: +_AGR_ASSET = ( + pathlib.Path(__file__).parent.parent / "examples" / "assets" / "Swing 16ths 57.agr" +) - """The shipped Ableton groove imports: 16th grid, alternating swing offsets.""" - groove = subsequence.groove.Groove.from_agr(str(_AGR_ASSET)) +def test_from_agr_tracked_asset_still_parses() -> None: + """The shipped Ableton groove imports: 16th grid, alternating swing offsets.""" - assert groove.grid == pytest.approx(0.25) - assert len(groove.offsets) == 16 + groove = subsequence.groove.Groove.from_agr(str(_AGR_ASSET)) - for i in range(0, 16, 2): - assert groove.offsets[i] == pytest.approx(0.0, abs=1e-3) - for i in range(1, 16, 2): - assert groove.offsets[i] == pytest.approx(0.035, abs=1e-3) + assert groove.grid == pytest.approx(0.25) + assert len(groove.offsets) == 16 + for i in range(0, 16, 2): + assert groove.offsets[i] == pytest.approx(0.0, abs=1e-3) + for i in range(1, 16, 2): + assert groove.offsets[i] == pytest.approx(0.035, abs=1e-3) -def _write_agr_clip (path: pathlib.Path, notes: typing.List[typing.Tuple[float, int]], clip_length: float) -> None: - """Write a minimal .agr XML clip (the structure of the shipped asset, reduced).""" - - notes_xml = "\n".join( - f'' - for i, (time, velocity) in enumerate(notes) - ) - - path.write_text( - "\n" - '\n' - "\n" - '\n' - '\n' - f'\n' - '\n' - f"{notes_xml}\n" - "\n" - "\n" - '\n' - '\n' - "\n" - "\n" - ) - - -def test_from_agr_chord_clip_raises_grid_error (tmp_path: pathlib.Path) -> None: - - """Two notes in one grid cell (a chord) raise, with and without grid=.""" - - agr = tmp_path / "chord.agr" - _write_agr_clip(agr, [(0.0, 127), (0.25, 100), (0.25, 90), (0.75, 80)], clip_length=1.0) - - with pytest.raises(ValueError, match="grid"): - subsequence.groove.Groove.from_agr(str(agr)) - - # A chord shares a slot at ANY grid — grid= can't rescue it. - with pytest.raises(ValueError, match="grid"): - subsequence.groove.Groove.from_agr(str(agr), grid=0.25) - - -def test_from_agr_rest_imports_cleanly_with_explicit_grid (tmp_path: pathlib.Path) -> None: - - """Three notes on a 4-cell grid import when grid= is explicit; the rest stays neutral.""" +def _write_agr_clip( + path: pathlib.Path, notes: typing.List[typing.Tuple[float, int]], clip_length: float +) -> None: + """Write a minimal .agr XML clip (the structure of the shipped asset, reduced).""" + + notes_xml = "\n".join( + f'' + for i, (time, velocity) in enumerate(notes) + ) + + path.write_text( + "\n" + '\n' + "\n" + '\n' + '\n' + f'\n' + '\n' + f"{notes_xml}\n" + "\n" + "\n" + '\n' + '\n' + "\n" + "\n" + ) + + +def test_from_agr_chord_clip_raises_grid_error(tmp_path: pathlib.Path) -> None: + """Two notes in one grid cell (a chord) raise, with and without grid=.""" + + agr = tmp_path / "chord.agr" + _write_agr_clip( + agr, [(0.0, 127), (0.25, 100), (0.25, 90), (0.75, 80)], clip_length=1.0 + ) + + with pytest.raises(ValueError, match="grid"): + subsequence.groove.Groove.from_agr(str(agr)) + + # A chord shares a slot at ANY grid — grid= can't rescue it. + with pytest.raises(ValueError, match="grid"): + subsequence.groove.Groove.from_agr(str(agr), grid=0.25) + + +def test_from_agr_rest_imports_cleanly_with_explicit_grid( + tmp_path: pathlib.Path, +) -> None: + """Three notes on a 4-cell grid import when grid= is explicit; the rest stays neutral.""" - agr = tmp_path / "rest.agr" - _write_agr_clip(agr, [(0.0, 127), (0.25, 127), (0.75, 127)], clip_length=1.0) + agr = tmp_path / "rest.agr" + _write_agr_clip(agr, [(0.0, 127), (0.25, 127), (0.75, 127)], clip_length=1.0) - groove = subsequence.groove.Groove.from_agr(str(agr), grid=0.25) + groove = subsequence.groove.Groove.from_agr(str(agr), grid=0.25) - assert len(groove.offsets) == 4 - assert groove.offsets[2] == 0.0 # the empty cell keeps a neutral offset + assert len(groove.offsets) == 4 + assert groove.offsets[2] == 0.0 # the empty cell keeps a neutral offset # ── sequencer: named-drone mirroring re-resolves through each map ────────────── -@pytest.mark.asyncio -async def test_named_drone_mirrors_use_their_own_drum_maps (patch_midi: None) -> None: - - """A named drone sounds the mirror's own note; a map lacking the name stays silent.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern( - channel=0, length=4, device=0, - mirrors=[(1, 5, {"kick": 50}), (2, 6, {"snare": 40})], - ) - pattern.raw_note_events.append(subsequence.pattern.RawNoteEvent( - pulse=0, message_type="note_on", pitch=36, velocity=100, origin="kick", - )) - - await sequencer.schedule_pattern(pattern, start_pulse=0) - - note_ons = [e for e in sequencer.event_queue if e.message_type == "note_on"] - # Primary keeps its own pitch; the kick-mapped mirror re-resolves to 50; - # the snare-only mirror (device 2) gets NO event — silence, never a wrong note. - assert sorted((e.device, e.channel, e.note) for e in note_ons) == [(0, 0, 36), (1, 5, 50)] +@pytest.mark.asyncio +async def test_named_drone_mirrors_use_their_own_drum_maps(patch_midi: None) -> None: + """A named drone sounds the mirror's own note; a map lacking the name stays silent.""" + + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern( + channel=0, + length=4, + device=0, + mirrors=[(1, 5, {"kick": 50}), (2, 6, {"snare": 40})], + ) + pattern.raw_note_events.append( + subsequence.pattern.RawNoteEvent( + pulse=0, + message_type="note_on", + pitch=36, + velocity=100, + origin="kick", + ) + ) + + await sequencer.schedule_pattern(pattern, start_pulse=0) + + note_ons = [e for e in sequencer.event_queue if e.message_type == "note_on"] + + # Primary keeps its own pitch; the kick-mapped mirror re-resolves to 50; + # the snare-only mirror (device 2) gets NO event — silence, never a wrong note. + assert sorted((e.device, e.channel, e.note) for e in note_ons) == [ + (0, 0, 36), + (1, 5, 50), + ] # ── sequencer: negative-priority CC dispatches before same-pulse note_on ─────── -@pytest.mark.asyncio -async def test_onset_bend_priority_beats_note_push_order (patch_midi: None) -> None: - - """A priority=-1 CcEvent pops before a pulse-0 note_on even though notes push first.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) - pattern.add_note(position=0, pitch=60, velocity=100, duration=12) - pattern.cc_events.append(subsequence.pattern.CcEvent( - pulse=0, message_type="pitchwheel", value=1234, priority=-1, - )) +@pytest.mark.asyncio +async def test_onset_bend_priority_beats_note_push_order(patch_midi: None) -> None: + """A priority=-1 CcEvent pops before a pulse-0 note_on even though notes push first.""" - await sequencer.schedule_pattern(pattern, start_pulse=0) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) + pattern.add_note(position=0, pitch=60, velocity=100, duration=12) + pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=0, + message_type="pitchwheel", + value=1234, + priority=-1, + ) + ) - popped: typing.List[str] = [] - while sequencer.event_queue: - popped.append(heapq.heappop(sequencer.event_queue).message_type) + await sequencer.schedule_pattern(pattern, start_pulse=0) - assert popped == ["pitchwheel", "note_on", "note_off"] + popped: typing.List[str] = [] + while sequencer.event_queue: + popped.append(heapq.heappop(sequencer.event_queue).message_type) + assert popped == ["pitchwheel", "note_on", "note_off"] -def test_midi_event_priority_outranks_sequence () -> None: - """At a shared pulse, a lower priority sorts first regardless of push order.""" +def test_midi_event_priority_outranks_sequence() -> None: + """At a shared pulse, a lower priority sorts first regardless of push order.""" - early = subsequence.sequencer.MidiEvent(pulse=0, message_type="pitchwheel", channel=0, priority=-1, sequence=10) - late = subsequence.sequencer.MidiEvent(pulse=0, message_type="note_on", channel=0, priority=0, sequence=1) + early = subsequence.sequencer.MidiEvent( + pulse=0, message_type="pitchwheel", channel=0, priority=-1, sequence=10 + ) + late = subsequence.sequencer.MidiEvent( + pulse=0, message_type="note_on", channel=0, priority=0, sequence=1 + ) - assert early < late + assert early < late # ── sequencer: unregister note_offs ride latency compensation ────────────────── -@pytest.mark.asyncio -async def test_stop_pattern_notes_defers_note_off_on_fast_device (patch_midi: None) -> None: - """_stop_pattern_notes routes through compensation — a fast device's note_off is deferred.""" +@pytest.mark.asyncio +async def test_stop_pattern_notes_defers_note_off_on_fast_device( + patch_midi: None, +) -> None: + """_stop_pattern_notes routes through compensation — a fast device's note_off is deferred.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - sequencer._event_loop = asyncio.get_running_loop() + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + sequencer._event_loop = asyncio.get_running_loop() - spy = conftest.SpyMidiOut() - sequencer.midi_out = spy - sequencer.set_device_latency(0, 0) - # A very slow second device gives device 0 a 5s offset, so the deferred - # note_off cannot fire during the test. - sequencer.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=5000) + spy = conftest.SpyMidiOut() + sequencer.midi_out = spy + sequencer.set_device_latency(0, 0) + # A very slow second device gives device 0 a 5s offset, so the deferred + # note_off cannot fire during the test. + sequencer.add_output_device("slow", conftest.SpyMidiOut(), latency_ms=5000) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) - sequencer.active_notes.add((0, 0, 60)) + pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) + sequencer.active_notes.add((0, 0, 60)) - await sequencer._stop_pattern_notes(pattern) + await sequencer._stop_pattern_notes(pattern) - assert not any(m.type == "note_off" for m in spy.sent) # not sent immediately - assert len(sequencer._pending_sends) == 1 # deferred instead - assert (0, 0, 60) not in sequencer.active_notes + assert not any(m.type == "note_off" for m in spy.sent) # not sent immediately + assert len(sequencer._pending_sends) == 1 # deferred instead + assert (0, 0, 60) not in sequencer.active_notes - sequencer._cancel_pending_sends() + sequencer._cancel_pending_sends() # ── sequencer: Link tempo changes land in the recording ──────────────────────── -class _FakeLinkClock: - - """Minimal Link session stand-in: one tempo, instant syncs.""" - tempo: float = 100.0 - num_peers: int = 0 - - async def wait_for_bar (self) -> float: +class _FakeLinkClock: + """Minimal Link session stand-in: one tempo, instant syncs.""" - """Pretend the bar boundary is now, at beat 0.""" + tempo: float = 100.0 + num_peers: int = 0 - return 0.0 + async def wait_for_bar(self) -> float: + """Pretend the bar boundary is now, at beat 0.""" - async def sync (self, beat: float) -> None: + return 0.0 - """Never block — the loop stops itself when the queue is empty.""" + async def sync(self, beat: float) -> None: + """Never block — the loop stops itself when the queue is empty.""" - return None + return None @pytest.mark.asyncio -async def test_link_tempo_change_recorded_as_set_tempo (patch_midi: None) -> None: - - """The Link loop records a set_tempo meta event when the session tempo differs.""" +async def test_link_tempo_change_recorded_as_set_tempo(patch_midi: None) -> None: + """The Link loop records a set_tempo meta event when the session tempo differs.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - sequencer.recording = True - sequencer.running = True + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + sequencer.recording = True + sequencer.running = True - await sequencer._run_loop_link_clock(_FakeLinkClock(), pulses_per_bar=96) + await sequencer._run_loop_link_clock(_FakeLinkClock(), pulses_per_bar=96) - tempos = [message for _, message in sequencer.recorded_events if message.type == "set_tempo"] + tempos = [ + message + for _, message in sequencer.recorded_events + if message.type == "set_tempo" + ] - assert len(tempos) == 1 - assert tempos[0].tempo == 600000 # mido.bpm2tempo(100) - assert sequencer.current_bpm == pytest.approx(100.0) + assert len(tempos) == 1 + assert tempos[0].tempo == 600000 # mido.bpm2tempo(100) + assert sequencer.current_bpm == pytest.approx(100.0) # ── web_ui: the dashboard reports the live tempo ─────────────────────────────── -def test_web_ui_state_reports_live_bpm (patch_midi: None) -> None: - """_get_state() reads the sequencer's current_bpm, not the declared comp.bpm.""" +def test_web_ui_state_reports_live_bpm(patch_midi: None) -> None: + """_get_state() reads the sequencer's current_bpm, not the declared comp.bpm.""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - ui = subsequence.web_ui.WebUI(comp) + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + ui = subsequence.web_ui.WebUI(comp) - comp._sequencer.current_bpm = 133.5 # a live tempo change + comp._sequencer.current_bpm = 133.5 # a live tempo change - assert ui._get_state(comp)["bpm"] == 133.5 + assert ui._get_state(comp)["bpm"] == 133.5 # ── midi_utils: headless multi-output selection fails usably ─────────────────── -def test_select_output_device_headless_multi_output_fails_usably ( - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - """EOF on the device prompt ends the call instead of spinning forever. +def test_select_output_device_headless_multi_output_fails_usably( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """EOF on the device prompt ends the call instead of spinning forever. - The prompt loop raises RuntimeError naming output_device=; the function's - outer error handler converts that into its documented (None, None) failure - return, logging the usable message. This test pins both halves: no hang, - and the actionable "pass output_device=" text reaching the log. - """ + The prompt loop raises RuntimeError naming output_device=; the function's + outer error handler converts that into its documented (None, None) failure + return, logging the usable message. This test pins both halves: no hang, + and the actionable "pass output_device=" text reaching the log. + """ - import mido + import mido - monkeypatch.setattr(mido, "get_output_names", lambda: ["Synth A", "Synth B"]) + monkeypatch.setattr(mido, "get_output_names", lambda: ["Synth A", "Synth B"]) - def _eof (prompt: str = "") -> str: - raise EOFError + def _eof(prompt: str = "") -> str: + raise EOFError - monkeypatch.setattr("builtins.input", _eof) + monkeypatch.setattr("builtins.input", _eof) - with caplog.at_level(logging.ERROR, logger="subsequence.midi_utils"): - result = subsequence.midi_utils.select_output_device(None) + with caplog.at_level(logging.ERROR, logger="subsequence.midi_utils"): + result = subsequence.midi_utils.select_output_device(None) - assert result == (None, None) - assert any("output_device" in record.getMessage() for record in caplog.records) + assert result == (None, None) + assert any("output_device" in record.getMessage() for record in caplog.records) # ── harmonic clock: callable without horizon and bar_beats ───────────────────── -def test_schedule_harmonic_clock_defaults_horizon_and_bar_beats () -> None: - """The clock binds with just a sequencer, a state getter, and cycle_beats.""" +def test_schedule_harmonic_clock_defaults_horizon_and_bar_beats() -> None: + """The clock binds with just a sequencer, a state getter, and cycle_beats.""" - signature = inspect.signature(subsequence.composition.schedule_harmonic_clock) + signature = inspect.signature(subsequence.composition.schedule_harmonic_clock) - bound = signature.bind("seq", lambda: None, cycle_beats=4) + bound = signature.bind("seq", lambda: None, cycle_beats=4) - assert "horizon" not in bound.arguments - assert "bar_beats" not in bound.arguments + assert "horizon" not in bound.arguments + assert "bar_beats" not in bound.arguments # ── harmonic clock: schedule_form advances the CURRENT form after a swap ─────── -@pytest.mark.asyncio -async def test_schedule_form_advances_swapped_form_state () -> None: - """get_form_state= is re-read per bar: after a swap, the NEW form advances.""" +@pytest.mark.asyncio +async def test_schedule_form_advances_swapped_form_state() -> None: + """get_form_state= is re-read per bar: after a swap, the NEW form advances.""" - form_a = subsequence.form_state.FormState([("verse", 4)]) - form_b = subsequence.form_state.FormState([("bridge", 4)]) - holder: typing.Dict[str, subsequence.form_state.FormState] = {"fs": form_a} + form_a = subsequence.form_state.FormState([("verse", 4)]) + form_b = subsequence.form_state.FormState([("bridge", 4)]) + holder: typing.Dict[str, subsequence.form_state.FormState] = {"fs": form_a} - captured: typing.Dict[str, typing.Any] = {} + captured: typing.Dict[str, typing.Any] = {} - mock_seq = unittest.mock.MagicMock() - mock_seq.pulses_per_beat = 24 - mock_seq.time_signature = (4, 4) - mock_seq.events = subsequence.event_emitter.EventEmitter() + mock_seq = unittest.mock.MagicMock() + mock_seq.pulses_per_beat = 24 + mock_seq.time_signature = (4, 4) + mock_seq.events = subsequence.event_emitter.EventEmitter() - async def capture (callback: typing.Callable, interval_beats: float, start_pulse: int = 0, reschedule_lookahead: float = 1) -> None: - captured["callback"] = callback + async def capture( + callback: typing.Callable, + interval_beats: float, + start_pulse: int = 0, + reschedule_lookahead: float = 1, + ) -> None: + captured["callback"] = callback - mock_seq.schedule_callback_repeating = capture + mock_seq.schedule_callback_repeating = capture - await subsequence.composition.schedule_form( - sequencer=mock_seq, - form_state=form_a, - reschedule_lookahead=1, - get_form_state=lambda: holder["fs"], - ) + await subsequence.composition.schedule_form( + sequencer=mock_seq, + form_state=form_a, + reschedule_lookahead=1, + get_form_state=lambda: holder["fs"], + ) - advance = captured["callback"] + advance = captured["callback"] - advance(72) # bar 1 boundary — form A is current - assert (form_a.total_bars, form_b.total_bars) == (1, 0) + advance(72) # bar 1 boundary — form A is current + assert (form_a.total_bars, form_b.total_bars) == (1, 0) - holder["fs"] = form_b # a mid-playback form() re-bind + holder["fs"] = form_b # a mid-playback form() re-bind - advance(168) # bar 2 boundary — form B must advance, A must not - assert (form_a.total_bars, form_b.total_bars) == (1, 1) + advance(168) # bar 2 boundary — form B must advance, A must not + assert (form_a.total_bars, form_b.total_bars) == (1, 1) # ── harmonic clock: _start_harmonic_clock is idempotent per playback ─────────── -def test_start_harmonic_clock_is_idempotent (patch_midi: None, monkeypatch: pytest.MonkeyPatch) -> None: - - """A second _start_harmonic_clock() call registers nothing new.""" - calls: typing.List[typing.Dict[str, typing.Any]] = [] +def test_start_harmonic_clock_is_idempotent( + patch_midi: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A second _start_harmonic_clock() call registers nothing new.""" - async def fake_clock (**kwargs: typing.Any) -> None: - calls.append(kwargs) + calls: typing.List[typing.Dict[str, typing.Any]] = [] - monkeypatch.setattr(subsequence.composition, "schedule_harmonic_clock", fake_clock) + async def fake_clock(**kwargs: typing.Any) -> None: + calls.append(kwargs) - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + monkeypatch.setattr(subsequence.composition, "schedule_harmonic_clock", fake_clock) - asyncio.run(comp._start_harmonic_clock()) - assert comp._harmonic_clock_started is True - assert len(calls) == 1 + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - asyncio.run(comp._start_harmonic_clock()) - assert len(calls) == 1 # the second call returned without rescheduling + asyncio.run(comp._start_harmonic_clock()) + assert comp._harmonic_clock_started is True + assert len(calls) == 1 + asyncio.run(comp._start_harmonic_clock()) + assert len(calls) == 1 # the second call returned without rescheduling -def test_harmony_before_play_does_not_start_clock (patch_midi: None) -> None: - """harmony() before play() leaves clock startup to _run() (no loop yet).""" +def test_harmony_before_play_does_not_start_clock(patch_midi: None) -> None: + """harmony() before play() leaves clock startup to _run() (no loop yet).""" - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - comp.harmony(progression=subsequence.progression(["Am", "F", "C", "G"])) + comp.harmony(progression=subsequence.progression(["Am", "F", "C", "G"])) - assert comp._harmonic_clock_started is False + assert comp._harmonic_clock_started is False diff --git a/tests/test_schedule_context.py b/tests/test_schedule_context.py index 79fba2e..7b84d34 100644 --- a/tests/test_schedule_context.py +++ b/tests/test_schedule_context.py @@ -6,140 +6,140 @@ import subsequence.composition -async def _wait_for (condition: typing.Callable[[], bool], deadline: float = 2.0, poll: float = 0.01) -> None: +async def _wait_for( + condition: typing.Callable[[], bool], deadline: float = 2.0, poll: float = 0.01 +) -> None: + """Poll until `condition()` is true or the deadline passes. - """Poll until `condition()` is true or the deadline passes. + Sync callbacks complete on executor threads, so a fixed number of + zero-sleeps can flake under load — polling a condition is deterministic. + """ - Sync callbacks complete on executor threads, so a fixed number of - zero-sleeps can flake under load — polling a condition is deterministic. - """ + loop = asyncio.get_running_loop() + end = loop.time() + deadline - loop = asyncio.get_running_loop() - end = loop.time() + deadline + while not condition(): + if loop.time() >= end: + return - while not condition(): - if loop.time() >= end: - return - - await asyncio.sleep(poll) + await asyncio.sleep(poll) # --------------------------------------------------------------------------- # Unit tests for _make_safe_callback directly (synchronous wrappers) # --------------------------------------------------------------------------- -def test_schedule_context_dataclass () -> None: - - """ScheduleContext is a plain dataclass with a cycle field.""" - - ctx = subsequence.composition.ScheduleContext(cycle=3) - assert ctx.cycle == 3 - - -def test_make_safe_callback_no_context_zero_args () -> None: - - """A zero-arg callback is wrapped and actually runs when the wrapper fires.""" - - called: list = [] - - def my_task () -> None: - called.append(True) - wrapped = subsequence.composition._make_safe_callback(my_task, accepts_context=False) +def test_schedule_context_dataclass() -> None: + """ScheduleContext is a plain dataclass with a cycle field.""" - async def run () -> None: - wrapped(0) - await _wait_for(lambda: len(called) == 1) + ctx = subsequence.composition.ScheduleContext(cycle=3) + assert ctx.cycle == 3 - asyncio.run(run()) - assert called == [True] +def test_make_safe_callback_no_context_zero_args() -> None: + """A zero-arg callback is wrapped and actually runs when the wrapper fires.""" + called: list = [] -def test_make_safe_callback_context_increments () -> None: + def my_task() -> None: + called.append(True) - """Each _execute() call should increment cycle_count inside the closure.""" + wrapped = subsequence.composition._make_safe_callback( + my_task, accepts_context=False + ) - received: list = [] + async def run() -> None: + wrapped(0) + await _wait_for(lambda: len(called) == 1) - def my_task (p: subsequence.composition.ScheduleContext) -> None: - received.append(p.cycle) + asyncio.run(run()) - wrapped = subsequence.composition._make_safe_callback(my_task, accepts_context=True) + assert called == [True] - async def run () -> None: - # Fire all three calls first, then wait for every spawned task to finish. - for _ in range(3): - wrapped(0) +def test_make_safe_callback_context_increments() -> None: + """Each _execute() call should increment cycle_count inside the closure.""" - await _wait_for(lambda: len(received) == 3) + received: list = [] - asyncio.run(run()) + def my_task(p: subsequence.composition.ScheduleContext) -> None: + received.append(p.cycle) - # Sync callbacks run via loop.run_in_executor (a thread pool), so completion - # order across the three fire-and-forget tasks is not guaranteed — under load - # they can finish out of order. The real invariant is that each trigger ran - # exactly once with its own captured cycle, i.e. the multiset {0, 1, 2}. - assert sorted(received) == [0, 1, 2] + wrapped = subsequence.composition._make_safe_callback(my_task, accepts_context=True) + async def run() -> None: + # Fire all three calls first, then wait for every spawned task to finish. + for _ in range(3): + wrapped(0) -def test_make_safe_callback_start_cycle_offset () -> None: + await _wait_for(lambda: len(received) == 3) - """start_cycle seeds the cycle counter so cycles stay monotonic after a pre-roll. + asyncio.run(run()) - Regression: a wait_for_initial task runs once as cycle 0 (blocking pre-roll), - then its repeating wrapper must start at cycle 1 — previously it restarted at 0, - yielding a non-monotonic 0, 0, 1, 2… contradicting ScheduleContext's docstring. - """ + # Sync callbacks run via loop.run_in_executor (a thread pool), so completion + # order across the three fire-and-forget tasks is not guaranteed — under load + # they can finish out of order. The real invariant is that each trigger ran + # exactly once with its own captured cycle, i.e. the multiset {0, 1, 2}. + assert sorted(received) == [0, 1, 2] - received: list = [] - def my_task (p: subsequence.composition.ScheduleContext) -> None: - received.append(p.cycle) +def test_make_safe_callback_start_cycle_offset() -> None: + """start_cycle seeds the cycle counter so cycles stay monotonic after a pre-roll. - wrapped = subsequence.composition._make_safe_callback(my_task, accepts_context=True, start_cycle=1) + Regression: a wait_for_initial task runs once as cycle 0 (blocking pre-roll), + then its repeating wrapper must start at cycle 1 — previously it restarted at 0, + yielding a non-monotonic 0, 0, 1, 2… contradicting ScheduleContext's docstring. + """ - async def run () -> None: + received: list = [] - for _ in range(3): - wrapped(0) + def my_task(p: subsequence.composition.ScheduleContext) -> None: + received.append(p.cycle) - await _wait_for(lambda: len(received) == 3) + wrapped = subsequence.composition._make_safe_callback( + my_task, accepts_context=True, start_cycle=1 + ) - asyncio.run(run()) + async def run() -> None: + for _ in range(3): + wrapped(0) - assert sorted(received) == [1, 2, 3] + await _wait_for(lambda: len(received) == 3) + asyncio.run(run()) -def test_make_safe_callback_no_context_flag () -> None: + assert sorted(received) == [1, 2, 3] - """When accepts_context=False the function is called with zero args.""" - received: list = [] +def test_make_safe_callback_no_context_flag() -> None: + """When accepts_context=False the function is called with zero args.""" - def my_task () -> None: - received.append("called") + received: list = [] - wrapped = subsequence.composition._make_safe_callback(my_task, accepts_context=False) + def my_task() -> None: + received.append("called") - async def run () -> None: - wrapped(0) - await _wait_for(lambda: len(received) == 1) + wrapped = subsequence.composition._make_safe_callback( + my_task, accepts_context=False + ) - asyncio.run(run()) + async def run() -> None: + wrapped(0) + await _wait_for(lambda: len(received) == 1) - assert received == ["called"] + asyncio.run(run()) + assert received == ["called"] -def test_fn_has_parameter_detects_p () -> None: - """_fn_has_parameter correctly identifies 'p' in the signature.""" +def test_fn_has_parameter_detects_p() -> None: + """_fn_has_parameter correctly identifies 'p' in the signature.""" - def with_p (p) -> None: ... # noqa: E704 - def without_p () -> None: ... # noqa: E704 - def wrong_name (ctx) -> None: ... # noqa: E704 + def with_p(p) -> None: ... # noqa: E704 + def without_p() -> None: ... # noqa: E704 + def wrong_name(ctx) -> None: ... # noqa: E704 - assert subsequence.composition._fn_has_parameter(with_p, "p") is True - assert subsequence.composition._fn_has_parameter(without_p, "p") is False - assert subsequence.composition._fn_has_parameter(wrong_name, "p") is False + assert subsequence.composition._fn_has_parameter(with_p, "p") is True + assert subsequence.composition._fn_has_parameter(without_p, "p") is False + assert subsequence.composition._fn_has_parameter(wrong_name, "p") is False diff --git a/tests/test_scheduled_tasks.py b/tests/test_scheduled_tasks.py index 9e9ba8c..bda24c9 100644 --- a/tests/test_scheduled_tasks.py +++ b/tests/test_scheduled_tasks.py @@ -11,245 +11,258 @@ import subsequence.sequencer -def test_sequencer_data_store_exists (patch_midi: None) -> None: +def test_sequencer_data_store_exists(patch_midi: None) -> None: + """Sequencer should have an empty data dict on creation.""" - """Sequencer should have an empty data dict on creation.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - - assert isinstance(seq.data, dict) - assert len(seq.data) == 0 + assert isinstance(seq.data, dict) + assert len(seq.data) == 0 @pytest.mark.asyncio -async def test_safe_callback_catches_exceptions (caplog: pytest.LogCaptureFixture) -> None: - - """A failing safe callback should log a warning naming the task instead of raising.""" +async def test_safe_callback_catches_exceptions( + caplog: pytest.LogCaptureFixture, +) -> None: + """A failing safe callback should log a warning naming the task instead of raising.""" - def bad_fn () -> None: - raise RuntimeError("boom") + def bad_fn() -> None: + raise RuntimeError("boom") - wrapped = subsequence.composition._make_safe_callback(bad_fn) + wrapped = subsequence.composition._make_safe_callback(bad_fn) - # wrapper is sync, fires a background task. - with caplog.at_level(logging.WARNING, logger="subsequence.composition"): - wrapped(0) + # wrapper is sync, fires a background task. + with caplog.at_level(logging.WARNING, logger="subsequence.composition"): + wrapped(0) - # Poll until the background task has run and logged (executor thread + event loop). - for _ in range(200): - if any("Scheduled task 'bad_fn' failed: boom" in r.message for r in caplog.records): - break + # Poll until the background task has run and logged (executor thread + event loop). + for _ in range(200): + if any( + "Scheduled task 'bad_fn' failed: boom" in r.message + for r in caplog.records + ): + break - await asyncio.sleep(0.01) + await asyncio.sleep(0.01) - warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] - assert any("Scheduled task 'bad_fn' failed: boom" in r.message for r in warnings) + assert any("Scheduled task 'bad_fn' failed: boom" in r.message for r in warnings) @pytest.mark.asyncio -async def test_safe_callback_runs_sync_in_executor () -> None: - - """Sync functions wrapped by _make_safe_callback should run in a thread pool.""" +async def test_safe_callback_runs_sync_in_executor() -> None: + """Sync functions wrapped by _make_safe_callback should run in a thread pool.""" - thread_names: typing.List[str] = [] + thread_names: typing.List[str] = [] - def sync_fn () -> None: - thread_names.append(threading.current_thread().name) + def sync_fn() -> None: + thread_names.append(threading.current_thread().name) - wrapped = subsequence.composition._make_safe_callback(sync_fn) - wrapped(0) + wrapped = subsequence.composition._make_safe_callback(sync_fn) + wrapped(0) - # Yield control so the executor thread completes. - await asyncio.sleep(0.05) + # Yield control so the executor thread completes. + await asyncio.sleep(0.05) - assert len(thread_names) == 1 - # The main asyncio thread is typically "MainThread"; executor threads are not. - assert thread_names[0] != "MainThread" + assert len(thread_names) == 1 + # The main asyncio thread is typically "MainThread"; executor threads are not. + assert thread_names[0] != "MainThread" @pytest.mark.asyncio -async def test_safe_callback_runs_async_directly () -> None: +async def test_safe_callback_runs_async_directly() -> None: + """Async functions wrapped by _make_safe_callback should run on the event loop.""" - """Async functions wrapped by _make_safe_callback should run on the event loop.""" + thread_names: typing.List[str] = [] - thread_names: typing.List[str] = [] + async def async_fn() -> None: + thread_names.append(threading.current_thread().name) - async def async_fn () -> None: - thread_names.append(threading.current_thread().name) + wrapped = subsequence.composition._make_safe_callback(async_fn) + wrapped(0) - wrapped = subsequence.composition._make_safe_callback(async_fn) - wrapped(0) + # Yield control so the background task runs. + await asyncio.sleep(0) - # Yield control so the background task runs. - await asyncio.sleep(0) - - assert len(thread_names) == 1 - assert thread_names[0] == "MainThread" + assert len(thread_names) == 1 + assert thread_names[0] == "MainThread" @pytest.mark.asyncio -async def test_safe_callback_does_not_block () -> None: +async def test_safe_callback_does_not_block() -> None: + """The wrapper should return immediately without blocking the caller.""" - """The wrapper should return immediately without blocking the caller.""" + completed = [] - completed = [] + def slow_fn() -> None: + import time - def slow_fn () -> None: - import time - time.sleep(0.1) - completed.append(True) + time.sleep(0.1) + completed.append(True) - wrapped = subsequence.composition._make_safe_callback(slow_fn) + wrapped = subsequence.composition._make_safe_callback(slow_fn) - # Call and immediately check - should not have run yet. - wrapped(0) - assert len(completed) == 0 + # Call and immediately check - should not have run yet. + wrapped(0) + assert len(completed) == 0 - # Now wait for it to finish in the background. - await asyncio.sleep(0.2) - assert len(completed) == 1 + # Now wait for it to finish in the background. + await asyncio.sleep(0.2) + assert len(completed) == 1 @pytest.mark.asyncio -async def test_schedule_task_registers_callback (patch_midi: None) -> None: - - """schedule_task should register a callback on the sequencer.""" +async def test_schedule_task_registers_callback(patch_midi: None) -> None: + """schedule_task should register a callback on the sequencer.""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - def my_task () -> None: - pass + def my_task() -> None: + pass - await subsequence.composition.schedule_task(sequencer=seq, fn=my_task, cycle_beats=8) + await subsequence.composition.schedule_task( + sequencer=seq, fn=my_task, cycle_beats=8 + ) - assert len(seq.callback_queue) == 1 + assert len(seq.callback_queue) == 1 @pytest.mark.asyncio -async def test_schedule_task_defer_skips_pulse_zero (patch_midi: None) -> None: - - """schedule_task(defer=True) should set start_pulse to one full cycle.""" +async def test_schedule_task_defer_skips_pulse_zero(patch_midi: None) -> None: + """schedule_task(defer=True) should set start_pulse to one full cycle.""" - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - def my_task () -> None: - pass + def my_task() -> None: + pass - await subsequence.composition.schedule_task(sequencer=seq, fn=my_task, cycle_beats=8, defer=True) + await subsequence.composition.schedule_task( + sequencer=seq, fn=my_task, cycle_beats=8, defer=True + ) - assert len(seq.callback_queue) == 1 + assert len(seq.callback_queue) == 1 - # With defer, start_pulse = 8 beats * 24 ppq = 192. - # Backshift subtracts lookahead: next_fire = 192 - (1 * 24) = 168. - _, _, scheduled = seq.callback_queue[0] - lookahead_pulses = int(1 * seq.pulses_per_beat) - expected_fire = int(8 * seq.pulses_per_beat) - lookahead_pulses + # With defer, start_pulse = 8 beats * 24 ppq = 192. + # Backshift subtracts lookahead: next_fire = 192 - (1 * 24) = 168. + _, _, scheduled = seq.callback_queue[0] + lookahead_pulses = int(1 * seq.pulses_per_beat) + expected_fire = int(8 * seq.pulses_per_beat) - lookahead_pulses - assert scheduled.next_fire_pulse == expected_fire + assert scheduled.next_fire_pulse == expected_fire @pytest.mark.asyncio -async def test_schedule_task_no_defer_fires_at_zero (patch_midi: None) -> None: +async def test_schedule_task_no_defer_fires_at_zero(patch_midi: None) -> None: + """schedule_task without defer should backshift to fire at pulse 0.""" - """schedule_task without defer should backshift to fire at pulse 0.""" + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + def my_task() -> None: + pass - def my_task () -> None: - pass + await subsequence.composition.schedule_task( + sequencer=seq, fn=my_task, cycle_beats=8 + ) - await subsequence.composition.schedule_task(sequencer=seq, fn=my_task, cycle_beats=8) + _, _, scheduled = seq.callback_queue[0] - _, _, scheduled = seq.callback_queue[0] + # Without defer, backshift puts the first fire at pulse 0 (or close to it). + # The next_fire_pulse should be less than one full cycle. + one_cycle = int(8 * seq.pulses_per_beat) - # Without defer, backshift puts the first fire at pulse 0 (or close to it). - # The next_fire_pulse should be less than one full cycle. - one_cycle = int(8 * seq.pulses_per_beat) + assert scheduled.next_fire_pulse < one_cycle - assert scheduled.next_fire_pulse < one_cycle +def test_initial_runs_before_first_pattern_build( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """End-to-end: schedule(wait_for_initial=True) completes before the first pattern build, so the data it writes is visible to patterns from bar 1.""" -def test_initial_runs_before_first_pattern_build (tmp_path: pathlib.Path, patch_midi: None) -> None: - - """End-to-end: schedule(wait_for_initial=True) completes before the first pattern build, so the data it writes is visible to patterns from bar 1.""" - - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=960) - first_build_saw_sentinel: typing.List[bool] = [] - - def populate () -> None: - composition.data["sentinel"] = "ready" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=960) + first_build_saw_sentinel: typing.List[bool] = [] - composition.schedule(populate, cycle_beats=4, wait_for_initial=True) + def populate() -> None: + composition.data["sentinel"] = "ready" - @composition.pattern(channel=1, beats=4) - def p (p: typing.Any) -> None: - if not first_build_saw_sentinel: - first_build_saw_sentinel.append(composition.data.get("sentinel") == "ready") + composition.schedule(populate, cycle_beats=4, wait_for_initial=True) - composition.render(bars=1, filename=str(tmp_path / "initial.mid")) + @composition.pattern(channel=1, beats=4) + def p(p: typing.Any) -> None: + if not first_build_saw_sentinel: + first_build_saw_sentinel.append(composition.data.get("sentinel") == "ready") - assert first_build_saw_sentinel, "the pattern never built" - assert first_build_saw_sentinel[0] is True + composition.render(bars=1, filename=str(tmp_path / "initial.mid")) + assert first_build_saw_sentinel, "the pattern never built" + assert first_build_saw_sentinel[0] is True -def test_initial_runs_async_fn_before_patterns (tmp_path: pathlib.Path, patch_midi: None) -> None: - """wait_for_initial=True blocks on an ASYNC function through the real _run() path. +def test_initial_runs_async_fn_before_patterns( + tmp_path: pathlib.Path, patch_midi: None +) -> None: + """wait_for_initial=True blocks on an ASYNC function through the real _run() path. - The coroutine branch of _run()'s wait-for-initial block: the async - populate must complete before the first pattern build reads the data. - """ + The coroutine branch of _run()'s wait-for-initial block: the async + populate must complete before the first pattern build reads the data. + """ - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=960) - first_build_saw_sentinel: typing.List[bool] = [] + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=960) + first_build_saw_sentinel: typing.List[bool] = [] - async def populate () -> None: - composition.data["sentinel"] = "ready" + async def populate() -> None: + composition.data["sentinel"] = "ready" - composition.schedule(populate, cycle_beats=4, wait_for_initial=True) + composition.schedule(populate, cycle_beats=4, wait_for_initial=True) - @composition.pattern(channel=1, beats=4) - def p (p: typing.Any) -> None: - if not first_build_saw_sentinel: - first_build_saw_sentinel.append(composition.data.get("sentinel") == "ready") + @composition.pattern(channel=1, beats=4) + def p(p: typing.Any) -> None: + if not first_build_saw_sentinel: + first_build_saw_sentinel.append(composition.data.get("sentinel") == "ready") - composition.render(bars=1, filename=str(tmp_path / "initial_async.mid")) + composition.render(bars=1, filename=str(tmp_path / "initial_async.mid")) - assert first_build_saw_sentinel, "the pattern never built" - assert first_build_saw_sentinel[0] is True + assert first_build_saw_sentinel, "the pattern never built" + assert first_build_saw_sentinel[0] is True -def test_initial_failure_does_not_raise ( - tmp_path: pathlib.Path, - patch_midi: None, - caplog: pytest.LogCaptureFixture, +def test_initial_failure_does_not_raise( + tmp_path: pathlib.Path, + patch_midi: None, + caplog: pytest.LogCaptureFixture, ) -> None: + """A failing wait_for_initial function must not crash play — _run() logs a warning. - """A failing wait_for_initial function must not crash play — _run() logs a warning. - - Exercises the real try/except in _run()'s wait-for-initial block: the - run completes (the pattern still builds) and the failure is warned with - the function's name. - """ + Exercises the real try/except in _run()'s wait-for-initial block: the + run completes (the pattern still builds) and the failure is warned with + the function's name. + """ - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=960) - built: typing.List[bool] = [] + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=960) + built: typing.List[bool] = [] - def bad_fn () -> None: - raise RuntimeError("network error") + def bad_fn() -> None: + raise RuntimeError("network error") - composition.schedule(bad_fn, cycle_beats=4, wait_for_initial=True) + composition.schedule(bad_fn, cycle_beats=4, wait_for_initial=True) - @composition.pattern(channel=1, beats=4) - def p (p: typing.Any) -> None: - built.append(True) + @composition.pattern(channel=1, beats=4) + def p(p: typing.Any) -> None: + built.append(True) - with caplog.at_level(logging.WARNING, logger="subsequence.composition"): - composition.render(bars=1, filename=str(tmp_path / "initial_fail.mid")) + with caplog.at_level(logging.WARNING, logger="subsequence.composition"): + composition.render(bars=1, filename=str(tmp_path / "initial_fail.mid")) - assert built, "the failing initial fn must not stop patterns from building" - assert any( - "bad_fn" in record.getMessage() and "failed" in record.getMessage() - for record in caplog.records - ) + assert built, "the failing initial fn must not stop patterns from building" + assert any( + "bad_fn" in record.getMessage() and "failed" in record.getMessage() + for record in caplog.records + ) diff --git a/tests/test_sequence_utils.py b/tests/test_sequence_utils.py index 6945b9d..bcaf239 100644 --- a/tests/test_sequence_utils.py +++ b/tests/test_sequence_utils.py @@ -4,1889 +4,1910 @@ import subsequence.sequence_utils -def test_sequence_to_indices_basic () -> None: +def test_sequence_to_indices_basic() -> None: + """Extract indices from a binary sequence with hits at known positions.""" - """Extract indices from a binary sequence with hits at known positions.""" + sequence = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0] - sequence = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0] + assert subsequence.sequence_utils.sequence_to_indices(sequence) == [0, 4, 8, 12] - assert subsequence.sequence_utils.sequence_to_indices(sequence) == [0, 4, 8, 12] +def test_sequence_to_indices_empty() -> None: + """An all-zero sequence should return an empty list.""" -def test_sequence_to_indices_empty () -> None: + assert subsequence.sequence_utils.sequence_to_indices([0, 0, 0, 0]) == [] - """An all-zero sequence should return an empty list.""" - assert subsequence.sequence_utils.sequence_to_indices([0, 0, 0, 0]) == [] +def test_sequence_to_indices_all_hits() -> None: + """An all-ones sequence should return every index.""" + assert subsequence.sequence_utils.sequence_to_indices([1, 1, 1]) == [0, 1, 2] -def test_sequence_to_indices_all_hits () -> None: - """An all-ones sequence should return every index.""" +def test_rotate_no_wraparound() -> None: + """Rolling indices that stay within bounds should shift correctly.""" - assert subsequence.sequence_utils.sequence_to_indices([1, 1, 1]) == [0, 1, 2] + assert subsequence.sequence_utils.rotate([0, 8], 4, 16) == [4, 12] -def test_rotate_no_wraparound () -> None: +def test_rotate_with_wraparound() -> None: + """Indices that exceed length should wrap to the beginning.""" - """Rolling indices that stay within bounds should shift correctly.""" + assert subsequence.sequence_utils.rotate([12, 14], 4, 16) == [0, 2] - assert subsequence.sequence_utils.rotate([0, 8], 4, 16) == [4, 12] +def test_rotate_negative_shift() -> None: + """A negative shift should move indices backward with wraparound.""" -def test_rotate_with_wraparound () -> None: + assert subsequence.sequence_utils.rotate([4, 12], -4, 16) == [0, 8] - """Indices that exceed length should wrap to the beginning.""" - assert subsequence.sequence_utils.rotate([12, 14], 4, 16) == [0, 2] +def test_rotate_empty_list() -> None: + """Rolling an empty list should return an empty list.""" + assert subsequence.sequence_utils.rotate([], 4, 16) == [] -def test_rotate_negative_shift () -> None: - """A negative shift should move indices backward with wraparound.""" +def test_rotate_zero_shift() -> None: + """A zero shift should return the original indices.""" - assert subsequence.sequence_utils.rotate([4, 12], -4, 16) == [0, 8] + assert subsequence.sequence_utils.rotate([0, 4, 8, 12], 0, 16) == [0, 4, 8, 12] -def test_rotate_empty_list () -> None: +def test_rotate_full_cycle() -> None: + """Rolling by the full length should return the original indices.""" - """Rolling an empty list should return an empty list.""" - - assert subsequence.sequence_utils.rotate([], 4, 16) == [] - - -def test_rotate_zero_shift () -> None: - - """A zero shift should return the original indices.""" - - assert subsequence.sequence_utils.rotate([0, 4, 8, 12], 0, 16) == [0, 4, 8, 12] - - -def test_rotate_full_cycle () -> None: - - """Rolling by the full length should return the original indices.""" - - assert subsequence.sequence_utils.rotate([0, 4, 8, 12], 16, 16) == [0, 4, 8, 12] + assert subsequence.sequence_utils.rotate([0, 4, 8, 12], 16, 16) == [0, 4, 8, 12] # --- weighted_choice --- -def test_weighted_choice_basic () -> None: - - """Should return one of the provided options.""" - - rng = random.Random(42) - result = subsequence.sequence_utils.weighted_choice([("a", 1.0), ("b", 1.0)], rng) - - assert result in ("a", "b") +def test_weighted_choice_basic() -> None: + """Should return one of the provided options.""" + rng = random.Random(42) + result = subsequence.sequence_utils.weighted_choice([("a", 1.0), ("b", 1.0)], rng) -def test_weighted_choice_single_option () -> None: + assert result in ("a", "b") - """A single option should always be returned.""" - rng = random.Random(0) - result = subsequence.sequence_utils.weighted_choice([("only", 1.0)], rng) +def test_weighted_choice_single_option() -> None: + """A single option should always be returned.""" - assert result == "only" + rng = random.Random(0) + result = subsequence.sequence_utils.weighted_choice([("only", 1.0)], rng) + assert result == "only" -def test_weighted_choice_respects_weights () -> None: - """Over many trials, heavy-weighted options should appear more often.""" +def test_weighted_choice_respects_weights() -> None: + """Over many trials, heavy-weighted options should appear more often.""" - rng = random.Random(42) - counts = {"heavy": 0, "light": 0} + rng = random.Random(42) + counts = {"heavy": 0, "light": 0} - for _ in range(1000): - result = subsequence.sequence_utils.weighted_choice([("heavy", 0.9), ("light", 0.1)], rng) - counts[result] += 1 + for _ in range(1000): + result = subsequence.sequence_utils.weighted_choice( + [("heavy", 0.9), ("light", 0.1)], rng + ) + counts[result] += 1 - assert counts["heavy"] > counts["light"] - assert counts["heavy"] > 800 + assert counts["heavy"] > counts["light"] + assert counts["heavy"] > 800 -def test_weighted_choice_empty_raises () -> None: +def test_weighted_choice_empty_raises() -> None: + """An empty options list should raise ValueError.""" - """An empty options list should raise ValueError.""" + import pytest - import pytest + rng = random.Random(0) - rng = random.Random(0) + with pytest.raises(ValueError): + subsequence.sequence_utils.weighted_choice([], rng) - with pytest.raises(ValueError): - subsequence.sequence_utils.weighted_choice([], rng) +def test_weighted_choice_deterministic() -> None: + """Same seed should produce the same result.""" -def test_weighted_choice_deterministic () -> None: + options = [("a", 0.3), ("b", 0.5), ("c", 0.2)] - """Same seed should produce the same result.""" + result_1 = subsequence.sequence_utils.weighted_choice(options, random.Random(99)) + result_2 = subsequence.sequence_utils.weighted_choice(options, random.Random(99)) - options = [("a", 0.3), ("b", 0.5), ("c", 0.2)] - - result_1 = subsequence.sequence_utils.weighted_choice(options, random.Random(99)) - result_2 = subsequence.sequence_utils.weighted_choice(options, random.Random(99)) - - assert result_1 == result_2 + assert result_1 == result_2 # --- shuffled_choices --- -def test_shuffled_choices_correct_length () -> None: - - """Should return exactly n items.""" - - rng = random.Random(42) - result = subsequence.sequence_utils.shuffled_choices([1, 2, 3], 10, rng) - - assert len(result) == 10 - +def test_shuffled_choices_correct_length() -> None: + """Should return exactly n items.""" -def test_shuffled_choices_no_adjacent_repeats () -> None: + rng = random.Random(42) + result = subsequence.sequence_utils.shuffled_choices([1, 2, 3], 10, rng) - """No two adjacent items should be the same.""" + assert len(result) == 10 - rng = random.Random(42) - result = subsequence.sequence_utils.shuffled_choices([1, 2, 3, 4], 100, rng) - for i in range(len(result) - 1): - assert result[i] != result[i + 1], f"Adjacent repeat at index {i}: {result[i]}" +def test_shuffled_choices_no_adjacent_repeats() -> None: + """No two adjacent items should be the same.""" + rng = random.Random(42) + result = subsequence.sequence_utils.shuffled_choices([1, 2, 3, 4], 100, rng) -def test_shuffled_choices_single_item_pool () -> None: + for i in range(len(result) - 1): + assert result[i] != result[i + 1], f"Adjacent repeat at index {i}: {result[i]}" - """A single-item pool should return that item repeated.""" - rng = random.Random(0) - result = subsequence.sequence_utils.shuffled_choices(["x"], 5, rng) +def test_shuffled_choices_single_item_pool() -> None: + """A single-item pool should return that item repeated.""" - assert result == ["x", "x", "x", "x", "x"] + rng = random.Random(0) + result = subsequence.sequence_utils.shuffled_choices(["x"], 5, rng) + assert result == ["x", "x", "x", "x", "x"] -def test_shuffled_choices_n_less_than_pool () -> None: - """Requesting fewer items than pool size should work.""" +def test_shuffled_choices_n_less_than_pool() -> None: + """Requesting fewer items than pool size should work.""" - rng = random.Random(42) - result = subsequence.sequence_utils.shuffled_choices([10, 20, 30, 40], 2, rng) + rng = random.Random(42) + result = subsequence.sequence_utils.shuffled_choices([10, 20, 30, 40], 2, rng) - assert len(result) == 2 - assert all(item in [10, 20, 30, 40] for item in result) + assert len(result) == 2 + assert all(item in [10, 20, 30, 40] for item in result) -def test_shuffled_choices_empty_pool_raises () -> None: +def test_shuffled_choices_empty_pool_raises() -> None: + """An empty pool should raise ValueError.""" - """An empty pool should raise ValueError.""" + import pytest - import pytest + rng = random.Random(0) - rng = random.Random(0) + with pytest.raises(ValueError): + subsequence.sequence_utils.shuffled_choices([], 5, rng) - with pytest.raises(ValueError): - subsequence.sequence_utils.shuffled_choices([], 5, rng) +def test_shuffled_choices_zero_n() -> None: + """n=0 should return an empty list.""" -def test_shuffled_choices_zero_n () -> None: + rng = random.Random(0) + result = subsequence.sequence_utils.shuffled_choices([1, 2, 3], 0, rng) - """n=0 should return an empty list.""" + assert result == [] - rng = random.Random(0) - result = subsequence.sequence_utils.shuffled_choices([1, 2, 3], 0, rng) - assert result == [] +def test_shuffled_choices_deterministic() -> None: + """Same seed should produce the same sequence.""" + pool = [1, 2, 3, 4] -def test_shuffled_choices_deterministic () -> None: + result_1 = subsequence.sequence_utils.shuffled_choices(pool, 20, random.Random(42)) + result_2 = subsequence.sequence_utils.shuffled_choices(pool, 20, random.Random(42)) - """Same seed should produce the same sequence.""" - - pool = [1, 2, 3, 4] - - result_1 = subsequence.sequence_utils.shuffled_choices(pool, 20, random.Random(42)) - result_2 = subsequence.sequence_utils.shuffled_choices(pool, 20, random.Random(42)) - - assert result_1 == result_2 + assert result_1 == result_2 # --- random_walk --- -def test_random_walk_within_range () -> None: - - """All values should be within [low, high].""" - - rng = random.Random(42) - result = subsequence.sequence_utils.random_walk(100, low=30, high=90, step=10, rng=rng) - - assert all(30 <= v <= 90 for v in result) +def test_random_walk_within_range() -> None: + """All values should be within [low, high].""" + rng = random.Random(42) + result = subsequence.sequence_utils.random_walk( + 100, low=30, high=90, step=10, rng=rng + ) -def test_random_walk_correct_length () -> None: + assert all(30 <= v <= 90 for v in result) - """Should return exactly n values.""" - rng = random.Random(42) - result = subsequence.sequence_utils.random_walk(16, low=0, high=127, step=5, rng=rng) +def test_random_walk_correct_length() -> None: + """Should return exactly n values.""" - assert len(result) == 16 + rng = random.Random(42) + result = subsequence.sequence_utils.random_walk( + 16, low=0, high=127, step=5, rng=rng + ) + assert len(result) == 16 -def test_random_walk_step_zero_constant () -> None: - """A step of 0 should return all the same value (midpoint).""" +def test_random_walk_step_zero_constant() -> None: + """A step of 0 should return all the same value (midpoint).""" - rng = random.Random(42) - result = subsequence.sequence_utils.random_walk(10, low=40, high=80, step=0, rng=rng) + rng = random.Random(42) + result = subsequence.sequence_utils.random_walk( + 10, low=40, high=80, step=0, rng=rng + ) - assert all(v == 60 for v in result) + assert all(v == 60 for v in result) -def test_random_walk_adjacent_step_limit () -> None: +def test_random_walk_adjacent_step_limit() -> None: + """Adjacent values should differ by at most the step size.""" - """Adjacent values should differ by at most the step size.""" + rng = random.Random(42) + step = 5 + result = subsequence.sequence_utils.random_walk( + 50, low=0, high=127, step=step, rng=rng + ) - rng = random.Random(42) - step = 5 - result = subsequence.sequence_utils.random_walk(50, low=0, high=127, step=step, rng=rng) + for i in range(len(result) - 1): + assert abs(result[i + 1] - result[i]) <= step - for i in range(len(result) - 1): - assert abs(result[i + 1] - result[i]) <= step +def test_random_walk_custom_start() -> None: + """First value should match the start parameter.""" -def test_random_walk_custom_start () -> None: + rng = random.Random(42) + result = subsequence.sequence_utils.random_walk( + 5, low=0, high=100, step=10, rng=rng, start=20 + ) - """First value should match the start parameter.""" + assert result[0] == 20 - rng = random.Random(42) - result = subsequence.sequence_utils.random_walk(5, low=0, high=100, step=10, rng=rng, start=20) - assert result[0] == 20 +def test_random_walk_start_clamped() -> None: + """Start value outside range should be clamped.""" + rng = random.Random(42) + result = subsequence.sequence_utils.random_walk( + 5, low=50, high=100, step=10, rng=rng, start=10 + ) -def test_random_walk_start_clamped () -> None: + assert result[0] == 50 - """Start value outside range should be clamped.""" - rng = random.Random(42) - result = subsequence.sequence_utils.random_walk(5, low=50, high=100, step=10, rng=rng, start=10) +def test_random_walk_empty() -> None: + """n=0 should return an empty list.""" - assert result[0] == 50 + rng = random.Random(42) + result = subsequence.sequence_utils.random_walk( + 0, low=0, high=100, step=10, rng=rng + ) + assert result == [] -def test_random_walk_empty () -> None: - """n=0 should return an empty list.""" +def test_random_walk_deterministic() -> None: + """Same seed should produce the same walk.""" - rng = random.Random(42) - result = subsequence.sequence_utils.random_walk(0, low=0, high=100, step=10, rng=rng) + result_1 = subsequence.sequence_utils.random_walk( + 20, low=0, high=100, step=10, rng=random.Random(42) + ) + result_2 = subsequence.sequence_utils.random_walk( + 20, low=0, high=100, step=10, rng=random.Random(42) + ) - assert result == [] - - -def test_random_walk_deterministic () -> None: - - """Same seed should produce the same walk.""" - - result_1 = subsequence.sequence_utils.random_walk(20, low=0, high=100, step=10, rng=random.Random(42)) - result_2 = subsequence.sequence_utils.random_walk(20, low=0, high=100, step=10, rng=random.Random(42)) - - assert result_1 == result_2 + assert result_1 == result_2 # --- probability_gate --- -def test_probability_gate_keep_all () -> None: - - """Probability 1.0 should keep all hits.""" +def test_probability_gate_keep_all() -> None: + """Probability 1.0 should keep all hits.""" - rng = random.Random(42) - seq = [1, 0, 1, 0, 1, 0, 1, 0] + rng = random.Random(42) + seq = [1, 0, 1, 0, 1, 0, 1, 0] - result = subsequence.sequence_utils.probability_gate(seq, 1.0, rng) + result = subsequence.sequence_utils.probability_gate(seq, 1.0, rng) - assert result == seq + assert result == seq -def test_probability_gate_remove_all () -> None: +def test_probability_gate_remove_all() -> None: + """Probability 0.0 should remove all hits.""" - """Probability 0.0 should remove all hits.""" + rng = random.Random(42) + seq = [1, 1, 1, 1, 1, 1, 1, 1] - rng = random.Random(42) - seq = [1, 1, 1, 1, 1, 1, 1, 1] + result = subsequence.sequence_utils.probability_gate(seq, 0.0, rng) - result = subsequence.sequence_utils.probability_gate(seq, 0.0, rng) + assert result == [0, 0, 0, 0, 0, 0, 0, 0] - assert result == [0, 0, 0, 0, 0, 0, 0, 0] +def test_probability_gate_zeros_never_promoted() -> None: + """Zero steps should remain zero regardless of probability.""" -def test_probability_gate_zeros_never_promoted () -> None: + rng = random.Random(42) + seq = [0, 0, 0, 0] - """Zero steps should remain zero regardless of probability.""" + result = subsequence.sequence_utils.probability_gate(seq, 1.0, rng) - rng = random.Random(42) - seq = [0, 0, 0, 0] + assert result == [0, 0, 0, 0] - result = subsequence.sequence_utils.probability_gate(seq, 1.0, rng) - assert result == [0, 0, 0, 0] +def test_probability_gate_per_step_list() -> None: + """A per-step probability list should apply individual thresholds.""" + rng = random.Random(42) + seq = [1, 1, 1, 1] + probs = [1.0, 0.0, 1.0, 0.0] -def test_probability_gate_per_step_list () -> None: + result = subsequence.sequence_utils.probability_gate(seq, probs, rng) - """A per-step probability list should apply individual thresholds.""" + assert result == [1, 0, 1, 0] - rng = random.Random(42) - seq = [1, 1, 1, 1] - probs = [1.0, 0.0, 1.0, 0.0] - result = subsequence.sequence_utils.probability_gate(seq, probs, rng) +def test_probability_gate_partial() -> None: + """Intermediate probability should keep some hits but not all.""" - assert result == [1, 0, 1, 0] + rng = random.Random(42) + seq = [1] * 100 + result = subsequence.sequence_utils.probability_gate(seq, 0.5, rng) -def test_probability_gate_partial () -> None: + kept = sum(result) + assert 0 < kept < 100 - """Intermediate probability should keep some hits but not all.""" - rng = random.Random(42) - seq = [1] * 100 +def test_probability_gate_deterministic() -> None: + """Same seed should produce the same gated sequence.""" - result = subsequence.sequence_utils.probability_gate(seq, 0.5, rng) + seq = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] - kept = sum(result) - assert 0 < kept < 100 + result_1 = subsequence.sequence_utils.probability_gate(seq, 0.6, random.Random(42)) + result_2 = subsequence.sequence_utils.probability_gate(seq, 0.6, random.Random(42)) - -def test_probability_gate_deterministic () -> None: - - """Same seed should produce the same gated sequence.""" - - seq = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] - - result_1 = subsequence.sequence_utils.probability_gate(seq, 0.6, random.Random(42)) - result_2 = subsequence.sequence_utils.probability_gate(seq, 0.6, random.Random(42)) - - assert result_1 == result_2 + assert result_1 == result_2 # --- generate_bresenham_sequence_weighted --- -def test_bresenham_weighted_length_matches_steps () -> None: - - """Output length should equal the requested step count.""" - for steps in [1, 8, 16, 32]: - seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted(steps, [0.3, 0.7]) - assert len(seq) == steps +def test_bresenham_weighted_length_matches_steps() -> None: + """Output length should equal the requested step count.""" + for steps in [1, 8, 16, 32]: + seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted( + steps, [0.3, 0.7] + ) + assert len(seq) == steps -def test_bresenham_weighted_equal_voices () -> None: - """Two equal-weight voices should each get exactly half the steps.""" +def test_bresenham_weighted_equal_voices() -> None: + """Two equal-weight voices should each get exactly half the steps.""" - seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted(16, [0.5, 0.5]) - assert seq.count(0) == 8 - assert seq.count(1) == 8 + seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted( + 16, [0.5, 0.5] + ) + assert seq.count(0) == 8 + assert seq.count(1) == 8 -def test_bresenham_weighted_single_voice () -> None: +def test_bresenham_weighted_single_voice() -> None: + """A single voice should claim every step.""" - """A single voice should claim every step.""" + seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted(8, [1.0]) + assert seq == [0] * 8 - seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted(8, [1.0]) - assert seq == [0] * 8 +def test_bresenham_weighted_heavier_voice_gets_more_steps() -> None: + """The voice with double the weight should get roughly double the steps.""" -def test_bresenham_weighted_heavier_voice_gets_more_steps () -> None: + seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted( + 16, [0.5, 0.25] + ) + assert seq.count(0) > seq.count(1) - """The voice with double the weight should get roughly double the steps.""" - seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted(16, [0.5, 0.25]) - assert seq.count(0) > seq.count(1) +def test_bresenham_weighted_three_voices_all_present() -> None: + """All three voices should appear when given non-trivial weights.""" + seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted( + 16, [0.4, 0.3, 0.3] + ) + assert 0 in seq + assert 1 in seq + assert 2 in seq -def test_bresenham_weighted_three_voices_all_present () -> None: - """All three voices should appear when given non-trivial weights.""" +def test_bresenham_weighted_deterministic() -> None: + """Same inputs should always produce the same sequence (no randomness).""" - seq = subsequence.sequence_utils.generate_bresenham_sequence_weighted(16, [0.4, 0.3, 0.3]) - assert 0 in seq - assert 1 in seq - assert 2 in seq + a = subsequence.sequence_utils.generate_bresenham_sequence_weighted( + 16, [0.3, 0.5, 0.2] + ) + b = subsequence.sequence_utils.generate_bresenham_sequence_weighted( + 16, [0.3, 0.5, 0.2] + ) + assert a == b -def test_bresenham_weighted_deterministic () -> None: +def test_bresenham_weighted_zero_steps_raises() -> None: + """Steps <= 0 should raise ValueError.""" - """Same inputs should always produce the same sequence (no randomness).""" + import pytest - a = subsequence.sequence_utils.generate_bresenham_sequence_weighted(16, [0.3, 0.5, 0.2]) - b = subsequence.sequence_utils.generate_bresenham_sequence_weighted(16, [0.3, 0.5, 0.2]) - assert a == b + with pytest.raises(ValueError): + subsequence.sequence_utils.generate_bresenham_sequence_weighted(0, [0.5]) -def test_bresenham_weighted_zero_steps_raises () -> None: +def test_bresenham_weighted_empty_weights_raises() -> None: + """Empty weights list should raise ValueError.""" - """Steps <= 0 should raise ValueError.""" + import pytest - import pytest - with pytest.raises(ValueError): - subsequence.sequence_utils.generate_bresenham_sequence_weighted(0, [0.5]) - - -def test_bresenham_weighted_empty_weights_raises () -> None: - - """Empty weights list should raise ValueError.""" - - import pytest - with pytest.raises(ValueError): - subsequence.sequence_utils.generate_bresenham_sequence_weighted(16, []) + with pytest.raises(ValueError): + subsequence.sequence_utils.generate_bresenham_sequence_weighted(16, []) # --- perlin_1d --- -def test_perlin_1d_range () -> None: - - """Output should always be in [0.0, 1.0].""" - - for i in range(200): - x = i * 0.13 - 5.0 - value = subsequence.sequence_utils.perlin_1d(x, seed=42) - assert 0.0 <= value <= 1.0, f"perlin_1d({x}) = {value} out of range" +def test_perlin_1d_range() -> None: + """Output should always be in [0.0, 1.0].""" + for i in range(200): + x = i * 0.13 - 5.0 + value = subsequence.sequence_utils.perlin_1d(x, seed=42) + assert 0.0 <= value <= 1.0, f"perlin_1d({x}) = {value} out of range" -def test_perlin_1d_deterministic () -> None: - """Same x and seed should always produce the same result.""" +def test_perlin_1d_deterministic() -> None: + """Same x and seed should always produce the same result.""" - a = subsequence.sequence_utils.perlin_1d(3.14, seed=7) - b = subsequence.sequence_utils.perlin_1d(3.14, seed=7) - assert a == b + a = subsequence.sequence_utils.perlin_1d(3.14, seed=7) + b = subsequence.sequence_utils.perlin_1d(3.14, seed=7) + assert a == b -def test_perlin_1d_smooth () -> None: +def test_perlin_1d_smooth() -> None: + """Adjacent samples should be close (no large jumps).""" - """Adjacent samples should be close (no large jumps).""" + step = 0.01 + prev = subsequence.sequence_utils.perlin_1d(0.0, seed=42) - step = 0.01 - prev = subsequence.sequence_utils.perlin_1d(0.0, seed=42) + for i in range(1, 100): + x = i * step + curr = subsequence.sequence_utils.perlin_1d(x, seed=42) + assert abs(curr - prev) < 0.15, f"Jump too large at x={x}: {prev} -> {curr}" + prev = curr - for i in range(1, 100): - x = i * step - curr = subsequence.sequence_utils.perlin_1d(x, seed=42) - assert abs(curr - prev) < 0.15, f"Jump too large at x={x}: {prev} -> {curr}" - prev = curr +def test_perlin_1d_different_seeds() -> None: + """Different seeds should produce different noise fields.""" -def test_perlin_1d_different_seeds () -> None: + values_a = [ + subsequence.sequence_utils.perlin_1d(i * 0.1, seed=0) for i in range(20) + ] + values_b = [ + subsequence.sequence_utils.perlin_1d(i * 0.1, seed=99) for i in range(20) + ] - """Different seeds should produce different noise fields.""" + assert values_a != values_b - values_a = [subsequence.sequence_utils.perlin_1d(i * 0.1, seed=0) for i in range(20)] - values_b = [subsequence.sequence_utils.perlin_1d(i * 0.1, seed=99) for i in range(20)] - assert values_a != values_b +def test_perlin_1d_varies() -> None: + """Output should not be constant — it should vary over the range.""" + values = [subsequence.sequence_utils.perlin_1d(i * 0.3, seed=42) for i in range(50)] -def test_perlin_1d_varies () -> None: - - """Output should not be constant — it should vary over the range.""" - - values = [subsequence.sequence_utils.perlin_1d(i * 0.3, seed=42) for i in range(50)] - - assert max(values) - min(values) > 0.1 + assert max(values) - min(values) > 0.1 # --- generate_cellular_automaton_1d --- -def test_cellular_automaton_length () -> None: - - """Output length should match the requested step count.""" - - for steps in [8, 16, 32]: - result = subsequence.sequence_utils.generate_cellular_automaton_1d(steps, rule=30, generation=5) - assert len(result) == steps - +def test_cellular_automaton_length() -> None: + """Output length should match the requested step count.""" -def test_cellular_automaton_binary () -> None: + for steps in [8, 16, 32]: + result = subsequence.sequence_utils.generate_cellular_automaton_1d( + steps, rule=30, generation=5 + ) + assert len(result) == steps - """Output should contain only 0s and 1s.""" - result = subsequence.sequence_utils.generate_cellular_automaton_1d(16, rule=30, generation=10) - assert all(v in (0, 1) for v in result) +def test_cellular_automaton_binary() -> None: + """Output should contain only 0s and 1s.""" + result = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=10 + ) + assert all(v in (0, 1) for v in result) -def test_cellular_automaton_generation_zero () -> None: - """Generation 0 with default seed should have a single centre cell.""" +def test_cellular_automaton_generation_zero() -> None: + """Generation 0 with default seed should have a single centre cell.""" - result = subsequence.sequence_utils.generate_cellular_automaton_1d(16, rule=30, generation=0) - assert result[8] == 1 - assert sum(result) == 1 + result = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=0 + ) + assert result[8] == 1 + assert sum(result) == 1 -def test_cellular_automaton_evolves () -> None: +def test_cellular_automaton_evolves() -> None: + """Different generations should produce different patterns.""" - """Different generations should produce different patterns.""" + gen_0 = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=0 + ) + gen_5 = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=5 + ) + gen_10 = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=10 + ) - gen_0 = subsequence.sequence_utils.generate_cellular_automaton_1d(16, rule=30, generation=0) - gen_5 = subsequence.sequence_utils.generate_cellular_automaton_1d(16, rule=30, generation=5) - gen_10 = subsequence.sequence_utils.generate_cellular_automaton_1d(16, rule=30, generation=10) + assert gen_0 != gen_5 + assert gen_5 != gen_10 - assert gen_0 != gen_5 - assert gen_5 != gen_10 +def test_cellular_automaton_deterministic() -> None: + """Same parameters should always produce the same output.""" -def test_cellular_automaton_deterministic () -> None: + a = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=7 + ) + b = subsequence.sequence_utils.generate_cellular_automaton_1d( + 16, rule=30, generation=7 + ) + assert a == b - """Same parameters should always produce the same output.""" - a = subsequence.sequence_utils.generate_cellular_automaton_1d(16, rule=30, generation=7) - b = subsequence.sequence_utils.generate_cellular_automaton_1d(16, rule=30, generation=7) - assert a == b +def test_cellular_automaton_rule_90_symmetry() -> None: + """Rule 90 from a single centre cell should produce symmetric patterns (odd grid).""" + result = subsequence.sequence_utils.generate_cellular_automaton_1d( + 17, rule=90, generation=3 + ) + assert result == list(reversed(result)) -def test_cellular_automaton_rule_90_symmetry () -> None: - """Rule 90 from a single centre cell should produce symmetric patterns (odd grid).""" +def test_cellular_automaton_empty() -> None: + """Steps <= 0 should return an empty list.""" - result = subsequence.sequence_utils.generate_cellular_automaton_1d(17, rule=90, generation=3) - assert result == list(reversed(result)) + assert ( + subsequence.sequence_utils.generate_cellular_automaton_1d( + 0, rule=30, generation=5 + ) + == [] + ) -def test_cellular_automaton_empty () -> None: +def test_cellular_automaton_custom_seed() -> None: + """A custom seed should set the initial state from its bit pattern.""" - """Steps <= 0 should return an empty list.""" - - assert subsequence.sequence_utils.generate_cellular_automaton_1d(0, rule=30, generation=5) == [] - - -def test_cellular_automaton_custom_seed () -> None: - - """A custom seed should set the initial state from its bit pattern.""" - - # seed=5 = binary 101 → state[0]=1, state[1]=0, state[2]=1 - result = subsequence.sequence_utils.generate_cellular_automaton_1d(8, rule=30, generation=0, seed=5) - assert result[0] == 1 - assert result[1] == 0 - assert result[2] == 1 - assert sum(result) == 2 + # seed=5 = binary 101 → state[0]=1, state[1]=0, state[2]=1 + result = subsequence.sequence_utils.generate_cellular_automaton_1d( + 8, rule=30, generation=0, seed=5 + ) + assert result[0] == 1 + assert result[1] == 0 + assert result[2] == 1 + assert sum(result) == 2 # --- _parse_life_rule --- -def test_parse_life_rule_conway () -> None: - - """B3/S23 (Conway's Life) should parse to correct birth and survival sets.""" +def test_parse_life_rule_conway() -> None: + """B3/S23 (Conway's Life) should parse to correct birth and survival sets.""" - birth, survival = subsequence.sequence_utils._parse_life_rule("B3/S23") - assert birth == {3} - assert survival == {2, 3} + birth, survival = subsequence.sequence_utils._parse_life_rule("B3/S23") + assert birth == {3} + assert survival == {2, 3} -def test_parse_life_rule_morley () -> None: +def test_parse_life_rule_morley() -> None: + """B368/S245 (Morley) should parse to correct birth and survival sets.""" - """B368/S245 (Morley) should parse to correct birth and survival sets.""" + birth, survival = subsequence.sequence_utils._parse_life_rule("B368/S245") + assert birth == {3, 6, 8} + assert survival == {2, 4, 5} - birth, survival = subsequence.sequence_utils._parse_life_rule("B368/S245") - assert birth == {3, 6, 8} - assert survival == {2, 4, 5} +def test_parse_life_rule_case_insensitive() -> None: + """Rule parsing should be case-insensitive.""" -def test_parse_life_rule_case_insensitive () -> None: + birth, survival = subsequence.sequence_utils._parse_life_rule("b3/s23") + assert birth == {3} + assert survival == {2, 3} - """Rule parsing should be case-insensitive.""" - birth, survival = subsequence.sequence_utils._parse_life_rule("b3/s23") - assert birth == {3} - assert survival == {2, 3} +def test_parse_life_rule_empty_sets() -> None: + """B/S should produce empty birth and survival sets.""" + birth, survival = subsequence.sequence_utils._parse_life_rule("B/S") + assert birth == set() + assert survival == set() -def test_parse_life_rule_empty_sets () -> None: - """B/S should produce empty birth and survival sets.""" +def test_parse_life_rule_invalid_format() -> None: + """Malformed rule strings should raise ValueError.""" - birth, survival = subsequence.sequence_utils._parse_life_rule("B/S") - assert birth == set() - assert survival == set() + import pytest + with pytest.raises(ValueError): + subsequence.sequence_utils._parse_life_rule("30") -def test_parse_life_rule_invalid_format () -> None: - - """Malformed rule strings should raise ValueError.""" - - import pytest - - with pytest.raises(ValueError): - subsequence.sequence_utils._parse_life_rule("30") - - with pytest.raises(ValueError): - subsequence.sequence_utils._parse_life_rule("S23/B3") + with pytest.raises(ValueError): + subsequence.sequence_utils._parse_life_rule("S23/B3") # --- generate_cellular_automaton_2d --- -def test_2d_ca_dimensions () -> None: - - """Output grid should be exactly rows × cols.""" - - grid = subsequence.sequence_utils.generate_cellular_automaton_2d(rows=4, cols=16) - assert len(grid) == 4 - assert all(len(row) == 16 for row in grid) +def test_2d_ca_dimensions() -> None: + """Output grid should be exactly rows × cols.""" + grid = subsequence.sequence_utils.generate_cellular_automaton_2d(rows=4, cols=16) + assert len(grid) == 4 + assert all(len(row) == 16 for row in grid) -def test_2d_ca_binary_values () -> None: - """All grid cells should be 0 or 1.""" +def test_2d_ca_binary_values() -> None: + """All grid cells should be 0 or 1.""" - grid = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=16, rule="B3/S23", generation=5, seed=42 - ) - assert all(v in (0, 1) for row in grid for v in row) + grid = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=16, rule="B3/S23", generation=5, seed=42 + ) + assert all(v in (0, 1) for row in grid for v in row) -def test_2d_ca_deterministic () -> None: +def test_2d_ca_deterministic() -> None: + """Same parameters should always produce the same grid.""" - """Same parameters should always produce the same grid.""" + a = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=16, rule="B368/S245", generation=10, seed=99 + ) + b = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=16, rule="B368/S245", generation=10, seed=99 + ) + assert a == b - a = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=16, rule="B368/S245", generation=10, seed=99 - ) - b = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=16, rule="B368/S245", generation=10, seed=99 - ) - assert a == b +def test_2d_ca_seed_centre() -> None: + """seed=1 with generation=0 should place a single live cell at centre.""" -def test_2d_ca_seed_centre () -> None: + grid = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=8, generation=0, seed=1 + ) + total = sum(v for row in grid for v in row) + assert total == 1 + assert grid[4 // 2][8 // 2] == 1 - """seed=1 with generation=0 should place a single live cell at centre.""" - grid = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=8, generation=0, seed=1 - ) - total = sum(v for row in grid for v in row) - assert total == 1 - assert grid[4 // 2][8 // 2] == 1 +def test_2d_ca_seed_random() -> None: + """Integer seed != 1 should produce a roughly density-fraction fill.""" + grid = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=10, cols=20, generation=0, seed=42, density=0.5 + ) + total = sum(v for row in grid for v in row) + # Expect between 20% and 80% of 200 cells alive (very loose check) + assert 40 <= total <= 160 -def test_2d_ca_seed_random () -> None: - """Integer seed != 1 should produce a roughly density-fraction fill.""" +def test_2d_ca_seed_explicit() -> None: + """Explicit list seed should be used as the starting grid unchanged.""" - grid = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=10, cols=20, generation=0, seed=42, density=0.5 - ) - total = sum(v for row in grid for v in row) - # Expect between 20% and 80% of 200 cells alive (very loose check) - assert 40 <= total <= 160 + initial = [[1, 0, 0, 1], [0, 1, 1, 0]] + grid = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=2, cols=4, generation=0, seed=initial + ) + assert grid == initial -def test_2d_ca_seed_explicit () -> None: +def test_2d_ca_generation_zero_unchanged() -> None: + """generation=0 should return the seed state without any evolution.""" - """Explicit list seed should be used as the starting grid unchanged.""" + seed_grid = [[1, 0, 1, 0], [0, 0, 1, 1], [1, 1, 0, 0], [0, 1, 0, 1]] + result = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=4, generation=0, seed=seed_grid + ) + assert result == seed_grid - initial = [[1, 0, 0, 1], [0, 1, 1, 0]] - grid = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=2, cols=4, generation=0, seed=initial - ) - assert grid == initial +def test_2d_ca_evolves_from_seed() -> None: + """Grid should change after one or more generations.""" -def test_2d_ca_generation_zero_unchanged () -> None: + grid_gen0 = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=6, cols=16, rule="B368/S245", generation=0, seed=42, density=0.4 + ) + grid_gen5 = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=6, cols=16, rule="B368/S245", generation=5, seed=42, density=0.4 + ) + assert grid_gen0 != grid_gen5 - """generation=0 should return the seed state without any evolution.""" - seed_grid = [[1, 0, 1, 0], [0, 0, 1, 1], [1, 1, 0, 0], [0, 1, 0, 1]] - result = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=4, generation=0, seed=seed_grid - ) - assert result == seed_grid +def test_2d_ca_conway_blinker() -> None: + """Conway blinker (period-2 oscillator) should alternate between horizontal and vertical.""" + # Horizontal blinker at centre of a 5×5 grid: row 2, cols 1–3 live. + horizontal = [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ] + # After 1 generation it becomes vertical: col 2, rows 1–3 live. + expected_vertical = [ + [0, 0, 0, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 0, 0, 0], + ] + result = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=5, cols=5, rule="B3/S23", generation=1, seed=horizontal + ) + assert result == expected_vertical -def test_2d_ca_evolves_from_seed () -> None: - """Grid should change after one or more generations.""" +def test_2d_ca_toroidal_wrapping() -> None: + """Cells at the edge should wrap to the opposite side.""" - grid_gen0 = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=6, cols=16, rule="B368/S245", generation=0, seed=42, density=0.4 - ) - grid_gen5 = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=6, cols=16, rule="B368/S245", generation=5, seed=42, density=0.4 - ) - assert grid_gen0 != grid_gen5 + # Live cell at top-left corner: its neighbours wrap around the edges. + corner = [[1, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] + # Just verify no index error and grid shape is intact. + result = subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=4, rule="B3/S23", generation=2, seed=corner + ) + assert len(result) == 4 + assert all(len(row) == 4 for row in result) -def test_2d_ca_conway_blinker () -> None: +def test_2d_ca_invalid_rule() -> None: + """An invalid rule string should raise ValueError.""" + + import pytest - """Conway blinker (period-2 oscillator) should alternate between horizontal and vertical.""" - - # Horizontal blinker at centre of a 5×5 grid: row 2, cols 1–3 live. - horizontal = [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 1, 1, 1, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - ] - # After 1 generation it becomes vertical: col 2, rows 1–3 live. - expected_vertical = [ - [0, 0, 0, 0, 0], - [0, 0, 1, 0, 0], - [0, 0, 1, 0, 0], - [0, 0, 1, 0, 0], - [0, 0, 0, 0, 0], - ] - result = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=5, cols=5, rule="B3/S23", generation=1, seed=horizontal - ) - assert result == expected_vertical - - -def test_2d_ca_toroidal_wrapping () -> None: - - """Cells at the edge should wrap to the opposite side.""" - - # Live cell at top-left corner: its neighbours wrap around the edges. - corner = [[1, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] - # Just verify no index error and grid shape is intact. - result = subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=4, rule="B3/S23", generation=2, seed=corner - ) - assert len(result) == 4 - assert all(len(row) == 4 for row in result) - - -def test_2d_ca_invalid_rule () -> None: - - """An invalid rule string should raise ValueError.""" - - import pytest - - with pytest.raises(ValueError): - subsequence.sequence_utils.generate_cellular_automaton_2d( - rows=4, cols=8, rule="invalid" - ) + with pytest.raises(ValueError): + subsequence.sequence_utils.generate_cellular_automaton_2d( + rows=4, cols=8, rule="invalid" + ) # --- logistic_map --- -def test_logistic_map_correct_length () -> None: - - """Should return exactly the requested number of values.""" - - result = subsequence.sequence_utils.logistic_map(r=3.5, steps=16) +def test_logistic_map_correct_length() -> None: + """Should return exactly the requested number of values.""" - assert len(result) == 16 + result = subsequence.sequence_utils.logistic_map(r=3.5, steps=16) + assert len(result) == 16 -def test_logistic_map_zero_steps_returns_empty () -> None: - """steps <= 0 should return an empty list.""" +def test_logistic_map_zero_steps_returns_empty() -> None: + """steps <= 0 should return an empty list.""" - assert subsequence.sequence_utils.logistic_map(r=3.5, steps=0) == [] + assert subsequence.sequence_utils.logistic_map(r=3.5, steps=0) == [] -def test_logistic_map_deterministic () -> None: +def test_logistic_map_deterministic() -> None: + """Same r and x0 should always produce the same sequence.""" - """Same r and x0 should always produce the same sequence.""" + a = subsequence.sequence_utils.logistic_map(r=3.7, steps=32, x0=0.4) + b = subsequence.sequence_utils.logistic_map(r=3.7, steps=32, x0=0.4) - a = subsequence.sequence_utils.logistic_map(r=3.7, steps=32, x0=0.4) - b = subsequence.sequence_utils.logistic_map(r=3.7, steps=32, x0=0.4) + assert a == b - assert a == b +def test_logistic_map_stable_regime_converges() -> None: + """For r < 3.0, the sequence should converge toward a fixed point.""" -def test_logistic_map_stable_regime_converges () -> None: + result = subsequence.sequence_utils.logistic_map(r=2.5, steps=100, x0=0.4) - """For r < 3.0, the sequence should converge toward a fixed point.""" + # After many iterations the last few values should be nearly identical. + assert abs(result[-1] - result[-2]) < 1e-6 - result = subsequence.sequence_utils.logistic_map(r=2.5, steps=100, x0=0.4) - # After many iterations the last few values should be nearly identical. - assert abs(result[-1] - result[-2]) < 1e-6 +def test_logistic_map_periodic_regime() -> None: + """For r ≈ 3.2, the sequence should oscillate between two values.""" + result = subsequence.sequence_utils.logistic_map(r=3.2, steps=200, x0=0.5) -def test_logistic_map_periodic_regime () -> None: + # After settling, even-indexed and odd-indexed tail values should cluster. + tail = result[150:] + even = tail[0::2] + odd = tail[1::2] - """For r ≈ 3.2, the sequence should oscillate between two values.""" + # All even-indexed values should be close to each other (one attractor). + assert max(even) - min(even) < 0.01 - result = subsequence.sequence_utils.logistic_map(r=3.2, steps=200, x0=0.5) + # The two attractors should be clearly separated. + assert abs(sum(even) / len(even) - sum(odd) / len(odd)) > 0.1 - # After settling, even-indexed and odd-indexed tail values should cluster. - tail = result[150:] - even = tail[0::2] - odd = tail[1::2] - # All even-indexed values should be close to each other (one attractor). - assert max(even) - min(even) < 0.01 +def test_logistic_map_chaos_regime_varies() -> None: + """For r > 3.57, successive values should not converge.""" - # The two attractors should be clearly separated. - assert abs(sum(even) / len(even) - sum(odd) / len(odd)) > 0.1 + result = subsequence.sequence_utils.logistic_map(r=3.9, steps=32, x0=0.5) + # In the chaotic regime the range of values should be wide. + assert max(result) - min(result) > 0.5 -def test_logistic_map_chaos_regime_varies () -> None: - """For r > 3.57, successive values should not converge.""" +def test_logistic_map_different_r_different_output() -> None: + """Different r values should produce different sequences.""" - result = subsequence.sequence_utils.logistic_map(r=3.9, steps=32, x0=0.5) + a = subsequence.sequence_utils.logistic_map(r=3.2, steps=16, x0=0.5) + b = subsequence.sequence_utils.logistic_map(r=3.8, steps=16, x0=0.5) - # In the chaotic regime the range of values should be wide. - assert max(result) - min(result) > 0.5 + assert a != b -def test_logistic_map_different_r_different_output () -> None: +def test_logistic_map_different_x0_different_output() -> None: + """Different starting values should produce different sequences.""" - """Different r values should produce different sequences.""" + a = subsequence.sequence_utils.logistic_map(r=3.7, steps=16, x0=0.4) + b = subsequence.sequence_utils.logistic_map(r=3.7, steps=16, x0=0.7) - a = subsequence.sequence_utils.logistic_map(r=3.2, steps=16, x0=0.5) - b = subsequence.sequence_utils.logistic_map(r=3.8, steps=16, x0=0.5) - - assert a != b - - -def test_logistic_map_different_x0_different_output () -> None: - - """Different starting values should produce different sequences.""" - - a = subsequence.sequence_utils.logistic_map(r=3.7, steps=16, x0=0.4) - b = subsequence.sequence_utils.logistic_map(r=3.7, steps=16, x0=0.7) - - assert a != b + assert a != b # --- pink_noise --- -def test_pink_noise_correct_length () -> None: - - """Output length should match the requested step count.""" - - assert len(subsequence.sequence_utils.pink_noise(16)) == 16 - assert len(subsequence.sequence_utils.pink_noise(100)) == 100 - - -def test_pink_noise_zero_steps_returns_empty () -> None: - - """steps=0 should return an empty list.""" - - assert subsequence.sequence_utils.pink_noise(0) == [] +def test_pink_noise_correct_length() -> None: + """Output length should match the requested step count.""" + assert len(subsequence.sequence_utils.pink_noise(16)) == 16 + assert len(subsequence.sequence_utils.pink_noise(100)) == 100 -def test_pink_noise_range () -> None: - """All output values should be in [0.0, 1.0].""" +def test_pink_noise_zero_steps_returns_empty() -> None: + """steps=0 should return an empty list.""" - result = subsequence.sequence_utils.pink_noise(1000, seed=7) - assert all(0.0 <= v <= 1.0 for v in result) + assert subsequence.sequence_utils.pink_noise(0) == [] -def test_pink_noise_deterministic () -> None: +def test_pink_noise_range() -> None: + """All output values should be in [0.0, 1.0].""" - """Same seed should always produce the same sequence.""" + result = subsequence.sequence_utils.pink_noise(1000, seed=7) + assert all(0.0 <= v <= 1.0 for v in result) - a = subsequence.sequence_utils.pink_noise(64, seed=42) - b = subsequence.sequence_utils.pink_noise(64, seed=42) - assert a == b +def test_pink_noise_deterministic() -> None: + """Same seed should always produce the same sequence.""" -def test_pink_noise_different_seeds () -> None: + a = subsequence.sequence_utils.pink_noise(64, seed=42) + b = subsequence.sequence_utils.pink_noise(64, seed=42) + assert a == b - """Different seeds should produce different sequences.""" - a = subsequence.sequence_utils.pink_noise(64, seed=0) - b = subsequence.sequence_utils.pink_noise(64, seed=99) - assert a != b +def test_pink_noise_different_seeds() -> None: + """Different seeds should produce different sequences.""" + a = subsequence.sequence_utils.pink_noise(64, seed=0) + b = subsequence.sequence_utils.pink_noise(64, seed=99) + assert a != b -def test_pink_noise_varies () -> None: - """Output should not be constant.""" +def test_pink_noise_varies() -> None: + """Output should not be constant.""" - result = subsequence.sequence_utils.pink_noise(100, seed=1) - assert max(result) - min(result) > 0.1 + result = subsequence.sequence_utils.pink_noise(100, seed=1) + assert max(result) - min(result) > 0.1 -def test_pink_noise_spectral_character () -> None: +def test_pink_noise_spectral_character() -> None: + """Adjacent samples should be more correlated than white noise (sources=1).""" - """Adjacent samples should be more correlated than white noise (sources=1).""" + n = 500 + pink = subsequence.sequence_utils.pink_noise(n, sources=16, seed=5) + white = subsequence.sequence_utils.pink_noise(n, sources=1, seed=5) - n = 500 - pink = subsequence.sequence_utils.pink_noise(n, sources=16, seed=5) - white = subsequence.sequence_utils.pink_noise(n, sources=1, seed=5) + def mean_consecutive_diff(seq: list) -> float: + return sum(abs(seq[i + 1] - seq[i]) for i in range(len(seq) - 1)) / ( + len(seq) - 1 + ) - def mean_consecutive_diff (seq: list) -> float: - return sum(abs(seq[i + 1] - seq[i]) for i in range(len(seq) - 1)) / (len(seq) - 1) - - assert mean_consecutive_diff(pink) < mean_consecutive_diff(white) + assert mean_consecutive_diff(pink) < mean_consecutive_diff(white) # --- lsystem_expand --- -def test_lsystem_expand_zero_generations () -> None: - - """generations=0 should return the axiom unchanged.""" - - assert subsequence.sequence_utils.lsystem_expand("ABC", {"A": "AB"}, 0) == "ABC" - - -def test_lsystem_expand_deterministic_single_generation () -> None: - - """One generation of Fibonacci rules should produce the expected string.""" +def test_lsystem_expand_zero_generations() -> None: + """generations=0 should return the axiom unchanged.""" - result = subsequence.sequence_utils.lsystem_expand( - axiom="A", rules={"A": "AB", "B": "A"}, generations=1 - ) - assert result == "AB" + assert subsequence.sequence_utils.lsystem_expand("ABC", {"A": "AB"}, 0) == "ABC" -def test_lsystem_expand_fibonacci_lengths () -> None: +def test_lsystem_expand_deterministic_single_generation() -> None: + """One generation of Fibonacci rules should produce the expected string.""" - """Fibonacci L-system string lengths should follow the Fibonacci sequence.""" + result = subsequence.sequence_utils.lsystem_expand( + axiom="A", rules={"A": "AB", "B": "A"}, generations=1 + ) + assert result == "AB" - rules = {"A": "AB", "B": "A"} - expected_lengths = [1, 2, 3, 5, 8, 13, 21] - for gen, expected_len in enumerate(expected_lengths): - result = subsequence.sequence_utils.lsystem_expand("A", rules, gen) - assert len(result) == expected_len, f"generation {gen}: expected length {expected_len}, got {len(result)}" +def test_lsystem_expand_fibonacci_lengths() -> None: + """Fibonacci L-system string lengths should follow the Fibonacci sequence.""" + rules = {"A": "AB", "B": "A"} + expected_lengths = [1, 2, 3, 5, 8, 13, 21] -def test_lsystem_expand_fibonacci_known_strings () -> None: + for gen, expected_len in enumerate(expected_lengths): + result = subsequence.sequence_utils.lsystem_expand("A", rules, gen) + assert len(result) == expected_len, ( + f"generation {gen}: expected length {expected_len}, got {len(result)}" + ) - """Fibonacci L-system should produce known strings for generations 0-4.""" - rules = {"A": "AB", "B": "A"} - expected = ["A", "AB", "ABA", "ABAAB", "ABAABABA"] +def test_lsystem_expand_fibonacci_known_strings() -> None: + """Fibonacci L-system should produce known strings for generations 0-4.""" - for gen, expected_str in enumerate(expected): - assert subsequence.sequence_utils.lsystem_expand("A", rules, gen) == expected_str + rules = {"A": "AB", "B": "A"} + expected = ["A", "AB", "ABA", "ABAAB", "ABAABABA"] + for gen, expected_str in enumerate(expected): + assert ( + subsequence.sequence_utils.lsystem_expand("A", rules, gen) == expected_str + ) -def test_lsystem_expand_identity_passthrough () -> None: - """Characters not in rules should pass through unchanged.""" +def test_lsystem_expand_identity_passthrough() -> None: + """Characters not in rules should pass through unchanged.""" - result = subsequence.sequence_utils.lsystem_expand( - axiom="AXB", rules={"A": "AB", "B": "A"}, generations=1 - ) - assert result == "ABXA" + result = subsequence.sequence_utils.lsystem_expand( + axiom="AXB", rules={"A": "AB", "B": "A"}, generations=1 + ) + assert result == "ABXA" -def test_lsystem_expand_empty_axiom () -> None: +def test_lsystem_expand_empty_axiom() -> None: + """An empty axiom should return an empty string.""" - """An empty axiom should return an empty string.""" + assert subsequence.sequence_utils.lsystem_expand("", {"A": "AB"}, 5) == "" - assert subsequence.sequence_utils.lsystem_expand("", {"A": "AB"}, 5) == "" +def test_lsystem_expand_empty_rules() -> None: + """Empty rules should leave the string unchanged at any generation.""" -def test_lsystem_expand_empty_rules () -> None: + assert subsequence.sequence_utils.lsystem_expand("ABC", {}, 5) == "ABC" - """Empty rules should leave the string unchanged at any generation.""" - assert subsequence.sequence_utils.lsystem_expand("ABC", {}, 5) == "ABC" +def test_lsystem_expand_stochastic_deterministic() -> None: + """Same rng seed should produce the same stochastic expansion.""" + rules: dict = {"A": [("AB", 3), ("BA", 1)], "B": "A"} + rng_a = random.Random(42) + rng_b = random.Random(42) + a = subsequence.sequence_utils.lsystem_expand("A", rules, 4, rng=rng_a) + b = subsequence.sequence_utils.lsystem_expand("A", rules, 4, rng=rng_b) + assert a == b -def test_lsystem_expand_stochastic_deterministic () -> None: - """Same rng seed should produce the same stochastic expansion.""" +def test_lsystem_expand_stochastic_respects_weights() -> None: + """The higher-weight production should be chosen most often.""" - rules: dict = {"A": [("AB", 3), ("BA", 1)], "B": "A"} - rng_a = random.Random(42) - rng_b = random.Random(42) - a = subsequence.sequence_utils.lsystem_expand("A", rules, 4, rng=rng_a) - b = subsequence.sequence_utils.lsystem_expand("A", rules, 4, rng=rng_b) - assert a == b + import pytest + rules: dict = {"A": [("X", 9), ("Y", 1)]} + counts: dict = {"X": 0, "Y": 0} -def test_lsystem_expand_stochastic_respects_weights () -> None: + for i in range(200): + result = subsequence.sequence_utils.lsystem_expand( + "A", rules, 1, rng=random.Random(i) + ) + counts[result] = counts.get(result, 0) + 1 - """The higher-weight production should be chosen most often.""" + assert counts["X"] > counts["Y"] * 5 - import pytest - rules: dict = {"A": [("X", 9), ("Y", 1)]} - counts: dict = {"X": 0, "Y": 0} +def test_lsystem_expand_stochastic_no_rng_raises() -> None: + """Stochastic rules without rng should raise ValueError.""" - for i in range(200): - result = subsequence.sequence_utils.lsystem_expand( - "A", rules, 1, rng=random.Random(i) - ) - counts[result] = counts.get(result, 0) + 1 + import pytest - assert counts["X"] > counts["Y"] * 5 - - -def test_lsystem_expand_stochastic_no_rng_raises () -> None: - - """Stochastic rules without rng should raise ValueError.""" - - import pytest - - rules: dict = {"A": [("AB", 1), ("BA", 1)]} - with pytest.raises(ValueError): - subsequence.sequence_utils.lsystem_expand("A", rules, 3) + rules: dict = {"A": [("AB", 1), ("BA", 1)]} + with pytest.raises(ValueError): + subsequence.sequence_utils.lsystem_expand("A", rules, 3) # --- perlin_1d_sequence --- -def test_perlin_1d_sequence_length () -> None: - - """Should return exactly count values.""" +def test_perlin_1d_sequence_length() -> None: + """Should return exactly count values.""" - result = subsequence.sequence_utils.perlin_1d_sequence(start=0.0, spacing=0.1, count=16, seed=0) + result = subsequence.sequence_utils.perlin_1d_sequence( + start=0.0, spacing=0.1, count=16, seed=0 + ) - assert len(result) == 16 + assert len(result) == 16 -def test_perlin_1d_sequence_values_in_range () -> None: +def test_perlin_1d_sequence_values_in_range() -> None: + """All values should be in [0.0, 1.0].""" - """All values should be in [0.0, 1.0].""" + result = subsequence.sequence_utils.perlin_1d_sequence( + start=0.0, spacing=0.1, count=32, seed=42 + ) - result = subsequence.sequence_utils.perlin_1d_sequence(start=0.0, spacing=0.1, count=32, seed=42) + assert all(0.0 <= v <= 1.0 for v in result) - assert all(0.0 <= v <= 1.0 for v in result) +def test_perlin_1d_sequence_matches_scalar() -> None: + """Each value should match the equivalent perlin_1d call.""" -def test_perlin_1d_sequence_matches_scalar () -> None: + start, step, count, seed = 1.5, 0.07, 8, 10 - """Each value should match the equivalent perlin_1d call.""" + seq = subsequence.sequence_utils.perlin_1d_sequence(start, step, count, seed) + scalar = [ + subsequence.sequence_utils.perlin_1d(start + i * step, seed) + for i in range(count) + ] - start, step, count, seed = 1.5, 0.07, 8, 10 + assert seq == scalar - seq = subsequence.sequence_utils.perlin_1d_sequence(start, step, count, seed) - scalar = [subsequence.sequence_utils.perlin_1d(start + i * step, seed) for i in range(count)] - assert seq == scalar +def test_perlin_1d_sequence_zero_count() -> None: + """count=0 should return an empty list.""" - -def test_perlin_1d_sequence_zero_count () -> None: - - """count=0 should return an empty list.""" - - assert subsequence.sequence_utils.perlin_1d_sequence(0.0, 0.1, 0) == [] + assert subsequence.sequence_utils.perlin_1d_sequence(0.0, 0.1, 0) == [] # --- perlin_2d_grid --- -def test_perlin_2d_grid_shape () -> None: - - """Should return y_count rows each of length x_count.""" - - grid = subsequence.sequence_utils.perlin_2d_grid(0.0, 0.0, 0.1, 0.1, x_count=4, y_count=3) - - assert len(grid) == 3 - assert all(len(row) == 4 for row in grid) +def test_perlin_2d_grid_shape() -> None: + """Should return y_count rows each of length x_count.""" + grid = subsequence.sequence_utils.perlin_2d_grid( + 0.0, 0.0, 0.1, 0.1, x_count=4, y_count=3 + ) -def test_perlin_2d_grid_values_in_range () -> None: + assert len(grid) == 3 + assert all(len(row) == 4 for row in grid) - """All values should be in [0.0, 1.0].""" - grid = subsequence.sequence_utils.perlin_2d_grid(0.0, 0.0, 0.1, 0.25, x_count=8, y_count=4, seed=5) +def test_perlin_2d_grid_values_in_range() -> None: + """All values should be in [0.0, 1.0].""" - assert all(0.0 <= v <= 1.0 for row in grid for v in row) + grid = subsequence.sequence_utils.perlin_2d_grid( + 0.0, 0.0, 0.1, 0.25, x_count=8, y_count=4, seed=5 + ) + assert all(0.0 <= v <= 1.0 for row in grid for v in row) -def test_perlin_2d_grid_matches_scalar () -> None: - """Each value should match the equivalent perlin_2d call.""" +def test_perlin_2d_grid_matches_scalar() -> None: + """Each value should match the equivalent perlin_2d call.""" - x_start, y_start, x_step, y_step = 0.5, 0.0, 0.1, 0.25 - x_count, y_count, seed = 4, 3, 7 + x_start, y_start, x_step, y_step = 0.5, 0.0, 0.1, 0.25 + x_count, y_count, seed = 4, 3, 7 - grid = subsequence.sequence_utils.perlin_2d_grid(x_start, y_start, x_step, y_step, x_count, y_count, seed) + grid = subsequence.sequence_utils.perlin_2d_grid( + x_start, y_start, x_step, y_step, x_count, y_count, seed + ) - for yi in range(y_count): - for xi in range(x_count): - expected = subsequence.sequence_utils.perlin_2d(x_start + xi * x_step, y_start + yi * y_step, seed) - assert grid[yi][xi] == expected + for yi in range(y_count): + for xi in range(x_count): + expected = subsequence.sequence_utils.perlin_2d( + x_start + xi * x_step, y_start + yi * y_step, seed + ) + assert grid[yi][xi] == expected # --- bank_select --- -def test_bank_select_zero () -> None: - - """Bank 0 → MSB 0, LSB 0.""" - - assert subsequence.midi_utils.bank_select(0) == (0, 0) - - -def test_bank_select_lsb_only () -> None: - - """Banks 1–127 fit in LSB alone (MSB stays 0).""" - - assert subsequence.midi_utils.bank_select(1) == (0, 1) - assert subsequence.midi_utils.bank_select(127) == (0, 127) +def test_bank_select_zero() -> None: + """Bank 0 → MSB 0, LSB 0.""" + assert subsequence.midi_utils.bank_select(0) == (0, 0) -def test_bank_select_msb_boundary () -> None: - """Bank 128 is the first bank requiring MSB=1.""" +def test_bank_select_lsb_only() -> None: + """Banks 1–127 fit in LSB alone (MSB stays 0).""" - assert subsequence.midi_utils.bank_select(128) == (1, 0) + assert subsequence.midi_utils.bank_select(1) == (0, 1) + assert subsequence.midi_utils.bank_select(127) == (0, 127) -def test_bank_select_mixed () -> None: +def test_bank_select_msb_boundary() -> None: + """Bank 128 is the first bank requiring MSB=1.""" - """Bank 256 → MSB 2, LSB 0; bank 257 → MSB 2, LSB 1.""" + assert subsequence.midi_utils.bank_select(128) == (1, 0) - assert subsequence.midi_utils.bank_select(256) == (2, 0) - assert subsequence.midi_utils.bank_select(257) == (2, 1) +def test_bank_select_mixed() -> None: + """Bank 256 → MSB 2, LSB 0; bank 257 → MSB 2, LSB 1.""" -def test_bank_select_max () -> None: + assert subsequence.midi_utils.bank_select(256) == (2, 0) + assert subsequence.midi_utils.bank_select(257) == (2, 1) - """Maximum 14-bit bank (16383) → MSB 127, LSB 127.""" - assert subsequence.midi_utils.bank_select(16383) == (127, 127) +def test_bank_select_max() -> None: + """Maximum 14-bit bank (16383) → MSB 127, LSB 127.""" + assert subsequence.midi_utils.bank_select(16383) == (127, 127) -def test_bank_select_clamps_negative () -> None: - """Negative values are clamped to bank 0.""" +def test_bank_select_clamps_negative() -> None: + """Negative values are clamped to bank 0.""" - assert subsequence.midi_utils.bank_select(-1) == (0, 0) + assert subsequence.midi_utils.bank_select(-1) == (0, 0) -def test_bank_select_clamps_overflow () -> None: +def test_bank_select_clamps_overflow() -> None: + """Values above 16383 are clamped to the maximum bank.""" - """Values above 16383 are clamped to the maximum bank.""" + assert subsequence.midi_utils.bank_select(99999) == (127, 127) - assert subsequence.midi_utils.bank_select(99999) == (127, 127) +def test_bank_select_roundtrip() -> None: + """MSB and LSB reconstruct the original bank number.""" -def test_bank_select_roundtrip () -> None: - - """MSB and LSB reconstruct the original bank number.""" - - for bank in [0, 1, 127, 128, 255, 256, 1000, 16383]: - msb, lsb = subsequence.midi_utils.bank_select(bank) - assert (msb << 7) | lsb == bank + for bank in [0, 1, 127, 128, 255, 256, 1000, 16383]: + msb, lsb = subsequence.midi_utils.bank_select(bank) + assert (msb << 7) | lsb == bank # --- thue_morse --- -def test_thue_morse_correct_length () -> None: - - """thue_morse(n) returns a list of exactly n values.""" - - assert len(subsequence.sequence_utils.thue_morse(16)) == 16 - - -def test_thue_morse_zero_returns_empty () -> None: +def test_thue_morse_correct_length() -> None: + """thue_morse(n) returns a list of exactly n values.""" - """thue_morse(0) returns an empty list.""" + assert len(subsequence.sequence_utils.thue_morse(16)) == 16 - assert subsequence.sequence_utils.thue_morse(0) == [] +def test_thue_morse_zero_returns_empty() -> None: + """thue_morse(0) returns an empty list.""" -def test_thue_morse_negative_returns_empty () -> None: + assert subsequence.sequence_utils.thue_morse(0) == [] - """thue_morse with negative n returns an empty list.""" - assert subsequence.sequence_utils.thue_morse(-5) == [] +def test_thue_morse_negative_returns_empty() -> None: + """thue_morse with negative n returns an empty list.""" + assert subsequence.sequence_utils.thue_morse(-5) == [] -def test_thue_morse_binary_values () -> None: - """All values in thue_morse output are 0 or 1.""" +def test_thue_morse_binary_values() -> None: + """All values in thue_morse output are 0 or 1.""" - result = subsequence.sequence_utils.thue_morse(32) - assert all(v in (0, 1) for v in result) + result = subsequence.sequence_utils.thue_morse(32) + assert all(v in (0, 1) for v in result) -def test_thue_morse_known_prefix () -> None: +def test_thue_morse_known_prefix() -> None: + """First 8 values match the well-known sequence 0 1 1 0 1 0 0 1.""" - """First 8 values match the well-known sequence 0 1 1 0 1 0 0 1.""" + assert subsequence.sequence_utils.thue_morse(8) == [0, 1, 1, 0, 1, 0, 0, 1] - assert subsequence.sequence_utils.thue_morse(8) == [0, 1, 1, 0, 1, 0, 0, 1] +def test_thue_morse_deterministic() -> None: + """Two calls with the same n produce identical results.""" -def test_thue_morse_deterministic () -> None: - - """Two calls with the same n produce identical results.""" - - assert ( - subsequence.sequence_utils.thue_morse(24) - == subsequence.sequence_utils.thue_morse(24) - ) + assert subsequence.sequence_utils.thue_morse( + 24 + ) == subsequence.sequence_utils.thue_morse(24) # --- de_bruijn --- -def test_de_bruijn_correct_length () -> None: - - """de_bruijn(k, n) returns a list of k**n values.""" - - assert len(subsequence.sequence_utils.de_bruijn(2, 3)) == 8 - - -def test_de_bruijn_zero_k_returns_empty () -> None: +def test_de_bruijn_correct_length() -> None: + """de_bruijn(k, n) returns a list of k**n values.""" - """de_bruijn(0, n) returns an empty list.""" + assert len(subsequence.sequence_utils.de_bruijn(2, 3)) == 8 - assert subsequence.sequence_utils.de_bruijn(0, 3) == [] +def test_de_bruijn_zero_k_returns_empty() -> None: + """de_bruijn(0, n) returns an empty list.""" -def test_de_bruijn_zero_n_returns_empty () -> None: + assert subsequence.sequence_utils.de_bruijn(0, 3) == [] - """de_bruijn(k, 0) returns an empty list.""" - assert subsequence.sequence_utils.de_bruijn(3, 0) == [] +def test_de_bruijn_zero_n_returns_empty() -> None: + """de_bruijn(k, 0) returns an empty list.""" + assert subsequence.sequence_utils.de_bruijn(3, 0) == [] -def test_de_bruijn_symbol_range () -> None: - """All symbols in de_bruijn(k, n) are in [0, k-1].""" +def test_de_bruijn_symbol_range() -> None: + """All symbols in de_bruijn(k, n) are in [0, k-1].""" - result = subsequence.sequence_utils.de_bruijn(3, 2) - assert all(0 <= v <= 2 for v in result) + result = subsequence.sequence_utils.de_bruijn(3, 2) + assert all(0 <= v <= 2 for v in result) -def test_de_bruijn_all_subsequences_present () -> None: +def test_de_bruijn_all_subsequences_present() -> None: + """Every n-gram over k symbols appears in the cyclic de Bruijn sequence.""" - """Every n-gram over k symbols appears in the cyclic de Bruijn sequence.""" + k, n = 2, 3 + seq = subsequence.sequence_utils.de_bruijn(k, n) + cyclic = seq + seq[: n - 1] - k, n = 2, 3 - seq = subsequence.sequence_utils.de_bruijn(k, n) - cyclic = seq + seq[:n - 1] + ngrams = set() + for i in range(len(seq)): + ngram = tuple(cyclic[i : i + n]) + ngrams.add(ngram) - ngrams = set() - for i in range(len(seq)): - ngram = tuple(cyclic[i:i + n]) - ngrams.add(ngram) + expected = k**n + assert len(ngrams) == expected - expected = k ** n - assert len(ngrams) == expected +def test_de_bruijn_deterministic() -> None: + """Two calls with the same parameters produce identical results.""" -def test_de_bruijn_deterministic () -> None: - - """Two calls with the same parameters produce identical results.""" - - assert ( - subsequence.sequence_utils.de_bruijn(3, 2) - == subsequence.sequence_utils.de_bruijn(3, 2) - ) + assert subsequence.sequence_utils.de_bruijn( + 3, 2 + ) == subsequence.sequence_utils.de_bruijn(3, 2) # --- fibonacci_rhythm --- -def test_fibonacci_rhythm_correct_length () -> None: - - """fibonacci_rhythm(n) returns a list of exactly n values.""" - - assert len(subsequence.sequence_utils.fibonacci_rhythm(8)) == 8 - - -def test_fibonacci_rhythm_zero_returns_empty () -> None: +def test_fibonacci_rhythm_correct_length() -> None: + """fibonacci_rhythm(n) returns a list of exactly n values.""" - """fibonacci_rhythm(0) returns an empty list.""" + assert len(subsequence.sequence_utils.fibonacci_rhythm(8)) == 8 - assert subsequence.sequence_utils.fibonacci_rhythm(0) == [] +def test_fibonacci_rhythm_zero_returns_empty() -> None: + """fibonacci_rhythm(0) returns an empty list.""" -def test_fibonacci_rhythm_within_range () -> None: + assert subsequence.sequence_utils.fibonacci_rhythm(0) == [] - """All positions are within [0.0, length).""" - result = subsequence.sequence_utils.fibonacci_rhythm(20, length=4.0) - assert all(0.0 <= v < 4.0 for v in result) +def test_fibonacci_rhythm_within_range() -> None: + """All positions are within [0.0, length).""" + result = subsequence.sequence_utils.fibonacci_rhythm(20, length=4.0) + assert all(0.0 <= v < 4.0 for v in result) -def test_fibonacci_rhythm_sorted () -> None: - """Output is sorted in ascending order.""" +def test_fibonacci_rhythm_sorted() -> None: + """Output is sorted in ascending order.""" - result = subsequence.sequence_utils.fibonacci_rhythm(16) - assert result == sorted(result) + result = subsequence.sequence_utils.fibonacci_rhythm(16) + assert result == sorted(result) -def test_fibonacci_rhythm_deterministic () -> None: +def test_fibonacci_rhythm_deterministic() -> None: + """Two calls with the same arguments produce identical results.""" - """Two calls with the same arguments produce identical results.""" + assert subsequence.sequence_utils.fibonacci_rhythm( + 12 + ) == subsequence.sequence_utils.fibonacci_rhythm(12) - assert ( - subsequence.sequence_utils.fibonacci_rhythm(12) - == subsequence.sequence_utils.fibonacci_rhythm(12) - ) +def test_fibonacci_rhythm_no_duplicates() -> None: + """All positions are distinct for a reasonable step count.""" -def test_fibonacci_rhythm_no_duplicates () -> None: - - """All positions are distinct for a reasonable step count.""" - - result = subsequence.sequence_utils.fibonacci_rhythm(16, length=4.0) - assert len(result) == len(set(result)) + result = subsequence.sequence_utils.fibonacci_rhythm(16, length=4.0) + assert len(result) == len(set(result)) # --- lorenz_attractor --- -def test_lorenz_attractor_correct_length () -> None: - - """lorenz_attractor(n) returns a list of exactly n tuples.""" - - assert len(subsequence.sequence_utils.lorenz_attractor(16)) == 16 - - -def test_lorenz_attractor_zero_returns_empty () -> None: +def test_lorenz_attractor_correct_length() -> None: + """lorenz_attractor(n) returns a list of exactly n tuples.""" - """lorenz_attractor(0) returns an empty list.""" + assert len(subsequence.sequence_utils.lorenz_attractor(16)) == 16 - assert subsequence.sequence_utils.lorenz_attractor(0) == [] +def test_lorenz_attractor_zero_returns_empty() -> None: + """lorenz_attractor(0) returns an empty list.""" -def test_lorenz_attractor_three_components () -> None: + assert subsequence.sequence_utils.lorenz_attractor(0) == [] - """Each element is a 3-tuple.""" - result = subsequence.sequence_utils.lorenz_attractor(8) - assert all(len(t) == 3 for t in result) +def test_lorenz_attractor_three_components() -> None: + """Each element is a 3-tuple.""" + result = subsequence.sequence_utils.lorenz_attractor(8) + assert all(len(t) == 3 for t in result) -def test_lorenz_attractor_normalised_range () -> None: - """All x, y, z components are in [0.0, 1.0].""" +def test_lorenz_attractor_normalised_range() -> None: + """All x, y, z components are in [0.0, 1.0].""" - result = subsequence.sequence_utils.lorenz_attractor(100) - for x, y, z in result: - assert 0.0 <= x <= 1.0 - assert 0.0 <= y <= 1.0 - assert 0.0 <= z <= 1.0 + result = subsequence.sequence_utils.lorenz_attractor(100) + for x, y, z in result: + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + assert 0.0 <= z <= 1.0 -def test_lorenz_attractor_deterministic () -> None: +def test_lorenz_attractor_deterministic() -> None: + """Same parameters produce identical output.""" - """Same parameters produce identical output.""" + a = subsequence.sequence_utils.lorenz_attractor(20, x0=0.1, y0=0.0, z0=0.0) + b = subsequence.sequence_utils.lorenz_attractor(20, x0=0.1, y0=0.0, z0=0.0) + assert a == b - a = subsequence.sequence_utils.lorenz_attractor(20, x0=0.1, y0=0.0, z0=0.0) - b = subsequence.sequence_utils.lorenz_attractor(20, x0=0.1, y0=0.0, z0=0.0) - assert a == b +def test_lorenz_attractor_sensitive_to_initial_conditions() -> None: + """Different initial conditions eventually produce different output.""" -def test_lorenz_attractor_sensitive_to_initial_conditions () -> None: - - """Different initial conditions eventually produce different output.""" - - a = subsequence.sequence_utils.lorenz_attractor(100, x0=0.1) - b = subsequence.sequence_utils.lorenz_attractor(100, x0=0.2) - assert a != b + a = subsequence.sequence_utils.lorenz_attractor(100, x0=0.1) + b = subsequence.sequence_utils.lorenz_attractor(100, x0=0.2) + assert a != b # --- reaction_diffusion_1d --- -def test_reaction_diffusion_1d_correct_length () -> None: - - """reaction_diffusion_1d(width) returns a list of exactly width values.""" - - assert len(subsequence.sequence_utils.reaction_diffusion_1d(16, steps=100)) == 16 - - -def test_reaction_diffusion_1d_zero_returns_empty () -> None: +def test_reaction_diffusion_1d_correct_length() -> None: + """reaction_diffusion_1d(width) returns a list of exactly width values.""" - """reaction_diffusion_1d(0) returns an empty list.""" + assert len(subsequence.sequence_utils.reaction_diffusion_1d(16, steps=100)) == 16 - assert subsequence.sequence_utils.reaction_diffusion_1d(0) == [] +def test_reaction_diffusion_1d_zero_returns_empty() -> None: + """reaction_diffusion_1d(0) returns an empty list.""" -def test_reaction_diffusion_1d_normalised_range () -> None: + assert subsequence.sequence_utils.reaction_diffusion_1d(0) == [] - """All values are in [0.0, 1.0].""" - result = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=500) - assert all(0.0 <= v <= 1.0 for v in result) +def test_reaction_diffusion_1d_normalised_range() -> None: + """All values are in [0.0, 1.0].""" + result = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=500) + assert all(0.0 <= v <= 1.0 for v in result) -def test_reaction_diffusion_1d_deterministic () -> None: - """Same parameters produce identical output.""" +def test_reaction_diffusion_1d_deterministic() -> None: + """Same parameters produce identical output.""" - a = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=200) - b = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=200) - assert a == b + a = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=200) + b = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=200) + assert a == b -def test_reaction_diffusion_1d_has_structure () -> None: +def test_reaction_diffusion_1d_has_structure() -> None: + """Not all values are identical for default parameters.""" - """Not all values are identical for default parameters.""" + result = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=500) + assert len(set(result)) > 1 - result = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=500) - assert len(set(result)) > 1 +def test_reaction_diffusion_1d_different_rates_differ() -> None: + """Different feed/kill rates produce different patterns.""" -def test_reaction_diffusion_1d_different_rates_differ () -> None: - - """Different feed/kill rates produce different patterns.""" - - a = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=300, feed_rate=0.055, kill_rate=0.062) - b = subsequence.sequence_utils.reaction_diffusion_1d(16, steps=300, feed_rate=0.037, kill_rate=0.060) - assert a != b + a = subsequence.sequence_utils.reaction_diffusion_1d( + 16, steps=300, feed_rate=0.055, kill_rate=0.062 + ) + b = subsequence.sequence_utils.reaction_diffusion_1d( + 16, steps=300, feed_rate=0.037, kill_rate=0.060 + ) + assert a != b # --- self_avoiding_walk --- -def test_self_avoiding_walk_correct_length () -> None: - - """self_avoiding_walk(n) returns a list of exactly n values.""" - - rng = random.Random(1) - assert len(subsequence.sequence_utils.self_avoiding_walk(16, 0, 7, rng)) == 16 - - -def test_self_avoiding_walk_zero_returns_empty () -> None: +def test_self_avoiding_walk_correct_length() -> None: + """self_avoiding_walk(n) returns a list of exactly n values.""" - """self_avoiding_walk(0) returns an empty list.""" + rng = random.Random(1) + assert len(subsequence.sequence_utils.self_avoiding_walk(16, 0, 7, rng)) == 16 - rng = random.Random(1) - assert subsequence.sequence_utils.self_avoiding_walk(0, 0, 7, rng) == [] +def test_self_avoiding_walk_zero_returns_empty() -> None: + """self_avoiding_walk(0) returns an empty list.""" -def test_self_avoiding_walk_within_range () -> None: + rng = random.Random(1) + assert subsequence.sequence_utils.self_avoiding_walk(0, 0, 7, rng) == [] - """All values are within [low, high].""" - rng = random.Random(1) - result = subsequence.sequence_utils.self_avoiding_walk(16, 2, 8, rng) - assert all(2 <= v <= 8 for v in result) +def test_self_avoiding_walk_within_range() -> None: + """All values are within [low, high].""" + rng = random.Random(1) + result = subsequence.sequence_utils.self_avoiding_walk(16, 2, 8, rng) + assert all(2 <= v <= 8 for v in result) -def test_self_avoiding_walk_no_immediate_repeat () -> None: - """No two consecutive values are identical (walk always moves).""" +def test_self_avoiding_walk_no_immediate_repeat() -> None: + """No two consecutive values are identical (walk always moves).""" - rng = random.Random(1) - result = subsequence.sequence_utils.self_avoiding_walk(20, 0, 9, rng) - for i in range(len(result) - 1): - assert result[i] != result[i + 1] + rng = random.Random(1) + result = subsequence.sequence_utils.self_avoiding_walk(20, 0, 9, rng) + for i in range(len(result) - 1): + assert result[i] != result[i + 1] -def test_self_avoiding_walk_step_size_one () -> None: +def test_self_avoiding_walk_step_size_one() -> None: + """Each step changes the value by exactly 1.""" - """Each step changes the value by exactly 1.""" + rng = random.Random(1) + result = subsequence.sequence_utils.self_avoiding_walk(16, 0, 9, rng) + for i in range(len(result) - 1): + assert abs(result[i + 1] - result[i]) == 1 - rng = random.Random(1) - result = subsequence.sequence_utils.self_avoiding_walk(16, 0, 9, rng) - for i in range(len(result) - 1): - assert abs(result[i + 1] - result[i]) == 1 +def test_self_avoiding_walk_deterministic() -> None: + """Same seed produces identical output.""" -def test_self_avoiding_walk_deterministic () -> None: + a = subsequence.sequence_utils.self_avoiding_walk(16, 0, 7, random.Random(99)) + b = subsequence.sequence_utils.self_avoiding_walk(16, 0, 7, random.Random(99)) + assert a == b - """Same seed produces identical output.""" - a = subsequence.sequence_utils.self_avoiding_walk(16, 0, 7, random.Random(99)) - b = subsequence.sequence_utils.self_avoiding_walk(16, 0, 7, random.Random(99)) - assert a == b +def test_self_avoiding_walk_custom_start() -> None: + """First value equals the start parameter.""" - -def test_self_avoiding_walk_custom_start () -> None: - - """First value equals the start parameter.""" - - rng = random.Random(1) - result = subsequence.sequence_utils.self_avoiding_walk(8, 0, 9, rng, start=3) - assert result[0] == 3 + rng = random.Random(1) + result = subsequence.sequence_utils.self_avoiding_walk(8, 0, 9, rng, start=3) + assert result[0] == 3 # ── branch_sequence: the variation-tree kernel ────────────────────────────── -def test_branch_sequence_is_deterministic_per_trunk () -> None: - - """The tree derives from the pitch content — same trunk, same variation.""" - - a = subsequence.sequence_utils.branch_sequence([60, 64, 67, 72], depth=3, path=5) - b = subsequence.sequence_utils.branch_sequence([60, 64, 67, 72], depth=3, path=5) - assert a == b - -def test_branch_sequence_path_wraps () -> None: +def test_branch_sequence_is_deterministic_per_trunk() -> None: + """The tree derives from the pitch content — same trunk, same variation.""" - """path wraps modulo 2 ** depth.""" + a = subsequence.sequence_utils.branch_sequence([60, 64, 67, 72], depth=3, path=5) + b = subsequence.sequence_utils.branch_sequence([60, 64, 67, 72], depth=3, path=5) + assert a == b - trunk = [60, 64, 67] - assert (subsequence.sequence_utils.branch_sequence(trunk, depth=2, path=1) - == subsequence.sequence_utils.branch_sequence(trunk, depth=2, path=5)) +def test_branch_sequence_path_wraps() -> None: + """path wraps modulo 2 ** depth.""" -def test_branch_sequence_preserves_length_and_relatedness () -> None: + trunk = [60, 64, 67] + assert subsequence.sequence_utils.branch_sequence( + trunk, depth=2, path=1 + ) == subsequence.sequence_utils.branch_sequence(trunk, depth=2, path=5) - """Every variation has the trunk's length (order/interval ops only).""" - trunk = [60, 64, 67, 72] - for path in range(8): - variation = subsequence.sequence_utils.branch_sequence(trunk, depth=3, path=path) - assert len(variation) == len(trunk) +def test_branch_sequence_preserves_length_and_relatedness() -> None: + """Every variation has the trunk's length (order/interval ops only).""" + trunk = [60, 64, 67, 72] + for path in range(8): + variation = subsequence.sequence_utils.branch_sequence( + trunk, depth=3, path=path + ) + assert len(variation) == len(trunk) -def test_branch_sequence_mutation_uses_caller_rng () -> None: - """mutation=1.0 substitutes only trunk pitches, deterministically per rng.""" +def test_branch_sequence_mutation_uses_caller_rng() -> None: + """mutation=1.0 substitutes only trunk pitches, deterministically per rng.""" - trunk = [60, 64, 67] - a = subsequence.sequence_utils.branch_sequence(trunk, depth=1, path=0, mutation=1.0, rng=random.Random(5)) - b = subsequence.sequence_utils.branch_sequence(trunk, depth=1, path=0, mutation=1.0, rng=random.Random(5)) - assert a == b - assert all(pitch in trunk for pitch in a) + trunk = [60, 64, 67] + a = subsequence.sequence_utils.branch_sequence( + trunk, depth=1, path=0, mutation=1.0, rng=random.Random(5) + ) + b = subsequence.sequence_utils.branch_sequence( + trunk, depth=1, path=0, mutation=1.0, rng=random.Random(5) + ) + assert a == b + assert all(pitch in trunk for pitch in a) -def test_branch_sequence_empty_raises () -> None: +def test_branch_sequence_empty_raises() -> None: + """An empty trunk is a configuration error.""" - """An empty trunk is a configuration error.""" + import pytest - import pytest + with pytest.raises(ValueError, match="empty"): + subsequence.sequence_utils.branch_sequence([]) - with pytest.raises(ValueError, match="empty"): - subsequence.sequence_utils.branch_sequence([]) +def test_branch_sequence_feeds_motif_notes() -> None: + """The kernel bridges to the value layer with zero new API.""" -def test_branch_sequence_feeds_motif_notes () -> None: + import subsequence as s - """The kernel bridges to the value layer with zero new API.""" - - import subsequence as s - - variation = subsequence.sequence_utils.branch_sequence([60, 64, 67, 72], depth=2, path=3) - m = s.Motif.notes(variation, durations=0.25) - assert len(m.events) == 4 + variation = subsequence.sequence_utils.branch_sequence( + [60, 64, 67, 72], depth=2, path=3 + ) + m = s.Motif.notes(variation, durations=0.25) + assert len(m.events) == 4 # --------------------------------------------------------------------------- # vl_distance() — the Tymoczko voice-leading kernel # --------------------------------------------------------------------------- -def test_vl_distance_c_to_f () -> None: - - """C major to F major: C→C (0), E→F (1), G→A (2) = 3.""" - - assert subsequence.sequence_utils.vl_distance([0, 4, 7], [5, 9, 0]) == 3 - - -def test_vl_distance_c_to_a_minor () -> None: - """C major to A minor share two tones: only G→A moves (2).""" +def test_vl_distance_c_to_f() -> None: + """C major to F major: C→C (0), E→F (1), G→A (2) = 3.""" - assert subsequence.sequence_utils.vl_distance([0, 4, 7], [9, 0, 4]) == 2 + assert subsequence.sequence_utils.vl_distance([0, 4, 7], [5, 9, 0]) == 3 -def test_vl_distance_identity_is_zero () -> None: +def test_vl_distance_c_to_a_minor() -> None: + """C major to A minor share two tones: only G→A moves (2).""" - """A chord is zero distance from itself.""" + assert subsequence.sequence_utils.vl_distance([0, 4, 7], [9, 0, 4]) == 2 - assert subsequence.sequence_utils.vl_distance([0, 4, 7], [0, 4, 7]) == 0 +def test_vl_distance_identity_is_zero() -> None: + """A chord is zero distance from itself.""" -def test_vl_distance_is_symmetric () -> None: + assert subsequence.sequence_utils.vl_distance([0, 4, 7], [0, 4, 7]) == 0 - """Distance is a metric: order of arguments must not matter.""" - a, b = [0, 3, 7], [2, 5, 9, 0] +def test_vl_distance_is_symmetric() -> None: + """Distance is a metric: order of arguments must not matter.""" - assert ( - subsequence.sequence_utils.vl_distance(a, b) - == subsequence.sequence_utils.vl_distance(b, a) - ) + a, b = [0, 3, 7], [2, 5, 9, 0] + assert subsequence.sequence_utils.vl_distance( + a, b + ) == subsequence.sequence_utils.vl_distance(b, a) -def test_vl_distance_wraps_pitch_class_circle () -> None: - """B→C is one semitone around the circle, not eleven.""" +def test_vl_distance_wraps_pitch_class_circle() -> None: + """B→C is one semitone around the circle, not eleven.""" - assert subsequence.sequence_utils.vl_distance([11], [0]) == 1 + assert subsequence.sequence_utils.vl_distance([11], [0]) == 1 -def test_vl_distance_unequal_sizes_doubles_smaller () -> None: +def test_vl_distance_unequal_sizes_doubles_smaller() -> None: + """Triad → seventh chord doubles a triad tone; nothing is dropped. - """Triad → seventh chord doubles a triad tone; nothing is dropped. + C major → C7: C, E, G map to themselves and one of them doubles to + cover Bb — the cheapest doubling is C→Bb (2, down around the circle). + """ - C major → C7: C, E, G map to themselves and one of them doubles to - cover Bb — the cheapest doubling is C→Bb (2, down around the circle). - """ + assert subsequence.sequence_utils.vl_distance([0, 4, 7], [0, 4, 7, 10]) == 2 - assert subsequence.sequence_utils.vl_distance([0, 4, 7], [0, 4, 7, 10]) == 2 +def test_vl_distance_absolute_pitch_mode() -> None: + """pitch_classes=False scores literal MIDI movement (no circle wrap).""" -def test_vl_distance_absolute_pitch_mode () -> None: + assert subsequence.sequence_utils.vl_distance([60], [72], pitch_classes=False) == 12 + # The default pitch-class mode folds 60 and 72 to the same pc, so 0 is correct. + assert subsequence.sequence_utils.vl_distance([60], [72]) == 0 - """pitch_classes=False scores literal MIDI movement (no circle wrap).""" - assert subsequence.sequence_utils.vl_distance([60], [72], pitch_classes=False) == 12 - # The default pitch-class mode folds 60 and 72 to the same pc, so 0 is correct. - assert subsequence.sequence_utils.vl_distance([60], [72]) == 0 +def test_vl_distance_empty_raises() -> None: + """Empty chords are an error, not a silent zero.""" + import pytest -def test_vl_distance_empty_raises () -> None: - - """Empty chords are an error, not a silent zero.""" - - import pytest - - with pytest.raises(ValueError): - subsequence.sequence_utils.vl_distance([], [0, 4, 7]) + with pytest.raises(ValueError): + subsequence.sequence_utils.vl_distance([], [0, 4, 7]) # --------------------------------------------------------------------------- # constrained_walk() — the shared hybrid kernel # --------------------------------------------------------------------------- -def _diamond_graph () -> "subsequence.weighted_graph.WeightedGraph": - - """a → {b, c} → d → a, plus d → e (a dead-endish tail via e → e).""" - - import subsequence.weighted_graph - - graph: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - graph.add_transition("a", "b", 1) - graph.add_transition("a", "c", 1) - graph.add_transition("b", "d", 1) - graph.add_transition("c", "d", 1) - graph.add_transition("d", "a", 1) - graph.add_transition("d", "e", 1) - graph.add_transition("e", "e", 1) - return graph - - -def test_constrained_walk_satisfies_end () -> None: - - """end= lands the final position, every time, under any seed.""" - - graph = _diamond_graph() - - for seed in range(20): - walk = subsequence.sequence_utils.constrained_walk( - graph, "a", 4, random.Random(seed), end="a", - ) - assert len(walk) == 4 - assert walk[0] == "a" and walk[-1] == "a" - -def test_constrained_walk_satisfies_pins_and_avoid () -> None: +def _diamond_graph() -> "subsequence.weighted_graph.WeightedGraph": + """a → {b, c} → d → a, plus d → e (a dead-endish tail via e → e).""" - """Pins fix interior positions; avoid excludes chosen positions.""" + import subsequence.weighted_graph - graph = _diamond_graph() + graph: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) + graph.add_transition("a", "b", 1) + graph.add_transition("a", "c", 1) + graph.add_transition("b", "d", 1) + graph.add_transition("c", "d", 1) + graph.add_transition("d", "a", 1) + graph.add_transition("d", "e", 1) + graph.add_transition("e", "e", 1) + return graph - for seed in range(20): - walk = subsequence.sequence_utils.constrained_walk( - graph, "a", 4, random.Random(seed), pins={2: "b"}, avoid=["e"], - ) - assert walk[1] == "b" - assert "e" not in walk +def test_constrained_walk_satisfies_end() -> None: + """end= lands the final position, every time, under any seed.""" -def test_constrained_walk_unconstrained_matches_choose_next () -> None: + graph = _diamond_graph() - """No constraints → draw-for-draw identical to repeated choose_next().""" + for seed in range(20): + walk = subsequence.sequence_utils.constrained_walk( + graph, + "a", + 4, + random.Random(seed), + end="a", + ) + assert len(walk) == 4 + assert walk[0] == "a" and walk[-1] == "a" - graph = _diamond_graph() - walked = subsequence.sequence_utils.constrained_walk(graph, "a", 6, random.Random(9)) +def test_constrained_walk_satisfies_pins_and_avoid() -> None: + """Pins fix interior positions; avoid excludes chosen positions.""" + + graph = _diamond_graph() + + for seed in range(20): + walk = subsequence.sequence_utils.constrained_walk( + graph, + "a", + 4, + random.Random(seed), + pins={2: "b"}, + avoid=["e"], + ) + assert walk[1] == "b" + assert "e" not in walk - rng = random.Random(9) - expected = ["a"] - for _ in range(5): - expected.append(graph.choose_next(expected[-1], rng)) - assert walked == expected +def test_constrained_walk_unconstrained_matches_choose_next() -> None: + """No constraints → draw-for-draw identical to repeated choose_next().""" + + graph = _diamond_graph() + + walked = subsequence.sequence_utils.constrained_walk( + graph, "a", 6, random.Random(9) + ) + rng = random.Random(9) + expected = ["a"] + for _ in range(5): + expected.append(graph.choose_next(expected[-1], rng)) -def test_constrained_walk_infeasible_raises_before_any_draw () -> None: + assert walked == expected - """Unsatisfiable pins fail loudly, naming the position — no RNG consumed.""" - import pytest +def test_constrained_walk_infeasible_raises_before_any_draw() -> None: + """Unsatisfiable pins fail loudly, naming the position — no RNG consumed.""" - graph = _diamond_graph() + import pytest - class Exploding(random.Random): - def uniform (self, a: float, b: float) -> float: - raise AssertionError("rng must not be consumed for an infeasible walk") + graph = _diamond_graph() - # b is not reachable in one step from d, and d→{a,e}: pin b at 3 after d at 2. - with pytest.raises(ValueError, match="position"): - subsequence.sequence_utils.constrained_walk( - graph, "a", 3, Exploding(), pins={2: "d", 3: "b"}, - ) + class Exploding(random.Random): + def uniform(self, a: float, b: float) -> float: + raise AssertionError("rng must not be consumed for an infeasible walk") + # b is not reachable in one step from d, and d→{a,e}: pin b at 3 after d at 2. + with pytest.raises(ValueError, match="position"): + subsequence.sequence_utils.constrained_walk( + graph, + "a", + 3, + Exploding(), + pins={2: "d", 3: "b"}, + ) -def test_constrained_walk_pin_not_in_graph_raises () -> None: - """A pin outside the graph's vocabulary is named in the error.""" +def test_constrained_walk_pin_not_in_graph_raises() -> None: + """A pin outside the graph's vocabulary is named in the error.""" - import pytest + import pytest - graph = _diamond_graph() + graph = _diamond_graph() - with pytest.raises(ValueError, match="vocabulary"): - subsequence.sequence_utils.constrained_walk( - graph, "a", 4, random.Random(0), pins={3: "z"}, - ) + with pytest.raises(ValueError, match="vocabulary"): + subsequence.sequence_utils.constrained_walk( + graph, + "a", + 4, + random.Random(0), + pins={3: "z"}, + ) -def test_constrained_walk_conflicting_constraints_raise () -> None: +def test_constrained_walk_conflicting_constraints_raise() -> None: + """end vs pins[length], pin-in-avoid, and pins[1] != start all conflict.""" - """end vs pins[length], pin-in-avoid, and pins[1] != start all conflict.""" + import pytest - import pytest + graph = _diamond_graph() - graph = _diamond_graph() + with pytest.raises(ValueError, match="same position"): + subsequence.sequence_utils.constrained_walk( + graph, + "a", + 4, + random.Random(0), + pins={4: "d"}, + end="a", + ) + with pytest.raises(ValueError, match="forbidden"): + subsequence.sequence_utils.constrained_walk( + graph, + "a", + 4, + random.Random(0), + pins={3: "d"}, + avoid=["d"], + ) + with pytest.raises(ValueError, match="position 1"): + subsequence.sequence_utils.constrained_walk( + graph, + "a", + 4, + random.Random(0), + pins={1: "b"}, + ) - with pytest.raises(ValueError, match="same position"): - subsequence.sequence_utils.constrained_walk( - graph, "a", 4, random.Random(0), pins={4: "d"}, end="a", - ) - with pytest.raises(ValueError, match="forbidden"): - subsequence.sequence_utils.constrained_walk( - graph, "a", 4, random.Random(0), pins={3: "d"}, avoid=["d"], - ) - with pytest.raises(ValueError, match="position 1"): - subsequence.sequence_utils.constrained_walk( - graph, "a", 4, random.Random(0), pins={1: "b"}, - ) +def test_constrained_walk_avoid_absent_node_is_trivially_satisfied() -> None: + """Avoiding a chord the graph cannot produce is allowed, not an error.""" -def test_constrained_walk_avoid_absent_node_is_trivially_satisfied () -> None: + graph = _diamond_graph() - """Avoiding a chord the graph cannot produce is allowed, not an error.""" + walk = subsequence.sequence_utils.constrained_walk( + graph, + "a", + 3, + random.Random(0), + avoid=["zz"], + ) - graph = _diamond_graph() + assert len(walk) == 3 - walk = subsequence.sequence_utils.constrained_walk( - graph, "a", 3, random.Random(0), avoid=["zz"], - ) - assert len(walk) == 3 +def test_constrained_walk_stall_falls_back_to_bare_weights() -> None: + """A modifier that suppresses every candidate stalls — the walk completes anyway.""" + graph = _diamond_graph() -def test_constrained_walk_stall_falls_back_to_bare_weights () -> None: + walk = subsequence.sequence_utils.constrained_walk( + graph, + "a", + 4, + random.Random(3), + end="a", + weight_modifier=lambda source, target, weight: 0.0, + ) - """A modifier that suppresses every candidate stalls — the walk completes anyway.""" + assert walk[-1] == "a" - graph = _diamond_graph() - walk = subsequence.sequence_utils.constrained_walk( - graph, "a", 4, random.Random(3), end="a", - weight_modifier=lambda source, target, weight: 0.0, - ) +def test_constrained_walk_history_hooks_fire_in_order() -> None: + """before_choice sees each source; after_choice sees each chosen node.""" - assert walk[-1] == "a" + graph = _diamond_graph() + log: list = [] + walk = subsequence.sequence_utils.constrained_walk( + graph, + "a", + 3, + random.Random(1), + before_choice=lambda source: log.append(("pre", source)), + after_choice=lambda chosen: log.append(("post", chosen)), + ) -def test_constrained_walk_history_hooks_fire_in_order () -> None: + assert log == [ + ("pre", walk[0]), + ("post", walk[1]), + ("pre", walk[1]), + ("post", walk[2]), + ] - """before_choice sees each source; after_choice sees each chosen node.""" - graph = _diamond_graph() - log: list = [] +def test_constrained_walk_length_one_returns_start() -> None: + """A one-position walk is just the start (pins validated).""" + + import pytest - walk = subsequence.sequence_utils.constrained_walk( - graph, "a", 3, random.Random(1), - before_choice=lambda source: log.append(("pre", source)), - after_choice=lambda chosen: log.append(("post", chosen)), - ) + graph = _diamond_graph() - assert log == [ - ("pre", walk[0]), ("post", walk[1]), - ("pre", walk[1]), ("post", walk[2]), - ] + assert subsequence.sequence_utils.constrained_walk( + graph, "a", 1, random.Random(0) + ) == ["a"] - -def test_constrained_walk_length_one_returns_start () -> None: - - """A one-position walk is just the start (pins validated).""" - - import pytest - - graph = _diamond_graph() - - assert subsequence.sequence_utils.constrained_walk(graph, "a", 1, random.Random(0)) == ["a"] - - with pytest.raises(ValueError, match="position 1"): - subsequence.sequence_utils.constrained_walk(graph, "a", 1, random.Random(0), end="b") + with pytest.raises(ValueError, match="position 1"): + subsequence.sequence_utils.constrained_walk( + graph, "a", 1, random.Random(0), end="b" + ) # ── generate_euclidean_sequence: the rotation contract ────────────────────── -def test_euclidean_starts_on_a_hit () -> None: - - """The Bjorklund result is rotated so index 0 is always a hit.""" - - assert subsequence.sequence_utils.generate_euclidean_sequence(8, 3)[0] == 1 - assert subsequence.sequence_utils.generate_euclidean_sequence(16, 3)[0] == 1 - assert subsequence.sequence_utils.generate_euclidean_sequence(12, 7)[0] == 1 - - -def test_euclidean_16_3_exact_pattern () -> None: - - """E(16,3) pins the documented spread: hits at 0, 5, 10.""" - - assert subsequence.sequence_utils.generate_euclidean_sequence(16, 3) == [ - 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, - ] - - -def test_euclidean_12_7_exact_pattern () -> None: - - """E(12,7) pins the classic 7-in-12 necklace.""" - - assert subsequence.sequence_utils.generate_euclidean_sequence(12, 7) == [ - 1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0, - ] - - -def test_euclidean_8_3_exact_pattern () -> None: - - """E(8,3) is the tresillo, starting on its hit.""" - - assert subsequence.sequence_utils.generate_euclidean_sequence(8, 3) == [1, 0, 0, 1, 0, 0, 1, 0] +def test_euclidean_starts_on_a_hit() -> None: + """The Bjorklund result is rotated so index 0 is always a hit.""" + + assert subsequence.sequence_utils.generate_euclidean_sequence(8, 3)[0] == 1 + assert subsequence.sequence_utils.generate_euclidean_sequence(16, 3)[0] == 1 + assert subsequence.sequence_utils.generate_euclidean_sequence(12, 7)[0] == 1 + + +def test_euclidean_16_3_exact_pattern() -> None: + """E(16,3) pins the documented spread: hits at 0, 5, 10.""" + + assert subsequence.sequence_utils.generate_euclidean_sequence(16, 3) == [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + ] + + +def test_euclidean_12_7_exact_pattern() -> None: + """E(12,7) pins the classic 7-in-12 necklace.""" + + assert subsequence.sequence_utils.generate_euclidean_sequence(12, 7) == [ + 1, + 0, + 1, + 0, + 1, + 1, + 0, + 1, + 0, + 1, + 1, + 0, + ] + + +def test_euclidean_8_3_exact_pattern() -> None: + """E(8,3) is the tresillo, starting on its hit.""" + + assert subsequence.sequence_utils.generate_euclidean_sequence(8, 3) == [ + 1, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + ] # ── generate_legato_durations ─────────────────────────────────────────────── -def test_legato_durations_span_to_next_hit () -> None: - - """Each hit's duration reaches the next hit (or the end); rests stay 0.""" - - assert subsequence.sequence_utils.generate_legato_durations([1, 0, 0, 1, 0, 1, 0, 0]) == [ - 3, 0, 0, 2, 0, 3, 0, 0, - ] - - -def test_legato_durations_single_hit_spans_to_end () -> None: +def test_legato_durations_span_to_next_hit() -> None: + """Each hit's duration reaches the next hit (or the end); rests stay 0.""" - """A lone opening hit sustains across the whole list.""" + assert subsequence.sequence_utils.generate_legato_durations( + [1, 0, 0, 1, 0, 1, 0, 0] + ) == [ + 3, + 0, + 0, + 2, + 0, + 3, + 0, + 0, + ] - assert subsequence.sequence_utils.generate_legato_durations([1, 0, 0, 0]) == [4, 0, 0, 0] +def test_legato_durations_single_hit_spans_to_end() -> None: + """A lone opening hit sustains across the whole list.""" -def test_legato_durations_all_zeros_is_all_rests () -> None: + assert subsequence.sequence_utils.generate_legato_durations([1, 0, 0, 0]) == [ + 4, + 0, + 0, + 0, + ] - """No hits in, no durations out — every step stays 0.""" - assert subsequence.sequence_utils.generate_legato_durations([0, 0, 0, 0]) == [0, 0, 0, 0] +def test_legato_durations_all_zeros_is_all_rests() -> None: + """No hits in, no durations out — every step stays 0.""" + assert subsequence.sequence_utils.generate_legato_durations([0, 0, 0, 0]) == [ + 0, + 0, + 0, + 0, + ] -def test_legato_durations_empty_input () -> None: - """An empty hit list returns an empty list.""" +def test_legato_durations_empty_input() -> None: + """An empty hit list returns an empty list.""" - assert subsequence.sequence_utils.generate_legato_durations([]) == [] + assert subsequence.sequence_utils.generate_legato_durations([]) == [] diff --git a/tests/test_sequence_utils_density_combine.py b/tests/test_sequence_utils_density_combine.py index 614d744..24b4f2f 100644 --- a/tests/test_sequence_utils_density_combine.py +++ b/tests/test_sequence_utils_density_combine.py @@ -16,252 +16,231 @@ # Reference layer set [0.2, 0.5, 0.8]: product 0.08, geomean 0.08**(1/3), # mean 0.5, min 0.2. -def test_combine_default_is_geomean () -> None: - """No strategy given uses the geometric mean.""" +def test_combine_default_is_geomean() -> None: + """No strategy given uses the geometric mean.""" - assert abs(C([0.2, 0.5, 0.8]) - (0.08 ** (1.0 / 3.0))) < TOL + assert abs(C([0.2, 0.5, 0.8]) - (0.08 ** (1.0 / 3.0))) < TOL -def test_combine_product () -> None: +def test_combine_product() -> None: + """product multiplies all layers.""" - """product multiplies all layers.""" + assert abs(C([0.2, 0.5, 0.8], "product") - 0.08) < TOL - assert abs(C([0.2, 0.5, 0.8], "product") - 0.08) < TOL +def test_combine_mean() -> None: + """mean is the arithmetic average.""" -def test_combine_mean () -> None: + assert abs(C([0.2, 0.5, 0.8], "mean") - 0.5) < TOL - """mean is the arithmetic average.""" - assert abs(C([0.2, 0.5, 0.8], "mean") - 0.5) < TOL +def test_combine_min() -> None: + """min takes the most restrictive layer.""" + assert abs(C([0.2, 0.5, 0.8], "min") - 0.2) < TOL -def test_combine_min () -> None: - """min takes the most restrictive layer.""" +def test_combine_scalar_only_returns_float() -> None: + """All-scalar layers reduce to a float.""" - assert abs(C([0.2, 0.5, 0.8], "min") - 0.2) < TOL + assert isinstance(C([0.3, 0.6]), float) -def test_combine_scalar_only_returns_float () -> None: +def test_combine_single_layer_identity() -> None: + """Combining one layer returns that layer.""" - """All-scalar layers reduce to a float.""" + for strategy in ["geomean", "min", "mean", "product"]: + assert abs(C([0.42], strategy) - 0.42) < TOL - assert isinstance(C([0.3, 0.6]), float) + out = C([[0.1, 0.9]], "mean") + assert len(out) == 2 + assert abs(out[0] - 0.1) < TOL + assert abs(out[1] - 0.9) < TOL -def test_combine_single_layer_identity () -> None: +def test_combine_mixed_scalar_and_list() -> None: + """A scalar layer broadcasts against a list layer.""" - """Combining one layer returns that layer.""" + out = C([[0.2, 0.8], 0.5], "mean") + assert len(out) == 2 + assert abs(out[0] - (0.2 + 0.5) / 2) < TOL + assert abs(out[1] - (0.8 + 0.5) / 2) < TOL - for strategy in ["geomean", "min", "mean", "product"]: - assert abs(C([0.42], strategy) - 0.42) < TOL - out = C([[0.1, 0.9]], "mean") - assert len(out) == 2 - assert abs(out[0] - 0.1) < TOL - assert abs(out[1] - 0.9) < TOL +def test_combine_length_mismatch_repeats_last() -> None: + """The shorter list layer repeats its last value.""" + out = C([[0.2, 0.4, 0.6], [0.9]], "min") + assert len(out) == 3 + assert abs(out[0] - 0.2) < TOL # min(0.2, 0.9) + assert abs(out[1] - 0.4) < TOL # min(0.4, 0.9) — 0.9 repeated + assert abs(out[2] - 0.6) < TOL # min(0.6, 0.9) — 0.9 repeated -def test_combine_mixed_scalar_and_list () -> None: - """A scalar layer broadcasts against a list layer.""" +def test_combine_list_geomean_per_step() -> None: + """geomean of equal per-step layers returns those values.""" - out = C([[0.2, 0.8], 0.5], "mean") - assert len(out) == 2 - assert abs(out[0] - (0.2 + 0.5) / 2) < TOL - assert abs(out[1] - (0.8 + 0.5) / 2) < TOL + out = C([[0.25, 0.5], [0.25, 0.5]], "geomean") + assert abs(out[0] - 0.25) < TOL + assert abs(out[1] - 0.5) < TOL -def test_combine_length_mismatch_repeats_last () -> None: +def test_combine_empty_layers_raises() -> None: + """An empty layer list is an error.""" - """The shorter list layer repeats its last value.""" + with pytest.raises(ValueError) as exc: + C([]) + assert "empty" in str(exc.value).lower() - out = C([[0.2, 0.4, 0.6], [0.9]], "min") - assert len(out) == 3 - assert abs(out[0] - 0.2) < TOL # min(0.2, 0.9) - assert abs(out[1] - 0.4) < TOL # min(0.4, 0.9) — 0.9 repeated - assert abs(out[2] - 0.6) < TOL # min(0.6, 0.9) — 0.9 repeated +def test_combine_empty_list_layer_yields_empty() -> None: + """An empty list operand collapses the result to [].""" -def test_combine_list_geomean_per_step () -> None: + assert C([[], 0.5]) == [] + assert C([[]]) == [] - """geomean of equal per-step layers returns those values.""" - out = C([[0.25, 0.5], [0.25, 0.5]], "geomean") - assert abs(out[0] - 0.25) < TOL - assert abs(out[1] - 0.5) < TOL +def test_combine_unknown_strategy_raises() -> None: + """An unknown strategy lists the valid names.""" + with pytest.raises(ValueError) as exc: + C([0.5], "nope") + assert "geomean" in str(exc.value) -def test_combine_empty_layers_raises () -> None: - """An empty layer list is an error.""" +def test_combine_bounds_scalar_sweep() -> None: + """Scalar combinations stay in [0, 1] and never NaN.""" - with pytest.raises(ValueError) as exc: - C([]) - assert "empty" in str(exc.value).lower() + rng = random.Random(0) + for _ in range(2000): + layers = [rng.random() for _ in range(rng.randint(1, 4))] + for strategy in ["geomean", "min", "mean", "product"]: + out = C(layers, strategy) + assert 0.0 <= out <= 1.0 + assert out == out -def test_combine_empty_list_layer_yields_empty () -> None: - """An empty list operand collapses the result to [].""" +def test_combine_bounds_list_sweep() -> None: + """List combinations stay in [0, 1] at every step.""" - assert C([[], 0.5]) == [] - assert C([[]]) == [] + rng = random.Random(2) - -def test_combine_unknown_strategy_raises () -> None: - - """An unknown strategy lists the valid names.""" - - with pytest.raises(ValueError) as exc: - C([0.5], "nope") - assert "geomean" in str(exc.value) - - -def test_combine_bounds_scalar_sweep () -> None: - - """Scalar combinations stay in [0, 1] and never NaN.""" - - rng = random.Random(0) - - for _ in range(2000): - layers = [rng.random() for _ in range(rng.randint(1, 4))] - for strategy in ["geomean", "min", "mean", "product"]: - out = C(layers, strategy) - assert 0.0 <= out <= 1.0 - assert out == out - - -def test_combine_bounds_list_sweep () -> None: - - """List combinations stay in [0, 1] at every step.""" - - rng = random.Random(2) - - for _ in range(500): - layers = [[rng.random() for _ in range(rng.randint(1, 8))] for _ in range(rng.randint(1, 4))] - for strategy in ["geomean", "min", "mean", "product"]: - for v in C(layers, strategy): - assert 0.0 <= v <= 1.0 - assert v == v + for _ in range(500): + layers = [ + [rng.random() for _ in range(rng.randint(1, 8))] + for _ in range(rng.randint(1, 4)) + ] + for strategy in ["geomean", "min", "mean", "product"]: + for v in C(layers, strategy): + assert 0.0 <= v <= 1.0 + assert v == v # --- warp_stack --- -def _logit (x: float) -> float: - return math.log(x / (1.0 - x)) - - -def _sigmoid (z: float) -> float: - return 1.0 / (1.0 + math.exp(-z)) - - -def test_stack_empty_amounts_identity () -> None: - - """Warping by no knobs returns the value unchanged.""" - - for v in [0.0, 0.3, 0.5, 1.0]: - assert S(v, []) == v - - assert S([0.2, 0.8], []) == [0.2, 0.8] - - -def test_stack_single_amount_equals_warp () -> None: - - """One knob equals a single density_warp.""" - - for v, a in [(0.3, 0.7), (0.5, 0.2), (0.9, 0.6)]: - assert abs(S(v, [a]) - W(v, a)) < TOL +def _logit(x: float) -> float: + return math.log(x / (1.0 - x)) -def test_stack_two_amounts_log_odds_sum () -> None: - """Two knobs equal one warp whose knobs sum in log-odds.""" +def _sigmoid(z: float) -> float: + return 1.0 / (1.0 + math.exp(-z)) - p, a1, a2 = 0.3, 0.6, 0.8 - combined = _sigmoid(_logit(a1) + _logit(a2)) - assert abs(S(p, [a1, a2]) - W(p, combined)) < TOL +def test_stack_empty_amounts_identity() -> None: + """Warping by no knobs returns the value unchanged.""" -def test_stack_compose_equals_nested () -> None: + for v in [0.0, 0.3, 0.5, 1.0]: + assert S(v, []) == v - """Stacking equals nested warps.""" + assert S([0.2, 0.8], []) == [0.2, 0.8] - p, a1, a2, a3 = 0.3, 0.6, 0.8, 0.4 - assert abs(S(p, [a1, a2, a3]) - W(W(W(p, a1), a2), a3)) < TOL +def test_stack_single_amount_equals_warp() -> None: + """One knob equals a single density_warp.""" -def test_stack_order_independent () -> None: + for v, a in [(0.3, 0.7), (0.5, 0.2), (0.9, 0.6)]: + assert abs(S(v, [a]) - W(v, a)) < TOL - """Knob order does not matter.""" - assert abs(S(0.3, [0.6, 0.8, 0.4]) - S(0.3, [0.4, 0.6, 0.8])) < TOL +def test_stack_two_amounts_log_odds_sum() -> None: + """Two knobs equal one warp whose knobs sum in log-odds.""" + p, a1, a2 = 0.3, 0.6, 0.8 + combined = _sigmoid(_logit(a1) + _logit(a2)) + assert abs(S(p, [a1, a2]) - W(p, combined)) < TOL -def test_stack_neutral_half_no_op () -> None: - """A 0.5 knob changes nothing.""" +def test_stack_compose_equals_nested() -> None: + """Stacking equals nested warps.""" - assert abs(S(0.37, [0.5, 0.5, 0.5]) - 0.37) < TOL - assert abs(S(0.3, [0.7, 0.5, 0.8]) - S(0.3, [0.7, 0.8])) < TOL + p, a1, a2, a3 = 0.3, 0.6, 0.8, 0.4 + assert abs(S(p, [a1, a2, a3]) - W(W(W(p, a1), a2), a3)) < TOL -def test_stack_scalar_returns_float () -> None: +def test_stack_order_independent() -> None: + """Knob order does not matter.""" - """All-scalar input returns a float.""" + assert abs(S(0.3, [0.6, 0.8, 0.4]) - S(0.3, [0.4, 0.6, 0.8])) < TOL - assert isinstance(S(0.4, [0.6, 0.7]), float) +def test_stack_neutral_half_no_op() -> None: + """A 0.5 knob changes nothing.""" -def test_stack_list_value_list_knobs () -> None: + assert abs(S(0.37, [0.5, 0.5, 0.5]) - 0.37) < TOL + assert abs(S(0.3, [0.7, 0.5, 0.8]) - S(0.3, [0.7, 0.8])) < TOL - """A list value with list knobs warps per step.""" - out = S([0.2, 0.8], [[0.7, 0.3], 0.6]) - expected = W(W([0.2, 0.8], [0.7, 0.3]), 0.6) - assert len(out) == 2 - for o, e in zip(out, expected): - assert abs(o - e) < TOL +def test_stack_scalar_returns_float() -> None: + """All-scalar input returns a float.""" + assert isinstance(S(0.4, [0.6, 0.7]), float) -def test_stack_scalar_value_list_knob_widens () -> None: - """A scalar value with a per-step knob widens to a list.""" +def test_stack_list_value_list_knobs() -> None: + """A list value with list knobs warps per step.""" - out = S(0.5, [[0.2, 0.5, 0.8]]) - assert len(out) == 3 - for o, a in zip(out, [0.2, 0.5, 0.8]): - assert abs(o - a) < TOL # W(0.5, a) == a + out = S([0.2, 0.8], [[0.7, 0.3], 0.6]) + expected = W(W([0.2, 0.8], [0.7, 0.3]), 0.6) + assert len(out) == 2 + for o, e in zip(out, expected): + assert abs(o - e) < TOL -def test_stack_saturates_denser () -> None: +def test_stack_scalar_value_list_knob_widens() -> None: + """A scalar value with a per-step knob widens to a list.""" - """More knobs above 0.5 only increase density.""" + out = S(0.5, [[0.2, 0.5, 0.8]]) + assert len(out) == 3 + for o, a in zip(out, [0.2, 0.5, 0.8]): + assert abs(o - a) < TOL # W(0.5, a) == a - vals = [S(0.5, [0.7] * k) for k in range(1, 6)] - assert all(vals[i] <= vals[i + 1] for i in range(len(vals) - 1)) - assert vals[-1] > vals[0] +def test_stack_saturates_denser() -> None: + """More knobs above 0.5 only increase density.""" -def test_stack_saturates_sparser () -> None: + vals = [S(0.5, [0.7] * k) for k in range(1, 6)] + assert all(vals[i] <= vals[i + 1] for i in range(len(vals) - 1)) + assert vals[-1] > vals[0] - """More knobs below 0.5 only decrease density.""" - vals = [S(0.5, [0.3] * k) for k in range(1, 6)] - assert all(vals[i] >= vals[i + 1] for i in range(len(vals) - 1)) - assert vals[-1] < vals[0] +def test_stack_saturates_sparser() -> None: + """More knobs below 0.5 only decrease density.""" + vals = [S(0.5, [0.3] * k) for k in range(1, 6)] + assert all(vals[i] >= vals[i + 1] for i in range(len(vals) - 1)) + assert vals[-1] < vals[0] -def test_stack_bounds_sweep () -> None: - """Stacked warps stay in [0, 1] and never NaN.""" +def test_stack_bounds_sweep() -> None: + """Stacked warps stay in [0, 1] and never NaN.""" - rng = random.Random(1) + rng = random.Random(1) - for _ in range(2000): - knobs = [rng.random() for _ in range(rng.randint(1, 5))] - out = S(rng.random(), knobs) - assert 0.0 <= out <= 1.0 - assert out == out + for _ in range(2000): + knobs = [rng.random() for _ in range(rng.randint(1, 5))] + out = S(rng.random(), knobs) + assert 0.0 <= out <= 1.0 + assert out == out diff --git a/tests/test_sequence_utils_density_spread.py b/tests/test_sequence_utils_density_spread.py index 81bc23e..66b6b44 100644 --- a/tests/test_sequence_utils_density_spread.py +++ b/tests/test_sequence_utils_density_spread.py @@ -10,291 +10,271 @@ TOL = 1e-9 -def _odds (x: float) -> float: - return x / (1.0 - x) +def _odds(x: float) -> float: + return x / (1.0 - x) -def _logit (x: float) -> float: - return math.log(x / (1.0 - x)) +def _logit(x: float) -> float: + return math.log(x / (1.0 - x)) -def _sigmoid (z: float) -> float: - return 1.0 / (1.0 + math.exp(-z)) +def _sigmoid(z: float) -> float: + return 1.0 / (1.0 + math.exp(-z)) -def _k (amount: float) -> float: - return amount / (1.0 - amount) +def _k(amount: float) -> float: + return amount / (1.0 - amount) # --- identity, fixed point, and the saturating corners --- -def test_identity_at_half_exact () -> None: - """amount=0.5 returns the value unchanged, bit-exactly, for any anchor.""" +def test_identity_at_half_exact() -> None: + """amount=0.5 returns the value unchanged, bit-exactly, for any anchor.""" - for p in [0.0, 0.1, 0.25, 0.5, 0.9, 1.0]: - for m in [0.001, 0.3, 0.5, 0.999]: - assert SP(p, 0.5, m) == p + for p in [0.0, 0.1, 0.25, 0.5, 0.9, 1.0]: + for m in [0.001, 0.3, 0.5, 0.999]: + assert SP(p, 0.5, m) == p -def test_fixed_point_at_midpoint () -> None: +def test_fixed_point_at_midpoint() -> None: + """A value equal to the anchor never moves, for any amount.""" - """A value equal to the anchor never moves, for any amount.""" + for m in [0.001, 0.1, 0.3, 0.5, 0.7, 0.9, 0.999]: + for amount in [0.0, 0.1, 0.5, 0.9, 1.0]: + assert abs(SP(m, amount, m) - m) < TOL - for m in [0.001, 0.1, 0.3, 0.5, 0.7, 0.9, 0.999]: - for amount in [0.0, 0.1, 0.5, 0.9, 1.0]: - assert abs(SP(m, amount, m) - m) < TOL +def test_amount_one_rails_by_side() -> None: + """amount=1 drives each interior value to the rail on its side of m.""" -def test_amount_one_rails_by_side () -> None: + assert SP(0.3, 1.0, 0.5) == 0.0 + assert SP(0.7, 1.0, 0.5) == 1.0 + assert SP(0.5, 1.0, 0.5) == 0.5 - """amount=1 drives each interior value to the rail on its side of m.""" + for p in [0.01, 0.2, 0.45]: + assert SP(p, 1.0, 0.6) == 0.0 # p < m -> 0 + for p in [0.65, 0.8, 0.99]: + assert SP(p, 1.0, 0.6) == 1.0 # p > m -> 1 - assert SP(0.3, 1.0, 0.5) == 0.0 - assert SP(0.7, 1.0, 0.5) == 1.0 - assert SP(0.5, 1.0, 0.5) == 0.5 - for p in [0.01, 0.2, 0.45]: - assert SP(p, 1.0, 0.6) == 0.0 # p < m -> 0 - for p in [0.65, 0.8, 0.99]: - assert SP(p, 1.0, 0.6) == 1.0 # p > m -> 1 +def test_amount_zero_collapses_to_midpoint() -> None: + """amount=0 collapses every interior value onto the anchor.""" + for p in [0.05, 0.3, 0.7, 0.95]: + assert SP(p, 0.0, 0.42) == 0.42 -def test_amount_zero_collapses_to_midpoint () -> None: - """amount=0 collapses every interior value onto the anchor.""" +def test_midpoint_half_reduces_to_odds_power() -> None: + """At m=0.5 the odds are raised to the power k = amount/(1-amount).""" - for p in [0.05, 0.3, 0.7, 0.95]: - assert SP(p, 0.0, 0.42) == 0.42 + # odds(0.8)=4, k=3 -> 4**3 = 64 -> 64/65. + assert abs(SP(0.8, 0.75, 0.5) - 64.0 / 65.0) < TOL + # odds(0.75)=3, k=3 -> 3**3 = 27 -> 27/28. + assert abs(SP(0.75, 0.75, 0.5) - 27.0 / 28.0) < TOL + # The reciprocal knob (k=1/3) is the cube root in odds space. + cube_root = 4.0 ** (1.0 / 3.0) + assert abs(SP(0.8, 0.25, 0.5) - cube_root / (1.0 + cube_root)) < TOL -def test_midpoint_half_reduces_to_odds_power () -> None: +def test_odds_ratio_identity() -> None: + """odds(out)/odds(m) == (odds(p)/odds(m))**k over moderate amounts.""" - """At m=0.5 the odds are raised to the power k = amount/(1-amount).""" - - # odds(0.8)=4, k=3 -> 4**3 = 64 -> 64/65. - assert abs(SP(0.8, 0.75, 0.5) - 64.0 / 65.0) < TOL - # odds(0.75)=3, k=3 -> 3**3 = 27 -> 27/28. - assert abs(SP(0.75, 0.75, 0.5) - 27.0 / 28.0) < TOL - # The reciprocal knob (k=1/3) is the cube root in odds space. - cube_root = 4.0 ** (1.0 / 3.0) - assert abs(SP(0.8, 0.25, 0.5) - cube_root / (1.0 + cube_root)) < TOL - - -def test_odds_ratio_identity () -> None: - - """odds(out)/odds(m) == (odds(p)/odds(m))**k over moderate amounts.""" - - for p in [0.2, 0.45, 0.6, 0.85]: - for m in [0.3, 0.5, 0.7]: - for amount in [0.35, 0.45, 0.6, 0.65]: - k = _k(amount) - out = SP(p, amount, m) - lhs = _odds(out) / _odds(m) - rhs = (_odds(p) / _odds(m)) ** k - assert abs(lhs - rhs) < 1e-6 + for p in [0.2, 0.45, 0.6, 0.85]: + for m in [0.3, 0.5, 0.7]: + for amount in [0.35, 0.45, 0.6, 0.65]: + k = _k(amount) + out = SP(p, amount, m) + lhs = _odds(out) / _odds(m) + rhs = (_odds(p) / _odds(m)) ** k + assert abs(lhs - rhs) < 1e-6 # --- the two compositional laws (interior intermediates only) --- -def test_composition_multiplies_gains () -> None: - """Two spreads about the same anchor compound by multiplying their gains.""" +def test_composition_multiplies_gains() -> None: + """Two spreads about the same anchor compound by multiplying their gains.""" - p, a1, a2, m = 0.6, 0.6, 0.65, 0.4 - kc = _k(a1) * _k(a2) - ac = kc / (1.0 + kc) - assert abs(SP(SP(p, a1, m), a2, m) - SP(p, ac, m)) < TOL + p, a1, a2, m = 0.6, 0.6, 0.65, 0.4 + kc = _k(a1) * _k(a2) + ac = kc / (1.0 + kc) + assert abs(SP(SP(p, a1, m), a2, m) - SP(p, ac, m)) < TOL -def test_inverse_law_returns_value () -> None: +def test_inverse_law_returns_value() -> None: + """Spreading by amount then by its complement is the identity.""" - """Spreading by amount then by its complement is the identity.""" + p, amount, m = 0.6, 0.65, 0.4 + assert abs(SP(SP(p, amount, m), 1.0 - amount, m) - p) < TOL - p, amount, m = 0.6, 0.65, 0.4 - assert abs(SP(SP(p, amount, m), 1.0 - amount, m) - p) < TOL +def test_saturation_is_irreversible() -> None: + """Once a strong expand pins a value to a rail, a later contract cannot recover it.""" -def test_saturation_is_irreversible () -> None: - - """Once a strong expand pins a value to a rail, a later contract cannot recover it.""" - - pinned = SP(0.99, 0.95, 0.5) - assert pinned == 1.0 - assert SP(pinned, 0.05, 0.5) == 1.0 # stays on the rail, not back to 0.99 + pinned = SP(0.99, 0.95, 0.5) + assert pinned == 1.0 + assert SP(pinned, 0.05, 0.5) == 1.0 # stays on the rail, not back to 0.99 # --- direction, ordering, and bounds --- -def test_expand_pushes_away_contract_pulls_toward () -> None: - - """Above 0.5 widens the gap to the anchor; below 0.5 narrows it.""" - - rng = random.Random(0) - - for _ in range(2000): - p = rng.random() * 0.9 + 0.05 - m = rng.random() * 0.8 + 0.1 - if abs(p - m) <= 0.01: - continue - assert abs(SP(p, 0.75, m) - m) >= abs(p - m) # expand - assert abs(SP(p, 0.25, m) - m) <= abs(p - m) # contract +def test_expand_pushes_away_contract_pulls_toward() -> None: + """Above 0.5 widens the gap to the anchor; below 0.5 narrows it.""" -def test_never_crosses_midpoint () -> None: + rng = random.Random(0) - """A value stays on its own side of the anchor for any interior amount.""" + for _ in range(2000): + p = rng.random() * 0.9 + 0.05 + m = rng.random() * 0.8 + 0.1 + if abs(p - m) <= 0.01: + continue + assert abs(SP(p, 0.75, m) - m) >= abs(p - m) # expand + assert abs(SP(p, 0.25, m) - m) <= abs(p - m) # contract - rng = random.Random(1) - for _ in range(2000): - p = rng.random() * 0.98 + 0.01 - m = rng.random() * 0.8 + 0.1 - for amount in [0.1, 0.3, 0.7, 0.9]: - out = SP(p, amount, m) - if p < m: - assert out <= m - elif p > m: - assert out >= m +def test_never_crosses_midpoint() -> None: + """A value stays on its own side of the anchor for any interior amount.""" + rng = random.Random(1) -def test_monotonic_in_value () -> None: + for _ in range(2000): + p = rng.random() * 0.98 + 0.01 + m = rng.random() * 0.8 + 0.1 + for amount in [0.1, 0.3, 0.7, 0.9]: + out = SP(p, amount, m) + if p < m: + assert out <= m + elif p > m: + assert out >= m - """Increasing the value never decreases the output.""" - out = [SP(p, 0.7, 0.4) for p in [0.05, 0.2, 0.4, 0.6, 0.8, 0.95]] - assert all(out[i] <= out[i + 1] for i in range(len(out) - 1)) +def test_monotonic_in_value() -> None: + """Increasing the value never decreases the output.""" + out = [SP(p, 0.7, 0.4) for p in [0.05, 0.2, 0.4, 0.6, 0.8, 0.95]] + assert all(out[i] <= out[i + 1] for i in range(len(out) - 1)) -def test_monotonic_in_amount () -> None: - """More expand drives a value further toward its own rail.""" +def test_monotonic_in_amount() -> None: + """More expand drives a value further toward its own rail.""" - above = [SP(0.7, a, 0.5) for a in [0.1, 0.3, 0.5, 0.7, 0.9]] - assert all(above[i] <= above[i + 1] for i in range(len(above) - 1)) # p > m climbs + above = [SP(0.7, a, 0.5) for a in [0.1, 0.3, 0.5, 0.7, 0.9]] + assert all(above[i] <= above[i + 1] for i in range(len(above) - 1)) # p > m climbs - below = [SP(0.3, a, 0.5) for a in [0.1, 0.3, 0.5, 0.7, 0.9]] - assert all(below[i] >= below[i + 1] for i in range(len(below) - 1)) # p < m falls + below = [SP(0.3, a, 0.5) for a in [0.1, 0.3, 0.5, 0.7, 0.9]] + assert all(below[i] >= below[i + 1] for i in range(len(below) - 1)) # p < m falls -def test_value_zero_stays_zero () -> None: +def test_value_zero_stays_zero() -> None: + """A zero value stays at zero for any amount and anchor.""" - """A zero value stays at zero for any amount and anchor.""" + for amount in [0.0, 0.3, 0.7, 1.0]: + for m in [0.2, 0.5, 0.8]: + assert SP(0.0, amount, m) == 0.0 - for amount in [0.0, 0.3, 0.7, 1.0]: - for m in [0.2, 0.5, 0.8]: - assert SP(0.0, amount, m) == 0.0 +def test_value_one_stays_one() -> None: + """A one value stays at one for any amount and anchor.""" -def test_value_one_stays_one () -> None: + for amount in [0.0, 0.3, 0.7, 1.0]: + for m in [0.2, 0.5, 0.8]: + assert SP(1.0, amount, m) == 1.0 - """A one value stays at one for any amount and anchor.""" - for amount in [0.0, 0.3, 0.7, 1.0]: - for m in [0.2, 0.5, 0.8]: - assert SP(1.0, amount, m) == 1.0 +def test_bounds_over_random_sweep() -> None: + """Output stays in [0, 1] and is never NaN.""" + rng = random.Random(2) -def test_bounds_over_random_sweep () -> None: - - """Output stays in [0, 1] and is never NaN.""" - - rng = random.Random(2) - - for _ in range(2000): - out = SP(rng.random(), rng.random(), rng.random() * 0.98 + 0.01) - assert 0.0 <= out <= 1.0 - assert out == out # not NaN + for _ in range(2000): + out = SP(rng.random(), rng.random(), rng.random() * 0.98 + 0.01) + assert 0.0 <= out <= 1.0 + assert out == out # not NaN # --- shape and broadcasting (mirrors the density_warp tests) --- -def test_scalar_returns_float () -> None: - - """Scalar value and amount return a float.""" - - assert isinstance(SP(0.4, 0.6, 0.5), float) +def test_scalar_returns_float() -> None: + """Scalar value and amount return a float.""" -def test_list_value_scalar_amount () -> None: + assert isinstance(SP(0.4, 0.6, 0.5), float) - """A list value with a scalar amount returns a same-length list.""" - out = SP([0.2, 0.5, 0.8], 0.7, 0.5) - assert isinstance(out, list) - assert len(out) == 3 - assert out[1] == 0.5 # middle equals the anchor -> fixed - for v, o in zip([0.2, 0.5, 0.8], out): - assert abs(o - SP(v, 0.7, 0.5)) < TOL +def test_list_value_scalar_amount() -> None: + """A list value with a scalar amount returns a same-length list.""" + out = SP([0.2, 0.5, 0.8], 0.7, 0.5) + assert isinstance(out, list) + assert len(out) == 3 + assert out[1] == 0.5 # middle equals the anchor -> fixed + for v, o in zip([0.2, 0.5, 0.8], out): + assert abs(o - SP(v, 0.7, 0.5)) < TOL -def test_scalar_value_list_amount () -> None: - """A scalar value with a per-step amount returns a list.""" +def test_scalar_value_list_amount() -> None: + """A scalar value with a per-step amount returns a list.""" - out = SP(0.7, [0.3, 0.7], 0.5) - assert isinstance(out, list) - assert len(out) == 2 - assert abs(out[0] - SP(0.7, 0.3, 0.5)) < TOL - assert abs(out[1] - SP(0.7, 0.7, 0.5)) < TOL + out = SP(0.7, [0.3, 0.7], 0.5) + assert isinstance(out, list) + assert len(out) == 2 + assert abs(out[0] - SP(0.7, 0.3, 0.5)) < TOL + assert abs(out[1] - SP(0.7, 0.7, 0.5)) < TOL -def test_list_value_list_amount () -> None: +def test_list_value_list_amount() -> None: + """Equal-length lists spread element-wise.""" - """Equal-length lists spread element-wise.""" + out = SP([0.2, 0.8], [0.7, 0.3], 0.5) + assert abs(out[0] - SP(0.2, 0.7, 0.5)) < TOL + assert abs(out[1] - SP(0.8, 0.3, 0.5)) < TOL - out = SP([0.2, 0.8], [0.7, 0.3], 0.5) - assert abs(out[0] - SP(0.2, 0.7, 0.5)) < TOL - assert abs(out[1] - SP(0.8, 0.3, 0.5)) < TOL +def test_length_mismatch_repeats_last() -> None: + """On unequal lengths the shorter operand repeats its last value.""" -def test_length_mismatch_repeats_last () -> None: + out = SP([0.2, 0.5, 0.8], [0.7], 0.5) + assert len(out) == 3 + for v, o in zip([0.2, 0.5, 0.8], out): + assert abs(o - SP(v, 0.7, 0.5)) < TOL - """On unequal lengths the shorter operand repeats its last value.""" + out = SP([0.3], [0.2, 0.4, 0.6], 0.5) + assert len(out) == 3 + for a, o in zip([0.2, 0.4, 0.6], out): + assert abs(o - SP(0.3, a, 0.5)) < TOL - out = SP([0.2, 0.5, 0.8], [0.7], 0.5) - assert len(out) == 3 - for v, o in zip([0.2, 0.5, 0.8], out): - assert abs(o - SP(v, 0.7, 0.5)) < TOL - out = SP([0.3], [0.2, 0.4, 0.6], 0.5) - assert len(out) == 3 - for a, o in zip([0.2, 0.4, 0.6], out): - assert abs(o - SP(0.3, a, 0.5)) < TOL +def test_empty_list_edges() -> None: + """An empty list operand yields an empty list.""" - -def test_empty_list_edges () -> None: - - """An empty list operand yields an empty list.""" - - assert SP([], 0.5) == [] - assert SP(0.5, []) == [] - assert SP([], []) == [] - assert SP([], [0.5]) == [] + assert SP([], 0.5) == [] + assert SP(0.5, []) == [] + assert SP([], []) == [] + assert SP([], [0.5]) == [] # --- the midpoint anchor --- -def test_midpoint_default_is_half () -> None: - - """The default anchor is 0.5.""" - - assert SP(0.3, 0.7) == SP(0.3, 0.7, 0.5) +def test_midpoint_default_is_half() -> None: + """The default anchor is 0.5.""" -def test_midpoint_out_of_range_raises () -> None: + assert SP(0.3, 0.7) == SP(0.3, 0.7, 0.5) - """An anchor on or beyond a rail is an error.""" - for bad in [0.0, 1.0, -0.1, 1.2]: - with pytest.raises(ValueError) as exc: - SP(0.5, 0.7, bad) - assert "midpoint" in str(exc.value) +def test_midpoint_out_of_range_raises() -> None: + """An anchor on or beyond a rail is an error.""" + for bad in [0.0, 1.0, -0.1, 1.2]: + with pytest.raises(ValueError) as exc: + SP(0.5, 0.7, bad) + assert "midpoint" in str(exc.value) -def test_midpoint_validated_before_shape () -> None: - """A bad anchor raises even when the payload would short-circuit to [].""" +def test_midpoint_validated_before_shape() -> None: + """A bad anchor raises even when the payload would short-circuit to [].""" - with pytest.raises(ValueError): - SP([], 0.5, midpoint=1.0) + with pytest.raises(ValueError): + SP([], 0.5, midpoint=1.0) diff --git a/tests/test_sequence_utils_density_to_steps.py b/tests/test_sequence_utils_density_to_steps.py index 98cad85..1708355 100644 --- a/tests/test_sequence_utils_density_to_steps.py +++ b/tests/test_sequence_utils_density_to_steps.py @@ -8,87 +8,78 @@ D2S = subsequence.sequence_utils.density_to_steps -def test_matches_hand_idiom_seeded () -> None: +def test_matches_hand_idiom_seeded() -> None: + """A seeded run reproduces [i for i in range(n) if rng.random() < d[i]] exactly.""" - """A seeded run reproduces [i for i in range(n) if rng.random() < d[i]] exactly.""" + density = [0.9, 0.1, 0.5, 0.2, 0.8, 0.3, 0.6, 0.0] - density = [0.9, 0.1, 0.5, 0.2, 0.8, 0.3, 0.6, 0.0] + expected_rng = random.Random(42) + expected = [i for i in range(len(density)) if expected_rng.random() < density[i]] - expected_rng = random.Random(42) - expected = [i for i in range(len(density)) if expected_rng.random() < density[i]] + actual = D2S(density, random.Random(42)) - actual = D2S(density, random.Random(42)) + assert actual == expected - assert actual == expected +def test_returns_ascending_indices() -> None: + """Fired indices come back in ascending step order.""" -def test_returns_ascending_indices () -> None: + result = D2S([0.99] * 8, random.Random(1)) - """Fired indices come back in ascending step order.""" + assert result == sorted(result) - result = D2S([0.99] * 8, random.Random(1)) - assert result == sorted(result) +def test_all_high_fires_every_step() -> None: + """Densities at or above 1.0 always fire — the full index list.""" + assert D2S([1.0, 1.5, 1.0, 2.0], random.Random(7)) == [0, 1, 2, 3] -def test_all_high_fires_every_step () -> None: - """Densities at or above 1.0 always fire — the full index list.""" +def test_all_zero_fires_nothing() -> None: + """Densities at or below 0.0 never fire; an empty result is normal, not an error.""" - assert D2S([1.0, 1.5, 1.0, 2.0], random.Random(7)) == [0, 1, 2, 3] + assert D2S([0.0, -0.5, 0.0], random.Random(7)) == [] -def test_all_zero_fires_nothing () -> None: +def test_empty_density_is_noop() -> None: + """An empty density list places nothing (no-op).""" - """Densities at or below 0.0 never fire; an empty result is normal, not an error.""" + assert D2S([], random.Random(7)) == [] - assert D2S([0.0, -0.5, 0.0], random.Random(7)) == [] +def test_scalar_requires_length() -> None: + """A scalar density with no length is the lone meaningless-input raise.""" -def test_empty_density_is_noop () -> None: + with pytest.raises(ValueError) as exc: + D2S(0.5, random.Random(7)) + assert "length" in str(exc.value) - """An empty density list places nothing (no-op).""" - assert D2S([], random.Random(7)) == [] +def test_scalar_with_length_reproduces_idiom() -> None: + """A scalar density rolls *length* independent draws, matching the hand idiom.""" + expected_rng = random.Random(99) + expected = [i for i in range(16) if expected_rng.random() < 0.4] -def test_scalar_requires_length () -> None: + actual = D2S(0.4, random.Random(99), 16) - """A scalar density with no length is the lone meaningless-input raise.""" + assert actual == expected - with pytest.raises(ValueError) as exc: - D2S(0.5, random.Random(7)) - assert "length" in str(exc.value) +def test_scalar_zero_or_negative_length_is_noop() -> None: + """A zero or negative grid places nothing.""" -def test_scalar_with_length_reproduces_idiom () -> None: + assert D2S(0.5, random.Random(7), 0) == [] + assert D2S(0.5, random.Random(7), -3) == [] - """A scalar density rolls *length* independent draws, matching the hand idiom.""" - expected_rng = random.Random(99) - expected = [i for i in range(16) if expected_rng.random() < 0.4] +def test_list_density_ignores_redundant_length() -> None: + """A redundant length on a list density is ignored (len(density) is the grid).""" - actual = D2S(0.4, random.Random(99), 16) + density = [0.9, 0.1, 0.8, 0.2] - assert actual == expected + from_list = D2S(density, random.Random(5)) + with_length = D2S(density, random.Random(5), 99) - -def test_scalar_zero_or_negative_length_is_noop () -> None: - - """A zero or negative grid places nothing.""" - - assert D2S(0.5, random.Random(7), 0) == [] - assert D2S(0.5, random.Random(7), -3) == [] - - -def test_list_density_ignores_redundant_length () -> None: - - """A redundant length on a list density is ignored (len(density) is the grid).""" - - density = [0.9, 0.1, 0.8, 0.2] - - from_list = D2S(density, random.Random(5)) - with_length = D2S(density, random.Random(5), 99) - - assert from_list == with_length - assert all(i < len(density) for i in with_length) + assert from_list == with_length + assert all(i < len(density) for i in with_length) diff --git a/tests/test_sequence_utils_density_warp.py b/tests/test_sequence_utils_density_warp.py index 4e9c0f7..d0dcad5 100644 --- a/tests/test_sequence_utils_density_warp.py +++ b/tests/test_sequence_utils_density_warp.py @@ -8,191 +8,172 @@ TOL = 1e-9 -def test_identity_at_half () -> None: +def test_identity_at_half() -> None: + """amount=0.5 returns the value unchanged.""" - """amount=0.5 returns the value unchanged.""" + for p in [0.0, 0.1, 0.25, 0.5, 0.9, 1.0]: + assert abs(W(p, 0.5) - p) < TOL - for p in [0.0, 0.1, 0.25, 0.5, 0.9, 1.0]: - assert abs(W(p, 0.5) - p) < TOL +def test_half_step_equals_amount() -> None: + """A neutral 0.5-weight step fires at exactly the knob value.""" -def test_half_step_equals_amount () -> None: + for a in [0.0, 0.1, 0.5, 0.9, 1.0]: + assert abs(W(0.5, a) - a) < TOL - """A neutral 0.5-weight step fires at exactly the knob value.""" - for a in [0.0, 0.1, 0.5, 0.9, 1.0]: - assert abs(W(0.5, a) - a) < TOL +def test_all_on_at_amount_one() -> None: + """amount=1 drives every interior value to exactly 1.0.""" + for p in [0.01, 0.3, 0.5, 0.7, 0.99]: + assert W(p, 1.0) == 1.0 -def test_all_on_at_amount_one () -> None: - """amount=1 drives every interior value to exactly 1.0.""" +def test_all_off_at_amount_zero() -> None: + """amount=0 drives every interior value to exactly 0.0.""" - for p in [0.01, 0.3, 0.5, 0.7, 0.99]: - assert W(p, 1.0) == 1.0 + for p in [0.01, 0.3, 0.5, 0.7, 0.99]: + assert W(p, 0.0) == 0.0 -def test_all_off_at_amount_zero () -> None: +def test_value_zero_stays_zero() -> None: + """A zero-character step never fires, even at amount=1.""" - """amount=0 drives every interior value to exactly 0.0.""" + for a in [0.0, 0.3, 0.7, 1.0]: + assert W(0.0, a) == 0.0 - for p in [0.01, 0.3, 0.5, 0.7, 0.99]: - assert W(p, 0.0) == 0.0 +def test_value_one_stays_one() -> None: + """A full-character step always fires, even at amount=0.""" -def test_value_zero_stays_zero () -> None: + for a in [0.0, 0.3, 0.7, 1.0]: + assert W(1.0, a) == 1.0 - """A zero-character step never fires, even at amount=1.""" - for a in [0.0, 0.3, 0.7, 1.0]: - assert W(0.0, a) == 0.0 +def test_asymmetric_corners() -> None: + """At the opposing corners the value's extremity wins.""" + assert W(0.0, 1.0) == 0.0 + assert W(1.0, 0.0) == 1.0 -def test_value_one_stays_one () -> None: - """A full-character step always fires, even at amount=0.""" +def test_symmetry_law() -> None: + """W(1-p, 1-a) == 1 - W(p, a) over the open square.""" - for a in [0.0, 0.3, 0.7, 1.0]: - assert W(1.0, a) == 1.0 + assert abs(W(0.2, 0.7) - (1.0 - W(0.8, 0.3))) < TOL + rng = random.Random(1) -def test_asymmetric_corners () -> None: + for _ in range(2000): + p = rng.random() * 0.98 + 0.01 + a = rng.random() * 0.98 + 0.01 + assert abs(W(1 - p, 1 - a) - (1.0 - W(p, a))) < TOL - """At the opposing corners the value's extremity wins.""" - assert W(0.0, 1.0) == 0.0 - assert W(1.0, 0.0) == 1.0 +def test_stacking_log_odds() -> None: + """Two warps equal one warp whose knobs combine by summing log-odds.""" + def logit(x: float) -> float: + return math.log(x / (1.0 - x)) -def test_symmetry_law () -> None: + def sigmoid(z: float) -> float: + return 1.0 / (1.0 + math.exp(-z)) - """W(1-p, 1-a) == 1 - W(p, a) over the open square.""" + p, a1, a2 = 0.3, 0.6, 0.8 + combined = sigmoid(logit(a1) + logit(a2)) + assert abs(W(W(p, a1), a2) - W(p, combined)) < TOL - assert abs(W(0.2, 0.7) - (1.0 - W(0.8, 0.3))) < TOL - rng = random.Random(1) +def test_anchored_values() -> None: + """Hand-computed values guard against silent drift.""" - for _ in range(2000): - p = rng.random() * 0.98 + 0.01 - a = rng.random() * 0.98 + 0.01 - assert abs(W(1 - p, 1 - a) - (1.0 - W(p, a))) < TOL + assert abs(W(0.25, 0.75) - 0.5) < TOL # odds (1/3) * 3 = 1 + assert abs(W(0.2, 0.7) - 0.3684210526315789) < TOL -def test_stacking_log_odds () -> None: +def test_scalar_returns_float() -> None: + """A scalar value returns a float.""" - """Two warps equal one warp whose knobs combine by summing log-odds.""" + assert isinstance(W(0.4, 0.6), float) - def logit (x: float) -> float: - return math.log(x / (1.0 - x)) - def sigmoid (z: float) -> float: - return 1.0 / (1.0 + math.exp(-z)) +def test_list_value_scalar_amount() -> None: + """A list value with a scalar knob returns a same-length list.""" - p, a1, a2 = 0.3, 0.6, 0.8 - combined = sigmoid(logit(a1) + logit(a2)) - assert abs(W(W(p, a1), a2) - W(p, combined)) < TOL + out = W([0.2, 0.5, 0.8], 0.7) + assert isinstance(out, list) + assert len(out) == 3 + assert abs(out[1] - 0.7) < TOL # middle is 0.5 -> amount + for v, o in zip([0.2, 0.5, 0.8], out): + assert abs(o - W(v, 0.7)) < TOL -def test_anchored_values () -> None: +def test_scalar_value_list_amount() -> None: + """A scalar value with a per-step knob list returns a list.""" - """Hand-computed values guard against silent drift.""" + out = W(0.5, [0.2, 0.5, 0.8]) + assert isinstance(out, list) + assert len(out) == 3 + for a, o in zip([0.2, 0.5, 0.8], out): + assert abs(o - a) < TOL # W(0.5, a) == a - assert abs(W(0.25, 0.75) - 0.5) < TOL # odds (1/3) * 3 = 1 - assert abs(W(0.2, 0.7) - 0.3684210526315789) < TOL +def test_list_value_list_amount() -> None: + """Equal-length lists warp element-wise.""" -def test_scalar_returns_float () -> None: + out = W([0.2, 0.8], [0.7, 0.3]) + assert abs(out[0] - W(0.2, 0.7)) < TOL + assert abs(out[1] - W(0.8, 0.3)) < TOL - """A scalar value returns a float.""" - assert isinstance(W(0.4, 0.6), float) +def test_length_mismatch_repeats_last() -> None: + """On unequal lengths the shorter operand repeats its last value.""" + out = W([0.2, 0.5, 0.8], [0.6]) + assert len(out) == 3 + for v, o in zip([0.2, 0.5, 0.8], out): + assert abs(o - W(v, 0.6)) < TOL -def test_list_value_scalar_amount () -> None: + out = W([0.5], [0.2, 0.4, 0.6]) + assert len(out) == 3 + for a, o in zip([0.2, 0.4, 0.6], out): + assert abs(o - W(0.5, a)) < TOL - """A list value with a scalar knob returns a same-length list.""" - out = W([0.2, 0.5, 0.8], 0.7) - assert isinstance(out, list) - assert len(out) == 3 - assert abs(out[1] - 0.7) < TOL # middle is 0.5 -> amount - for v, o in zip([0.2, 0.5, 0.8], out): - assert abs(o - W(v, 0.7)) < TOL +def test_empty_list_edges() -> None: + """An empty list operand yields an empty list.""" + assert W([], 0.5) == [] + assert W(0.5, []) == [] + assert W([], []) == [] + assert W([], [0.5]) == [] -def test_scalar_value_list_amount () -> None: - """A scalar value with a per-step knob list returns a list.""" +def test_guards_clamp_out_of_range() -> None: + """Out-of-range inputs are absorbed by the edge guards.""" - out = W(0.5, [0.2, 0.5, 0.8]) - assert isinstance(out, list) - assert len(out) == 3 - for a, o in zip([0.2, 0.5, 0.8], out): - assert abs(o - a) < TOL # W(0.5, a) == a + assert W(-0.1, 0.5) == 0.0 + assert W(1.2, 0.5) == 1.0 + assert W(0.5, -0.1) == 0.0 + assert W(0.5, 1.2) == 1.0 -def test_list_value_list_amount () -> None: +def test_bounds_over_random_sweep() -> None: + """Output stays in [0, 1] and is never NaN.""" - """Equal-length lists warp element-wise.""" + rng = random.Random(0) - out = W([0.2, 0.8], [0.7, 0.3]) - assert abs(out[0] - W(0.2, 0.7)) < TOL - assert abs(out[1] - W(0.8, 0.3)) < TOL + for _ in range(2000): + out = W(rng.random(), rng.random()) + assert 0.0 <= out <= 1.0 + assert out == out # not NaN -def test_length_mismatch_repeats_last () -> None: +def test_monotonic_in_amount_and_value() -> None: + """Increasing amount (or value) never decreases the output.""" - """On unequal lengths the shorter operand repeats its last value.""" + by_amount = [W(0.4, a) for a in [0.1, 0.3, 0.5, 0.7, 0.9]] + assert all(by_amount[i] <= by_amount[i + 1] for i in range(len(by_amount) - 1)) - out = W([0.2, 0.5, 0.8], [0.6]) - assert len(out) == 3 - for v, o in zip([0.2, 0.5, 0.8], out): - assert abs(o - W(v, 0.6)) < TOL - - out = W([0.5], [0.2, 0.4, 0.6]) - assert len(out) == 3 - for a, o in zip([0.2, 0.4, 0.6], out): - assert abs(o - W(0.5, a)) < TOL - - -def test_empty_list_edges () -> None: - - """An empty list operand yields an empty list.""" - - assert W([], 0.5) == [] - assert W(0.5, []) == [] - assert W([], []) == [] - assert W([], [0.5]) == [] - - -def test_guards_clamp_out_of_range () -> None: - - """Out-of-range inputs are absorbed by the edge guards.""" - - assert W(-0.1, 0.5) == 0.0 - assert W(1.2, 0.5) == 1.0 - assert W(0.5, -0.1) == 0.0 - assert W(0.5, 1.2) == 1.0 - - -def test_bounds_over_random_sweep () -> None: - - """Output stays in [0, 1] and is never NaN.""" - - rng = random.Random(0) - - for _ in range(2000): - out = W(rng.random(), rng.random()) - assert 0.0 <= out <= 1.0 - assert out == out # not NaN - - -def test_monotonic_in_amount_and_value () -> None: - - """Increasing amount (or value) never decreases the output.""" - - by_amount = [W(0.4, a) for a in [0.1, 0.3, 0.5, 0.7, 0.9]] - assert all(by_amount[i] <= by_amount[i + 1] for i in range(len(by_amount) - 1)) - - by_value = [W(p, 0.7) for p in [0.1, 0.3, 0.5, 0.7, 0.9]] - assert all(by_value[i] <= by_value[i + 1] for i in range(len(by_value) - 1)) + by_value = [W(p, 0.7) for p in [0.1, 0.3, 0.5, 0.7, 0.9]] + assert all(by_value[i] <= by_value[i + 1] for i in range(len(by_value) - 1)) diff --git a/tests/test_sequence_utils_morse_code.py b/tests/test_sequence_utils_morse_code.py index 47d617a..b366566 100644 --- a/tests/test_sequence_utils_morse_code.py +++ b/tests/test_sequence_utils_morse_code.py @@ -7,110 +7,97 @@ TO_INDICES = subsequence.sequence_utils.sequence_to_indices -def test_single_dot () -> None: +def test_single_dot() -> None: + """A lone 'e' (one dot) is a single short note.""" - """A lone 'e' (one dot) is a single short note.""" + assert MORSE("e") == [0.25] - assert MORSE("e") == [0.25] +def test_single_dash_spans_three_cells() -> None: + """A lone 't' (one dash) is one note sustained across three unit cells.""" -def test_single_dash_spans_three_cells () -> None: + assert MORSE("t") == [0.75, 0.0, 0.0] - """A lone 't' (one dash) is one note sustained across three unit cells.""" - assert MORSE("t") == [0.75, 0.0, 0.0] +def test_sos_canonical_pattern() -> None: + """'sos' reconstructs the canonical ...---... — three dots, three dashes, three dots.""" + rhythm = MORSE("sos") + steps = TO_INDICES(rhythm) + durations = [rhythm[s] for s in steps] -def test_sos_canonical_pattern () -> None: + # Nine notes: dot dot dot / dash dash dash / dot dot dot. + assert durations == [0.25, 0.25, 0.25, 0.75, 0.75, 0.75, 0.25, 0.25, 0.25] + # Onset cells: S at 0,2,4 ; O at 8,12,16 ; S at 22,24,26 ; total 27 cells. + assert steps == [0, 2, 4, 8, 12, 16, 22, 24, 26] + assert len(rhythm) == 27 - """'sos' reconstructs the canonical ...---... — three dots, three dashes, three dots.""" - rhythm = MORSE("sos") - steps = TO_INDICES(rhythm) - durations = [rhythm[s] for s in steps] +def test_letter_gap() -> None: + """Two letters are separated by a three-cell rest (letter gap).""" - # Nine notes: dot dot dot / dash dash dash / dot dot dot. - assert durations == [0.25, 0.25, 0.25, 0.75, 0.75, 0.75, 0.25, 0.25, 0.25] - # Onset cells: S at 0,2,4 ; O at 8,12,16 ; S at 22,24,26 ; total 27 cells. - assert steps == [0, 2, 4, 8, 12, 16, 22, 24, 26] - assert len(rhythm) == 27 + assert MORSE("ee") == [0.25, 0.0, 0.0, 0.0, 0.25] -def test_letter_gap () -> None: +def test_word_gap() -> None: + """Two words are separated by a seven-cell rest (word gap).""" - """Two letters are separated by a three-cell rest (letter gap).""" + assert MORSE("e e") == [0.25, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25] - assert MORSE("ee") == [0.25, 0.0, 0.0, 0.0, 0.25] +def test_case_insensitive() -> None: + """Morse is caseless — upper and lower case encode identically.""" -def test_word_gap () -> None: + assert MORSE("SOS") == MORSE("sos") - """Two words are separated by a seven-cell rest (word gap).""" - assert MORSE("e e") == [0.25, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25] +def test_unknown_characters_dropped() -> None: + """Unencodable characters are removed, not substituted.""" + assert MORSE("e#e") == MORSE("ee") -def test_case_insensitive () -> None: - """Morse is caseless — upper and lower case encode identically.""" +def test_whitespace_collapsed_and_trimmed() -> None: + """Runs of whitespace collapse to one word gap; leading/trailing trimmed.""" - assert MORSE("SOS") == MORSE("sos") + assert MORSE("e e") == MORSE("e e") + assert MORSE(" e ") == MORSE("e") -def test_unknown_characters_dropped () -> None: +def test_empty_and_all_invalid_are_noops() -> None: + """No encodable characters yields an empty list (no-op).""" - """Unencodable characters are removed, not substituted.""" + assert MORSE("") == [] + assert MORSE(" ") == [] + assert MORSE("###") == [] - assert MORSE("e#e") == MORSE("ee") +def test_punctuation_supported() -> None: + """Extended punctuation encodes (e.g. '?' = ..--.. = four dots-with-two-dashes).""" -def test_whitespace_collapsed_and_trimmed () -> None: + rhythm = MORSE("?") + durations = [rhythm[s] for s in TO_INDICES(rhythm)] + assert durations == [0.25, 0.25, 0.75, 0.75, 0.25, 0.25] - """Runs of whitespace collapse to one word gap; leading/trailing trimmed.""" - assert MORSE("e e") == MORSE("e e") - assert MORSE(" e ") == MORSE("e") +def test_parameterised_durations() -> None: + """Custom dot/dash rescale note lengths and the dash span (round(dash/dot) cells).""" + # dot is the unit; dash spans round(1.5 / 0.5) = 3 cells. + assert MORSE("e", dot=0.5) == [0.5] + assert MORSE("t", dot=0.5, dash=1.5) == [1.5, 0.0, 0.0] -def test_empty_and_all_invalid_are_noops () -> None: - """No encodable characters yields an empty list (no-op).""" +def test_custom_gaps() -> None: + """Gap parameters set the rest spans in dot-units.""" - assert MORSE("") == [] - assert MORSE(" ") == [] - assert MORSE("###") == [] + # letter_gap of one unit (0.25) -> a single rest cell between the two dots. + assert MORSE("ee", letter_gap=0.25) == [0.25, 0.0, 0.25] -def test_punctuation_supported () -> None: +def test_dot_must_be_positive() -> None: + """dot is the base time unit and must be positive.""" - """Extended punctuation encodes (e.g. '?' = ..--.. = four dots-with-two-dashes).""" - - rhythm = MORSE("?") - durations = [rhythm[s] for s in TO_INDICES(rhythm)] - assert durations == [0.25, 0.25, 0.75, 0.75, 0.25, 0.25] - - -def test_parameterised_durations () -> None: - - """Custom dot/dash rescale note lengths and the dash span (round(dash/dot) cells).""" - - # dot is the unit; dash spans round(1.5 / 0.5) = 3 cells. - assert MORSE("e", dot=0.5) == [0.5] - assert MORSE("t", dot=0.5, dash=1.5) == [1.5, 0.0, 0.0] - - -def test_custom_gaps () -> None: - - """Gap parameters set the rest spans in dot-units.""" - - # letter_gap of one unit (0.25) -> a single rest cell between the two dots. - assert MORSE("ee", letter_gap=0.25) == [0.25, 0.0, 0.25] - - -def test_dot_must_be_positive () -> None: - - """dot is the base time unit and must be positive.""" - - with pytest.raises(ValueError) as exc: - MORSE("e", dot=0.0) - assert "dot must be positive" in str(exc.value) + with pytest.raises(ValueError) as exc: + MORSE("e", dot=0.0) + assert "dot must be positive" in str(exc.value) diff --git a/tests/test_sequence_utils_scale.py b/tests/test_sequence_utils_scale.py index e2c89da..9b06505 100644 --- a/tests/test_sequence_utils_scale.py +++ b/tests/test_sequence_utils_scale.py @@ -1,48 +1,53 @@ - import pytest import subsequence.sequence_utils -def test_scale_clamp_basics () -> None: - """Test standard scaling behavior.""" - # 0-10 -> 0-100 - assert subsequence.sequence_utils.scale_clamp(5, 0, 10, 0, 100) == 50.0 - assert subsequence.sequence_utils.scale_clamp(0, 0, 10, 0, 100) == 0.0 - assert subsequence.sequence_utils.scale_clamp(10, 0, 10, 0, 100) == 100.0 - -def test_scale_clamp_clamping () -> None: - """Test values outside input range are clamped.""" - # Should be clamped to 0 or 100 - assert subsequence.sequence_utils.scale_clamp(-5, 0, 10, 0, 100) == 0.0 - assert subsequence.sequence_utils.scale_clamp(15, 0, 10, 0, 100) == 100.0 - -def test_scale_clamp_reversed_output () -> None: - """Test output range min > max (inverted logic).""" - # Input 0 should map to 100, Input 10 to 0 - # 0 -> 100 - assert subsequence.sequence_utils.scale_clamp(0, 0, 10, 100, 0) == 100.0 - # 10 -> 0 - assert subsequence.sequence_utils.scale_clamp(10, 0, 10, 100, 0) == 0.0 - # 5 -> 50 - assert subsequence.sequence_utils.scale_clamp(5, 0, 10, 100, 0) == 50.0 - - # Clamping check: -5 (below input min) should map to output 'start' (100) - assert subsequence.sequence_utils.scale_clamp(-5, 0, 10, 100, 0) == 100.0 - # Clamping check: 15 (above input max) should map to output 'end' (0) - assert subsequence.sequence_utils.scale_clamp(15, 0, 10, 100, 0) == 0.0 - -def test_scale_clamp_reversed_input () -> None: - """Test input range min > max.""" - # Input range 10 down to 0 mapping to 0-100 - # 10 -> 0, 0 -> 100 - assert subsequence.sequence_utils.scale_clamp(10, 10, 0, 0, 100) == 0.0 - assert subsequence.sequence_utils.scale_clamp(0, 10, 0, 0, 100) == 100.0 - -def test_scale_clamp_zero_width () -> None: - """Test zero-width input range raises error.""" - with pytest.raises(ValueError): - subsequence.sequence_utils.scale_clamp(5, 10, 10, 0, 100) - -def test_scale_clamp_floats () -> None: - """Test with float values.""" - val = subsequence.sequence_utils.scale_clamp(0.5, 0.0, 1.0, 0.0, 10.0) - assert abs(val - 5.0) < 0.000001 + +def test_scale_clamp_basics() -> None: + """Test standard scaling behavior.""" + # 0-10 -> 0-100 + assert subsequence.sequence_utils.scale_clamp(5, 0, 10, 0, 100) == 50.0 + assert subsequence.sequence_utils.scale_clamp(0, 0, 10, 0, 100) == 0.0 + assert subsequence.sequence_utils.scale_clamp(10, 0, 10, 0, 100) == 100.0 + + +def test_scale_clamp_clamping() -> None: + """Test values outside input range are clamped.""" + # Should be clamped to 0 or 100 + assert subsequence.sequence_utils.scale_clamp(-5, 0, 10, 0, 100) == 0.0 + assert subsequence.sequence_utils.scale_clamp(15, 0, 10, 0, 100) == 100.0 + + +def test_scale_clamp_reversed_output() -> None: + """Test output range min > max (inverted logic).""" + # Input 0 should map to 100, Input 10 to 0 + # 0 -> 100 + assert subsequence.sequence_utils.scale_clamp(0, 0, 10, 100, 0) == 100.0 + # 10 -> 0 + assert subsequence.sequence_utils.scale_clamp(10, 0, 10, 100, 0) == 0.0 + # 5 -> 50 + assert subsequence.sequence_utils.scale_clamp(5, 0, 10, 100, 0) == 50.0 + + # Clamping check: -5 (below input min) should map to output 'start' (100) + assert subsequence.sequence_utils.scale_clamp(-5, 0, 10, 100, 0) == 100.0 + # Clamping check: 15 (above input max) should map to output 'end' (0) + assert subsequence.sequence_utils.scale_clamp(15, 0, 10, 100, 0) == 0.0 + + +def test_scale_clamp_reversed_input() -> None: + """Test input range min > max.""" + # Input range 10 down to 0 mapping to 0-100 + # 10 -> 0, 0 -> 100 + assert subsequence.sequence_utils.scale_clamp(10, 10, 0, 0, 100) == 0.0 + assert subsequence.sequence_utils.scale_clamp(0, 10, 0, 0, 100) == 100.0 + + +def test_scale_clamp_zero_width() -> None: + """Test zero-width input range raises error.""" + with pytest.raises(ValueError): + subsequence.sequence_utils.scale_clamp(5, 10, 10, 0, 100) + + +def test_scale_clamp_floats() -> None: + """Test with float values.""" + val = subsequence.sequence_utils.scale_clamp(0.5, 0.0, 1.0, 0.0, 10.0) + assert abs(val - 5.0) < 0.000001 diff --git a/tests/test_sequence_utils_transforms_list.py b/tests/test_sequence_utils_transforms_list.py index 76a9b4c..dbccf5f 100644 --- a/tests/test_sequence_utils_transforms_list.py +++ b/tests/test_sequence_utils_transforms_list.py @@ -11,308 +11,274 @@ # --- tile --- -def test_tile_non_multiple () -> None: - """Cycling to a non-multiple length truncates mid-pattern.""" +def test_tile_non_multiple() -> None: + """Cycling to a non-multiple length truncates mid-pattern.""" - assert TILE([1, 0, 0], 8) == [1, 0, 0, 1, 0, 0, 1, 0] + assert TILE([1, 0, 0], 8) == [1, 0, 0, 1, 0, 0, 1, 0] -def test_tile_exact_multiple () -> None: +def test_tile_exact_multiple() -> None: + """An exact multiple repeats whole cells.""" - """An exact multiple repeats whole cells.""" + assert TILE([1, 0, 0], 6) == [1, 0, 0, 1, 0, 0] - assert TILE([1, 0, 0], 6) == [1, 0, 0, 1, 0, 0] +def test_tile_shorter_than_pattern() -> None: + """A length below the pattern length truncates it.""" -def test_tile_shorter_than_pattern () -> None: + assert TILE([1, 2, 3, 4], 2) == [1, 2] - """A length below the pattern length truncates it.""" - assert TILE([1, 2, 3, 4], 2) == [1, 2] +def test_tile_preserves_type() -> None: + """tile is type-agnostic (works on any element type).""" + assert TILE(["a", "b"], 3) == ["a", "b", "a"] -def test_tile_preserves_type () -> None: - """tile is type-agnostic (works on any element type).""" +def test_tile_zero_length() -> None: + """length <= 0 yields an empty list.""" - assert TILE(["a", "b"], 3) == ["a", "b", "a"] + assert TILE([1, 0], 0) == [] -def test_tile_zero_length () -> None: +def test_tile_negative_length() -> None: + """A negative length yields an empty list.""" - """length <= 0 yields an empty list.""" + assert TILE([1, 0], -4) == [] - assert TILE([1, 0], 0) == [] +def test_tile_empty_raises() -> None: + """Tiling an empty sequence to a positive length is an error.""" -def test_tile_negative_length () -> None: + with pytest.raises(ValueError) as exc: + TILE([], 8) + assert "cannot tile an empty sequence" in str(exc.value) - """A negative length yields an empty list.""" - assert TILE([1, 0], -4) == [] +def test_tile_empty_zero_length_ok() -> None: + """An empty sequence with length 0 is a clean empty list (no raise).""" - -def test_tile_empty_raises () -> None: - - """Tiling an empty sequence to a positive length is an error.""" - - with pytest.raises(ValueError) as exc: - TILE([], 8) - assert "cannot tile an empty sequence" in str(exc.value) - - -def test_tile_empty_zero_length_ok () -> None: - - """An empty sequence with length 0 is a clean empty list (no raise).""" - - assert TILE([], 0) == [] + assert TILE([], 0) == [] # --- mask (keep where active) --- -def test_mask_against_parallel () -> None: - - """A parallel part keeps where it is truthy.""" - - assert MASK([9, 9, 9, 9], against=[1, 0, 1, 0]) == [9, 0, 9, 0] - - -def test_mask_against_truthy_density () -> None: - - """Non-zero density values count as active.""" - - assert MASK([5, 6, 7], against=[0.3, 0.0, 0.9]) == [5, 0, 7] - - -def test_mask_steps_indices () -> None: - - """An index collection keeps only those positions.""" - - assert MASK([9, 9, 9, 9], steps=[0, 2]) == [9, 0, 9, 0] +def test_mask_against_parallel() -> None: + """A parallel part keeps where it is truthy.""" -def test_mask_custom_zero () -> None: + assert MASK([9, 9, 9, 9], against=[1, 0, 1, 0]) == [9, 0, 9, 0] - """The off-value is configurable.""" - assert MASK([9, 9, 9], against=[1, 0, 1], zero=-1) == [9, -1, 9] +def test_mask_against_truthy_density() -> None: + """Non-zero density values count as active.""" + assert MASK([5, 6, 7], against=[0.3, 0.0, 0.9]) == [5, 0, 7] -def test_mask_zero_default_vs_float () -> None: - """Default off-value is 0; pass zero=0.0 to keep floats float.""" +def test_mask_steps_indices() -> None: + """An index collection keeps only those positions.""" - assert MASK([0.5, 0.5], against=[1, 0]) == [0.5, 0] - assert MASK([0.5, 0.5], against=[1, 0], zero=0.0) == [0.5, 0.0] + assert MASK([9, 9, 9, 9], steps=[0, 2]) == [9, 0, 9, 0] -def test_mask_against_shorter_repeats_last () -> None: +def test_mask_custom_zero() -> None: + """The off-value is configurable.""" - """A short parallel part repeats its last value.""" + assert MASK([9, 9, 9], against=[1, 0, 1], zero=-1) == [9, -1, 9] - assert MASK([9, 9, 9, 9], against=[1, 0]) == [9, 0, 0, 0] +def test_mask_zero_default_vs_float() -> None: + """Default off-value is 0; pass zero=0.0 to keep floats float.""" -def test_mask_against_longer_truncates () -> None: + assert MASK([0.5, 0.5], against=[1, 0]) == [0.5, 0] + assert MASK([0.5, 0.5], against=[1, 0], zero=0.0) == [0.5, 0.0] - """A long parallel part is bounded by the sequence length.""" - assert MASK([9, 9], against=[1, 1, 1, 1]) == [9, 9] +def test_mask_against_shorter_repeats_last() -> None: + """A short parallel part repeats its last value.""" + assert MASK([9, 9, 9, 9], against=[1, 0]) == [9, 0, 0, 0] -def test_mask_against_empty_inactive () -> None: - """An empty parallel part is inactive everywhere.""" +def test_mask_against_longer_truncates() -> None: + """A long parallel part is bounded by the sequence length.""" - assert MASK([9, 9], against=[]) == [0, 0] + assert MASK([9, 9], against=[1, 1, 1, 1]) == [9, 9] -def test_mask_steps_out_of_range_ignored () -> None: +def test_mask_against_empty_inactive() -> None: + """An empty parallel part is inactive everywhere.""" - """Indices outside the sequence are ignored.""" + assert MASK([9, 9], against=[]) == [0, 0] - assert MASK([9, 9], steps=[0, 5, -3]) == [9, 0] +def test_mask_steps_out_of_range_ignored() -> None: + """Indices outside the sequence are ignored.""" -def test_mask_empty_sequence () -> None: + assert MASK([9, 9], steps=[0, 5, -3]) == [9, 0] - """An empty sequence yields an empty list.""" - assert MASK([], against=[1]) == [] +def test_mask_empty_sequence() -> None: + """An empty sequence yields an empty list.""" + assert MASK([], against=[1]) == [] -def test_mask_type_agnostic () -> None: - """mask preserves arbitrary element types and off-values.""" +def test_mask_type_agnostic() -> None: + """mask preserves arbitrary element types and off-values.""" - assert MASK(["x", "y"], against=[1, 0], zero=None) == ["x", None] + assert MASK(["x", "y"], against=[1, 0], zero=None) == ["x", None] -def test_mask_both_raises () -> None: +def test_mask_both_raises() -> None: + """Passing both against and steps is an error.""" - """Passing both against and steps is an error.""" + with pytest.raises(ValueError) as exc: + MASK([1], against=[1], steps=[0]) + assert "exactly one of against= or steps=" in str(exc.value) - with pytest.raises(ValueError) as exc: - MASK([1], against=[1], steps=[0]) - assert "exactly one of against= or steps=" in str(exc.value) +def test_mask_neither_raises() -> None: + """Passing neither against nor steps is an error.""" -def test_mask_neither_raises () -> None: - - """Passing neither against nor steps is an error.""" - - with pytest.raises(ValueError) as exc: - MASK([1]) - assert "exactly one of against= or steps=" in str(exc.value) + with pytest.raises(ValueError) as exc: + MASK([1]) + assert "exactly one of against= or steps=" in str(exc.value) # --- choke (suppress where active) --- -def test_choke_against_parallel () -> None: - - """A parallel part suppresses where it is truthy.""" - - assert CHOKE([9, 9, 9, 9], against=[1, 0, 1, 0]) == [0, 9, 0, 9] - -def test_choke_steps_indices () -> None: +def test_choke_against_parallel() -> None: + """A parallel part suppresses where it is truthy.""" - """An index collection suppresses those positions.""" + assert CHOKE([9, 9, 9, 9], against=[1, 0, 1, 0]) == [0, 9, 0, 9] - assert CHOKE([9, 9, 9, 9], steps=[0, 2]) == [0, 9, 0, 9] +def test_choke_steps_indices() -> None: + """An index collection suppresses those positions.""" -def test_choke_custom_floor () -> None: + assert CHOKE([9, 9, 9, 9], steps=[0, 2]) == [0, 9, 0, 9] - """The suppressed value is configurable.""" - assert CHOKE([9, 9], against=[1, 0], floor=-1) == [-1, 9] +def test_choke_custom_floor() -> None: + """The suppressed value is configurable.""" + assert CHOKE([9, 9], against=[1, 0], floor=-1) == [-1, 9] -def test_choke_against_shorter_repeats_last () -> None: - """A short parallel part repeats its last value.""" +def test_choke_against_shorter_repeats_last() -> None: + """A short parallel part repeats its last value.""" - assert CHOKE([9, 9, 9, 9], against=[1, 0]) == [0, 9, 9, 9] + assert CHOKE([9, 9, 9, 9], against=[1, 0]) == [0, 9, 9, 9] -def test_choke_steps_out_of_range_ignored () -> None: +def test_choke_steps_out_of_range_ignored() -> None: + """Indices outside the sequence are ignored.""" - """Indices outside the sequence are ignored.""" + assert CHOKE([9, 9], steps=[5]) == [9, 9] - assert CHOKE([9, 9], steps=[5]) == [9, 9] +def test_choke_both_raises() -> None: + """Passing both against and steps is an error.""" -def test_choke_both_raises () -> None: + with pytest.raises(ValueError): + CHOKE([1], against=[1], steps=[0]) - """Passing both against and steps is an error.""" - with pytest.raises(ValueError): - CHOKE([1], against=[1], steps=[0]) +def test_choke_neither_raises() -> None: + """Passing neither against nor steps is an error.""" - -def test_choke_neither_raises () -> None: - - """Passing neither against nor steps is an error.""" - - with pytest.raises(ValueError): - CHOKE([1]) + with pytest.raises(ValueError): + CHOKE([1]) # --- the complementarity law --- -def test_choke_is_mask_of_complement () -> None: - - """choke(seq, against=x) equals mask(seq, against=NOT x).""" - seq = [3, 5, 7, 9, 2, 4] - sel = [1, 0, 1, 0, 1, 1] - not_sel = [0 if v else 1 for v in sel] - assert CHOKE(seq, against=sel) == MASK(seq, against=not_sel) +def test_choke_is_mask_of_complement() -> None: + """choke(seq, against=x) equals mask(seq, against=NOT x).""" + seq = [3, 5, 7, 9, 2, 4] + sel = [1, 0, 1, 0, 1, 1] + not_sel = [0 if v else 1 for v in sel] + assert CHOKE(seq, against=sel) == MASK(seq, against=not_sel) -def test_mask_choke_partition () -> None: - """At each step exactly one of mask/choke keeps the original value.""" +def test_mask_choke_partition() -> None: + """At each step exactly one of mask/choke keeps the original value.""" - seq = [3, 5, 7, 9, 2, 4] - sel = [1, 0, 1, 0, 1, 1] - masked = MASK(seq, against=sel) - choked = CHOKE(seq, against=sel) + seq = [3, 5, 7, 9, 2, 4] + sel = [1, 0, 1, 0, 1, 1] + masked = MASK(seq, against=sel) + choked = CHOKE(seq, against=sel) - for original, m, c in zip(seq, masked, choked): - assert (m == original) != (c == original) + for original, m, c in zip(seq, masked, choked): + assert (m == original) != (c == original) # --- displace (phase-shift a pattern, wrapping) --- -def test_displace_positive_moves_later () -> None: - - """A positive amount pushes content later (right), wrapping the tail to the front.""" - - assert DISPLACE([1, 0, 0, 0], 1) == [0, 1, 0, 0] - assert DISPLACE([1, 2, 3, 4], 1) == [4, 1, 2, 3] - - -def test_displace_negative_moves_earlier () -> None: - - """A negative amount pulls content earlier (left).""" - - assert DISPLACE([0, 1, 0, 0], -1) == [1, 0, 0, 0] - - -def test_displace_zero_is_unchanged_copy () -> None: - """amount 0 returns an equal but fresh list (never the same object).""" +def test_displace_positive_moves_later() -> None: + """A positive amount pushes content later (right), wrapping the tail to the front.""" - original = [1, 2, 3, 4] - result = DISPLACE(original, 0) + assert DISPLACE([1, 0, 0, 0], 1) == [0, 1, 0, 0] + assert DISPLACE([1, 2, 3, 4], 1) == [4, 1, 2, 3] - assert result == original - assert result is not original +def test_displace_negative_moves_earlier() -> None: + """A negative amount pulls content earlier (left).""" -def test_displace_full_revolution () -> None: + assert DISPLACE([0, 1, 0, 0], -1) == [1, 0, 0, 0] - """A whole-length shift is a full revolution — unchanged.""" - assert DISPLACE([1, 2, 3, 4], 4) == [1, 2, 3, 4] +def test_displace_zero_is_unchanged_copy() -> None: + """amount 0 returns an equal but fresh list (never the same object).""" + original = [1, 2, 3, 4] + result = DISPLACE(original, 0) -def test_displace_over_length_wraps () -> None: + assert result == original + assert result is not original - """An over-length amount wraps modulo the length.""" - assert DISPLACE([1, 0, 0, 0], 5) == DISPLACE([1, 0, 0, 0], 1) +def test_displace_full_revolution() -> None: + """A whole-length shift is a full revolution — unchanged.""" + assert DISPLACE([1, 2, 3, 4], 4) == [1, 2, 3, 4] -def test_displace_empty () -> None: - """An empty sequence is a no-op (and the divide-by-zero guard).""" +def test_displace_over_length_wraps() -> None: + """An over-length amount wraps modulo the length.""" - assert DISPLACE([], 3) == [] + assert DISPLACE([1, 0, 0, 0], 5) == DISPLACE([1, 0, 0, 0], 1) -def test_displace_single_element () -> None: +def test_displace_empty() -> None: + """An empty sequence is a no-op (and the divide-by-zero guard).""" - """A single-element list is unchanged for any amount.""" + assert DISPLACE([], 3) == [] - assert DISPLACE([9], 7) == [9] +def test_displace_single_element() -> None: + """A single-element list is unchanged for any amount.""" -def test_displace_type_agnostic () -> None: + assert DISPLACE([9], 7) == [9] - """displace reorders any element type.""" - assert DISPLACE(["a", "b", "c"], 1) == ["c", "a", "b"] +def test_displace_type_agnostic() -> None: + """displace reorders any element type.""" + assert DISPLACE(["a", "b", "c"], 1) == ["c", "a", "b"] -def test_displace_does_not_mutate_input () -> None: - """The original sequence is left untouched.""" +def test_displace_does_not_mutate_input() -> None: + """The original sequence is left untouched.""" - original = [1, 0, 1, 0] - DISPLACE(original, 2) + original = [1, 0, 1, 0] + DISPLACE(original, 2) - assert original == [1, 0, 1, 0] + assert original == [1, 0, 1, 0] diff --git a/tests/test_sequence_utils_transforms_value.py b/tests/test_sequence_utils_transforms_value.py index b9980fe..fc59fba 100644 --- a/tests/test_sequence_utils_transforms_value.py +++ b/tests/test_sequence_utils_transforms_value.py @@ -11,178 +11,159 @@ # --- flip --- -def test_flip_default_is_complement () -> None: - """The default range gives the [0, 1] complement 1 - x.""" +def test_flip_default_is_complement() -> None: + """The default range gives the [0, 1] complement 1 - x.""" - assert abs(F(0.0) - 1.0) < TOL - assert abs(F(1.0) - 0.0) < TOL - assert abs(F(0.25) - 0.75) < TOL + assert abs(F(0.0) - 1.0) < TOL + assert abs(F(1.0) - 0.0) < TOL + assert abs(F(0.25) - 0.75) < TOL -def test_flip_is_involution () -> None: +def test_flip_is_involution() -> None: + """Flipping twice returns the original value.""" - """Flipping twice returns the original value.""" + rng = random.Random(0) - rng = random.Random(0) + for _ in range(500): + x = rng.random() + assert abs(F(F(x)) - x) < TOL - for _ in range(500): - x = rng.random() - assert abs(F(F(x)) - x) < TOL +def test_flip_range_aware() -> None: + """A non-unit range mirrors within that range.""" -def test_flip_range_aware () -> None: + assert F(100, 0, 127) == 27 + assert F(0, 0, 127) == 127 + assert F(127, 0, 127) == 0 - """A non-unit range mirrors within that range.""" - assert F(100, 0, 127) == 27 - assert F(0, 0, 127) == 127 - assert F(127, 0, 127) == 0 +def test_flip_logical_not_on_binary() -> None: + """On a 0/1 list flip is the logical complement.""" + out = F([1, 0, 0, 1]) + for o, e in zip(out, [0.0, 1.0, 1.0, 0.0]): + assert abs(o - e) < TOL -def test_flip_logical_not_on_binary () -> None: - """On a 0/1 list flip is the logical complement.""" +def test_flip_scalar_returns_float() -> None: + """A scalar value returns a float.""" - out = F([1, 0, 0, 1]) - for o, e in zip(out, [0.0, 1.0, 1.0, 0.0]): - assert abs(o - e) < TOL + assert isinstance(F(0.3), float) -def test_flip_scalar_returns_float () -> None: +def test_flip_list_returns_list() -> None: + """A list value returns a same-length list.""" - """A scalar value returns a float.""" + out = F([0.1, 0.2, 0.9]) + assert isinstance(out, list) + assert len(out) == 3 - assert isinstance(F(0.3), float) +def test_flip_empty_list() -> None: + """An empty list yields an empty list.""" -def test_flip_list_returns_list () -> None: + assert F([]) == [] - """A list value returns a same-length list.""" - out = F([0.1, 0.2, 0.9]) - assert isinstance(out, list) - assert len(out) == 3 +def test_flip_does_not_clamp() -> None: + """Out-of-range input is reflected, not clamped.""" - -def test_flip_empty_list () -> None: - - """An empty list yields an empty list.""" - - assert F([]) == [] - - -def test_flip_does_not_clamp () -> None: - - """Out-of-range input is reflected, not clamped.""" - - assert abs(F(1.2) - (-0.2)) < TOL + assert abs(F(1.2) - (-0.2)) < TOL # --- clamp --- -def test_clamp_within_passes () -> None: - - """A value already in range is unchanged.""" - - assert C(0.5) == 0.5 - -def test_clamp_bounds () -> None: +def test_clamp_within_passes() -> None: + """A value already in range is unchanged.""" - """Out-of-range values snap to the nearest bound.""" + assert C(0.5) == 0.5 - assert C(1.2) == 1.0 - assert C(-0.3) == 0.0 +def test_clamp_bounds() -> None: + """Out-of-range values snap to the nearest bound.""" -def test_clamp_custom_range () -> None: + assert C(1.2) == 1.0 + assert C(-0.3) == 0.0 - """A custom range bounds to that range.""" - assert C(200, 0, 127) == 127 - assert C(-5, 0, 127) == 0 +def test_clamp_custom_range() -> None: + """A custom range bounds to that range.""" + assert C(200, 0, 127) == 127 + assert C(-5, 0, 127) == 0 -def test_clamp_list () -> None: - """A list is bounded element-wise.""" +def test_clamp_list() -> None: + """A list is bounded element-wise.""" - out = C([-1.0, 0.5, 2.0]) - for o, e in zip(out, [0.0, 0.5, 1.0]): - assert abs(o - e) < TOL + out = C([-1.0, 0.5, 2.0]) + for o, e in zip(out, [0.0, 0.5, 1.0]): + assert abs(o - e) < TOL -def test_clamp_scalar_returns_float () -> None: +def test_clamp_scalar_returns_float() -> None: + """A scalar value returns a float.""" - """A scalar value returns a float.""" + assert isinstance(C(0.4), float) - assert isinstance(C(0.4), float) +def test_clamp_list_returns_list() -> None: + """A list value returns a list.""" -def test_clamp_list_returns_list () -> None: + assert isinstance(C([0.1, 0.2]), list) - """A list value returns a list.""" - assert isinstance(C([0.1, 0.2]), list) +def test_clamp_empty_list() -> None: + """An empty list yields an empty list.""" + assert C([]) == [] -def test_clamp_empty_list () -> None: - """An empty list yields an empty list.""" +def test_flip_then_clamp_composes() -> None: + """flip composes with clamp to tame out-of-range input.""" - assert C([]) == [] - - -def test_flip_then_clamp_composes () -> None: - - """flip composes with clamp to tame out-of-range input.""" - - assert C(F(1.2)) == 0.0 + assert C(F(1.2)) == 0.0 # --- threshold --- -def test_threshold_is_strict () -> None: - - """Exactly the cutoff does NOT fire; strictly above does.""" - - assert TH([0.5, 0.50001, 0.49999]) == [0, 1, 0] - - -def test_threshold_default_cutoff () -> None: - - """The default cutoff is 0.5.""" - assert TH([0.9, 0.1, 0.6, 0.4]) == [1, 0, 1, 0] +def test_threshold_is_strict() -> None: + """Exactly the cutoff does NOT fire; strictly above does.""" + assert TH([0.5, 0.50001, 0.49999]) == [0, 1, 0] -def test_threshold_custom_cutoff () -> None: - """A custom cutoff gates against that level.""" +def test_threshold_default_cutoff() -> None: + """The default cutoff is 0.5.""" - assert TH([0.3, 0.7], 0.6) == [0, 1] + assert TH([0.9, 0.1, 0.6, 0.4]) == [1, 0, 1, 0] -def test_threshold_returns_ints () -> None: +def test_threshold_custom_cutoff() -> None: + """A custom cutoff gates against that level.""" - """Every output element is an int 0 or 1.""" + assert TH([0.3, 0.7], 0.6) == [0, 1] - for v in TH([0.9, 0.1, 0.6]): - assert isinstance(v, int) - assert v in (0, 1) +def test_threshold_returns_ints() -> None: + """Every output element is an int 0 or 1.""" -def test_threshold_empty () -> None: + for v in TH([0.9, 0.1, 0.6]): + assert isinstance(v, int) + assert v in (0, 1) - """An empty sequence yields an empty list.""" - assert TH([]) == [] +def test_threshold_empty() -> None: + """An empty sequence yields an empty list.""" + assert TH([]) == [] -def test_threshold_pairs_with_indices () -> None: - """threshold + sequence_to_indices gives the firing steps.""" +def test_threshold_pairs_with_indices() -> None: + """threshold + sequence_to_indices gives the firing steps.""" - gate = TH([0.9, 0.1, 0.6, 0.4]) - assert subsequence.sequence_utils.sequence_to_indices(gate) == [0, 2] + gate = TH([0.9, 0.1, 0.6, 0.4]) + assert subsequence.sequence_utils.sequence_to_indices(gate) == [0, 2] diff --git a/tests/test_sequencer_dispatch_safety.py b/tests/test_sequencer_dispatch_safety.py index b3ecd57..956f67d 100644 --- a/tests/test_sequencer_dispatch_safety.py +++ b/tests/test_sequencer_dispatch_safety.py @@ -17,74 +17,81 @@ @pytest.mark.asyncio -async def test_process_pulse_survives_malformed_event (patch_midi: None) -> None: - - """A bad event is logged and skipped; subsequent good events still dispatch.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - - # Pulse 0: a malformed note_on whose velocity is a tuple — what would - # happen if hit_steps(velocity=(25, 75)) silently stored the tuple - # on Note.velocity, the way it did before the per-method validation. - bad_event = subsequence.sequencer.MidiEvent( - pulse = 0, - message_type = 'note_on', - channel = 0, - note = 60, - velocity = (25, 75), # type: ignore[arg-type] # deliberately bad - ) - - # Pulse 0: a well-formed event scheduled right after, which must still - # fire even though the prior event blew up. - good_event = subsequence.sequencer.MidiEvent( - pulse = 0, - message_type = 'note_on', - channel = 0, - note = 64, - velocity = 90, - ) - - sequencer._push_event(bad_event) - sequencer._push_event(good_event) - - # Should not raise. - await sequencer._process_pulse(pulse=0) - - # The event queue must be drained (both events popped). - assert len(sequencer.event_queue) == 0 - - # The good event must have made it into active_notes; the bad one - # must not have, since its tracking branch raised. - assert (0, 0, 64) in sequencer.active_notes - assert (0, 0, 60) not in sequencer.active_notes +async def test_process_pulse_survives_malformed_event(patch_midi: None) -> None: + """A bad event is logged and skipped; subsequent good events still dispatch.""" + + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + + # Pulse 0: a malformed note_on whose velocity is a tuple — what would + # happen if hit_steps(velocity=(25, 75)) silently stored the tuple + # on Note.velocity, the way it did before the per-method validation. + bad_event = subsequence.sequencer.MidiEvent( + pulse=0, + message_type="note_on", + channel=0, + note=60, + velocity=(25, 75), # type: ignore[arg-type] # deliberately bad + ) + + # Pulse 0: a well-formed event scheduled right after, which must still + # fire even though the prior event blew up. + good_event = subsequence.sequencer.MidiEvent( + pulse=0, + message_type="note_on", + channel=0, + note=64, + velocity=90, + ) + + sequencer._push_event(bad_event) + sequencer._push_event(good_event) + + # Should not raise. + await sequencer._process_pulse(pulse=0) + + # The event queue must be drained (both events popped). + assert len(sequencer.event_queue) == 0 + + # The good event must have made it into active_notes; the bad one + # must not have, since its tracking branch raised. + assert (0, 0, 64) in sequencer.active_notes + assert (0, 0, 60) not in sequencer.active_notes @pytest.mark.asyncio -async def test_process_pulse_logs_failed_event_with_context (patch_midi: None, caplog: pytest.LogCaptureFixture) -> None: - - """The skipped-event log must include enough context to debug — pulse, type, device, channel.""" - - import logging - caplog.set_level(logging.ERROR, logger="subsequence.sequencer") - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - - bad_event = subsequence.sequencer.MidiEvent( - pulse = 17, - message_type = 'note_on', - channel = 5, - note = 60, - velocity = (25, 75), # type: ignore[arg-type] - device = 0, - ) - sequencer._push_event(bad_event) - - await sequencer._process_pulse(pulse=17) - - # Find the dispatch-failure log - failure_messages = [r for r in caplog.records if "Failed to dispatch" in r.getMessage()] - assert len(failure_messages) == 1 - msg = failure_messages[0].getMessage() - assert "pulse 17" in msg - assert "note_on" in msg - assert "channel=5" in msg +async def test_process_pulse_logs_failed_event_with_context( + patch_midi: None, caplog: pytest.LogCaptureFixture +) -> None: + """The skipped-event log must include enough context to debug — pulse, type, device, channel.""" + + import logging + + caplog.set_level(logging.ERROR, logger="subsequence.sequencer") + + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + + bad_event = subsequence.sequencer.MidiEvent( + pulse=17, + message_type="note_on", + channel=5, + note=60, + velocity=(25, 75), # type: ignore[arg-type] + device=0, + ) + sequencer._push_event(bad_event) + + await sequencer._process_pulse(pulse=17) + + # Find the dispatch-failure log + failure_messages = [ + r for r in caplog.records if "Failed to dispatch" in r.getMessage() + ] + assert len(failure_messages) == 1 + msg = failure_messages[0].getMessage() + assert "pulse 17" in msg + assert "note_on" in msg + assert "channel=5" in msg diff --git a/tests/test_sequencer_events.py b/tests/test_sequencer_events.py index 5be18e5..4c1e99b 100644 --- a/tests/test_sequencer_events.py +++ b/tests/test_sequencer_events.py @@ -7,327 +7,354 @@ @pytest.mark.asyncio -async def test_reschedule_event_precedes_pattern_rebuild (patch_midi: None) -> None: +async def test_reschedule_event_precedes_pattern_rebuild(patch_midi: None) -> None: + """The reschedule_pulse event should fire before pattern on_reschedule.""" - """The reschedule_pulse event should fire before pattern on_reschedule.""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + order: list[str] = [] - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - order: list[str] = [] + def on_reschedule_pulse( + pulse: int, patterns: list[subsequence.pattern.Pattern] + ) -> None: + """Record reschedule event order.""" - def on_reschedule_pulse (pulse: int, patterns: list[subsequence.pattern.Pattern]) -> None: + order.append("event") - """Record reschedule event order.""" + sequencer.on_event("reschedule_pulse", on_reschedule_pulse) - order.append("event") + class TestPattern(subsequence.pattern.Pattern): + """Pattern that records reschedule timing.""" - sequencer.on_event("reschedule_pulse", on_reschedule_pulse) + def __init__(self) -> None: + """Initialize a short pattern cycle.""" - class TestPattern (subsequence.pattern.Pattern): + super().__init__(channel=0, length=2, reschedule_lookahead=1) - """Pattern that records reschedule timing.""" + def on_reschedule(self) -> None: + """Record the pattern rebuild order.""" - def __init__ (self) -> None: + order.append("pattern") - """Initialize a short pattern cycle.""" + pattern = TestPattern() + await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) - super().__init__(channel=0, length=2, reschedule_lookahead=1) + reschedule_pulse = ( + pattern.length * sequencer.pulses_per_beat + - pattern.reschedule_lookahead * sequencer.pulses_per_beat + ) + await sequencer._maybe_reschedule_patterns(reschedule_pulse) - - def on_reschedule (self) -> None: - - """Record the pattern rebuild order.""" - - order.append("pattern") - - - pattern = TestPattern() - await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) - - reschedule_pulse = pattern.length * sequencer.pulses_per_beat - pattern.reschedule_lookahead * sequencer.pulses_per_beat - await sequencer._maybe_reschedule_patterns(reschedule_pulse) - - assert order == ["event", "pattern"] + assert order == ["event", "pattern"] @pytest.mark.asyncio -async def test_repeating_callback_fires_without_patterns (patch_midi: None) -> None: - - """Repeating callbacks should fire even when no patterns are scheduled.""" +async def test_repeating_callback_fires_without_patterns(patch_midi: None) -> None: + """Repeating callbacks should fire even when no patterns are scheduled.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - fired: list[int] = [] + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + fired: list[int] = [] - def on_tick (pulse: int) -> None: + def on_tick(pulse: int) -> None: + """Record when the callback fires.""" - """Record when the callback fires.""" + fired.append(pulse) - fired.append(pulse) + await sequencer.schedule_callback_repeating( + on_tick, interval_beats=2, start_pulse=0, reschedule_lookahead=1 + ) - await sequencer.schedule_callback_repeating(on_tick, interval_beats=2, start_pulse=0, reschedule_lookahead=1) + fire_pulse = (2 - 1) * sequencer.pulses_per_beat + await sequencer._maybe_reschedule_patterns(fire_pulse) - fire_pulse = (2 - 1) * sequencer.pulses_per_beat - await sequencer._maybe_reschedule_patterns(fire_pulse) - - assert fired == [fire_pulse] + assert fired == [fire_pulse] @pytest.mark.asyncio -async def test_callback_precedes_reschedule_event (patch_midi: None) -> None: - - """Repeating callbacks should run before reschedule events and pattern rebuilds.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - order: list[str] = [] - - def on_tick (pulse: int) -> None: - - """Record when the repeating callback fires.""" - - order.append("callback") - - def on_reschedule_pulse (pulse: int, patterns: list[subsequence.pattern.Pattern]) -> None: - - """Record reschedule event order.""" +async def test_callback_precedes_reschedule_event(patch_midi: None) -> None: + """Repeating callbacks should run before reschedule events and pattern rebuilds.""" - order.append("event") + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + order: list[str] = [] - sequencer.on_event("reschedule_pulse", on_reschedule_pulse) + def on_tick(pulse: int) -> None: + """Record when the repeating callback fires.""" - class TestPattern (subsequence.pattern.Pattern): + order.append("callback") - """Pattern that records reschedule timing.""" + def on_reschedule_pulse( + pulse: int, patterns: list[subsequence.pattern.Pattern] + ) -> None: + """Record reschedule event order.""" - def __init__ (self) -> None: + order.append("event") - """Initialize a short pattern cycle.""" + sequencer.on_event("reschedule_pulse", on_reschedule_pulse) - super().__init__(channel=0, length=2, reschedule_lookahead=1) + class TestPattern(subsequence.pattern.Pattern): + """Pattern that records reschedule timing.""" + def __init__(self) -> None: + """Initialize a short pattern cycle.""" - def on_reschedule (self) -> None: + super().__init__(channel=0, length=2, reschedule_lookahead=1) - """Record the pattern rebuild order.""" + def on_reschedule(self) -> None: + """Record the pattern rebuild order.""" - order.append("pattern") + order.append("pattern") - pattern = TestPattern() - await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) - await sequencer.schedule_callback_repeating(on_tick, interval_beats=2, start_pulse=0, reschedule_lookahead=1) + pattern = TestPattern() + await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) + await sequencer.schedule_callback_repeating( + on_tick, interval_beats=2, start_pulse=0, reschedule_lookahead=1 + ) - reschedule_pulse = pattern.length * sequencer.pulses_per_beat - pattern.reschedule_lookahead * sequencer.pulses_per_beat - await sequencer._maybe_reschedule_patterns(reschedule_pulse) + reschedule_pulse = ( + pattern.length * sequencer.pulses_per_beat + - pattern.reschedule_lookahead * sequencer.pulses_per_beat + ) + await sequencer._maybe_reschedule_patterns(reschedule_pulse) - assert order == ["callback", "event", "pattern"] + assert order == ["callback", "event", "pattern"] @pytest.mark.asyncio -async def test_dynamic_length_change_on_reschedule (patch_midi: None) -> None: +async def test_dynamic_length_change_on_reschedule(patch_midi: None) -> None: + """When a pattern changes its length in on_reschedule, the sequencer should use the new length for the next cycle.""" - """When a pattern changes its length in on_reschedule, the sequencer should use the new length for the next cycle.""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + class GrowingPattern(subsequence.pattern.Pattern): + """Pattern that doubles its length on first reschedule.""" - class GrowingPattern (subsequence.pattern.Pattern): + def __init__(self) -> None: + """Initialize with a 2-beat cycle.""" - """Pattern that doubles its length on first reschedule.""" + super().__init__(channel=0, length=2, reschedule_lookahead=1) + self.reschedule_count = 0 - def __init__ (self) -> None: + def on_reschedule(self) -> None: + """Double the length on the first reschedule.""" - """Initialize with a 2-beat cycle.""" + self.reschedule_count += 1 - super().__init__(channel=0, length=2, reschedule_lookahead=1) - self.reschedule_count = 0 + if self.reschedule_count == 1: + self.length = 4 - def on_reschedule (self) -> None: + pattern = GrowingPattern() + await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) - """Double the length on the first reschedule.""" + # First reschedule fires at: length(2) - lookahead(1) = 1 beat = 24 pulses. + first_reschedule_pulse = int((2 - 1) * sequencer.pulses_per_beat) + await sequencer._maybe_reschedule_patterns(first_reschedule_pulse) - self.reschedule_count += 1 + assert pattern.reschedule_count == 1 + assert pattern.length == 4 - if self.reschedule_count == 1: - self.length = 4 + # After the first reschedule, the next cycle starts at pulse 2*24=48. + # With new length=4 and lookahead=1, next reschedule should be at: 48 + (4-1)*24 = 48+72 = 120. + # The old length would give: 48 + (2-1)*24 = 48+24 = 72. + # We check the reschedule queue to verify the new timing is used. + _, _, scheduled = sequencer.reschedule_queue[0] - pattern = GrowingPattern() - await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) + expected_next_reschedule = int(2 * sequencer.pulses_per_beat) + int( + (4 - 1) * sequencer.pulses_per_beat + ) - # First reschedule fires at: length(2) - lookahead(1) = 1 beat = 24 pulses. - first_reschedule_pulse = int((2 - 1) * sequencer.pulses_per_beat) - await sequencer._maybe_reschedule_patterns(first_reschedule_pulse) - - assert pattern.reschedule_count == 1 - assert pattern.length == 4 - - # After the first reschedule, the next cycle starts at pulse 2*24=48. - # With new length=4 and lookahead=1, next reschedule should be at: 48 + (4-1)*24 = 48+72 = 120. - # The old length would give: 48 + (2-1)*24 = 48+24 = 72. - # We check the reschedule queue to verify the new timing is used. - _, _, scheduled = sequencer.reschedule_queue[0] - - expected_next_reschedule = int(2 * sequencer.pulses_per_beat) + int((4 - 1) * sequencer.pulses_per_beat) - - assert scheduled.next_reschedule_pulse == expected_next_reschedule - assert scheduled.length_pulses == int(4 * sequencer.pulses_per_beat) + assert scheduled.next_reschedule_pulse == expected_next_reschedule + assert scheduled.length_pulses == int(4 * sequencer.pulses_per_beat) @pytest.mark.asyncio -async def test_schedule_pattern_includes_cc_events (patch_midi: None) -> None: - - """CC events on a pattern should appear in the sequencer event queue.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=3, length=4) - - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=0, message_type='control_change', control=74, value=100) - ) - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=24, message_type='pitchwheel', value=4000) - ) - - await sequencer.schedule_pattern(pattern, start_pulse=0) - - # Extract the CC/pitchwheel events from the queue - cc_events = [e for e in sequencer.event_queue if e.message_type in ('control_change', 'pitchwheel')] - - assert len(cc_events) == 2 - - cc = next(e for e in cc_events if e.message_type == 'control_change') - assert cc.pulse == 0 - assert cc.channel == 3 - assert cc.control == 74 - assert cc.value == 100 - - pb = next(e for e in cc_events if e.message_type == 'pitchwheel') - assert pb.pulse == 24 - assert pb.channel == 3 - assert pb.value == 4000 +async def test_schedule_pattern_includes_cc_events(patch_midi: None) -> None: + """CC events on a pattern should appear in the sequencer event queue.""" + + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern(channel=3, length=4) + + pattern.cc_events.append( + subsequence.pattern.CcEvent( + pulse=0, message_type="control_change", control=74, value=100 + ) + ) + pattern.cc_events.append( + subsequence.pattern.CcEvent(pulse=24, message_type="pitchwheel", value=4000) + ) + + await sequencer.schedule_pattern(pattern, start_pulse=0) + + # Extract the CC/pitchwheel events from the queue + cc_events = [ + e + for e in sequencer.event_queue + if e.message_type in ("control_change", "pitchwheel") + ] + + assert len(cc_events) == 2 + + cc = next(e for e in cc_events if e.message_type == "control_change") + assert cc.pulse == 0 + assert cc.channel == 3 + assert cc.control == 74 + assert cc.value == 100 + + pb = next(e for e in cc_events if e.message_type == "pitchwheel") + assert pb.pulse == 24 + assert pb.channel == 3 + assert pb.value == 4000 @pytest.mark.asyncio -async def test_schedule_pattern_includes_osc_events (patch_midi: None) -> None: +async def test_schedule_pattern_includes_osc_events(patch_midi: None) -> None: + """OSC events on a pattern should appear in the sequencer event queue as message_type='osc'.""" - """OSC events on a pattern should appear in the sequencer event queue as message_type='osc'.""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern(channel=0, length=4) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4) + pattern.osc_events.append( + subsequence.pattern.OscEvent(pulse=0, address="/mixer/fader/1", args=(0.8,)) + ) + pattern.osc_events.append( + subsequence.pattern.OscEvent(pulse=24, address="/fx/reverb/wet", args=(0.4,)) + ) - pattern.osc_events.append( - subsequence.pattern.OscEvent(pulse=0, address="/mixer/fader/1", args=(0.8,)) - ) - pattern.osc_events.append( - subsequence.pattern.OscEvent(pulse=24, address="/fx/reverb/wet", args=(0.4,)) - ) + await sequencer.schedule_pattern(pattern, start_pulse=0) - await sequencer.schedule_pattern(pattern, start_pulse=0) + osc_events = [e for e in sequencer.event_queue if e.message_type == "osc"] - osc_events = [e for e in sequencer.event_queue if e.message_type == 'osc'] + assert len(osc_events) == 2 - assert len(osc_events) == 2 + e1 = next(e for e in osc_events if e.pulse == 0) + assert e1.data == ("/mixer/fader/1", (0.8,)) - e1 = next(e for e in osc_events if e.pulse == 0) - assert e1.data == ("/mixer/fader/1", (0.8,)) - - e2 = next(e for e in osc_events if e.pulse == 24) - assert e2.data == ("/fx/reverb/wet", (0.4,)) + e2 = next(e for e in osc_events if e.pulse == 24) + assert e2.data == ("/fx/reverb/wet", (0.4,)) @pytest.mark.asyncio -async def test_osc_event_dispatched_to_server (patch_midi: None) -> None: +async def test_osc_event_dispatched_to_server(patch_midi: None) -> None: + """When osc_server is set, processing an OSC event should call osc_server.send().""" - """When osc_server is set, processing an OSC event should call osc_server.send().""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + mock_osc_server = unittest.mock.MagicMock() + sequencer.osc_server = mock_osc_server - mock_osc_server = unittest.mock.MagicMock() - sequencer.osc_server = mock_osc_server + # Push an OSC event directly into the queue + import heapq - # Push an OSC event directly into the queue - import heapq - heapq.heappush( - sequencer.event_queue, - subsequence.sequencer.MidiEvent(pulse=0, message_type='osc', channel=0, data=("/fader/1", (0.75,))) - ) + heapq.heappush( + sequencer.event_queue, + subsequence.sequencer.MidiEvent( + pulse=0, message_type="osc", channel=0, data=("/fader/1", (0.75,)) + ), + ) - await sequencer._process_pulse(0) + await sequencer._process_pulse(0) - mock_osc_server.send.assert_called_once_with("/fader/1", 0.75) + mock_osc_server.send.assert_called_once_with("/fader/1", 0.75) @pytest.mark.asyncio -async def test_osc_event_noop_without_server (patch_midi: None) -> None: - - """When osc_server is None, processing an OSC event should not raise.""" - - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - assert sequencer.osc_server is None - - import heapq - heapq.heappush( - sequencer.event_queue, - subsequence.sequencer.MidiEvent(pulse=0, message_type='osc', channel=0, data=("/fader/1", (0.5,))) - ) +async def test_osc_event_noop_without_server(patch_midi: None) -> None: + """When osc_server is None, processing an OSC event should not raise.""" - # Should complete without error - await sequencer._process_pulse(0) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + assert sequencer.osc_server is None - assert len(sequencer.event_queue) == 0 + import heapq + heapq.heappush( + sequencer.event_queue, + subsequence.sequencer.MidiEvent( + pulse=0, message_type="osc", channel=0, data=("/fader/1", (0.5,)) + ), + ) -def test_same_pulse_events_preserve_insertion_order () -> None: + # Should complete without error + await sequencer._process_pulse(0) - """Events sharing a pulse must dispatch in FIFO order. + assert len(sequencer.event_queue) == 0 - NRPN/RPN bursts (CC 99 → 98 → 6 → 38) and Bank Select before Program - Change rely on this guarantee. Without the ``MidiEvent.sequence`` - tie-breaker, ``heapq`` ordering of equal-pulse events is undefined. - """ - import heapq +def test_same_pulse_events_preserve_insertion_order() -> None: + """Events sharing a pulse must dispatch in FIFO order. - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + NRPN/RPN bursts (CC 99 → 98 → 6 → 38) and Bank Select before Program + Change rely on this guarantee. Without the ``MidiEvent.sequence`` + tie-breaker, ``heapq`` ordering of equal-pulse events is undefined. + """ - # Build an NRPN-style burst plus an unrelated note and CC at the same pulse, - # repeated twice — exactly the shape the heap reordered without the fix. - control_sequence = [99, 98, 6, 38, 0, 7, 99, 98, 6, 38] + import heapq - for i, control in enumerate(control_sequence): + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - event = subsequence.sequencer.MidiEvent( - pulse = 10, - message_type = 'control_change' if control != 0 else 'note_on', - channel = 0, - control = control, - value = i, - note = 60 if control == 0 else 0, - velocity = 100 if control == 0 else 0, - ) - sequencer._push_event(event) + # Build an NRPN-style burst plus an unrelated note and CC at the same pulse, + # repeated twice — exactly the shape the heap reordered without the fix. + control_sequence = [99, 98, 6, 38, 0, 7, 99, 98, 6, 38] - dispatched_values = [] + for i, control in enumerate(control_sequence): + event = subsequence.sequencer.MidiEvent( + pulse=10, + message_type="control_change" if control != 0 else "note_on", + channel=0, + control=control, + value=i, + note=60 if control == 0 else 0, + velocity=100 if control == 0 else 0, + ) + sequencer._push_event(event) - while sequencer.event_queue: - event = heapq.heappop(sequencer.event_queue) - dispatched_values.append(event.value) + dispatched_values = [] - assert dispatched_values == list(range(len(control_sequence))) + while sequencer.event_queue: + event = heapq.heappop(sequencer.event_queue) + dispatched_values.append(event.value) + assert dispatched_values == list(range(len(control_sequence))) -def test_event_counter_resets_independently_per_sequencer () -> None: - """Two separate Sequencer instances must not share counter state. +def test_event_counter_resets_independently_per_sequencer() -> None: + """Two separate Sequencer instances must not share counter state. - A leak here would manifest as test-order-dependent ordering bugs. - """ + A leak here would manifest as test-order-dependent ordering bugs. + """ - seq_a = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - seq_b = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + seq_a = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + seq_b = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - event_a = subsequence.sequencer.MidiEvent(pulse=0, message_type='control_change', channel=0, control=1, value=0) - event_b = subsequence.sequencer.MidiEvent(pulse=0, message_type='control_change', channel=0, control=2, value=0) + event_a = subsequence.sequencer.MidiEvent( + pulse=0, message_type="control_change", channel=0, control=1, value=0 + ) + event_b = subsequence.sequencer.MidiEvent( + pulse=0, message_type="control_change", channel=0, control=2, value=0 + ) - seq_a._push_event(event_a) - seq_b._push_event(event_b) + seq_a._push_event(event_a) + seq_b._push_event(event_b) - # Both sequencers' first event should get sequence=0 from their own counter. - assert event_a.sequence == 0 - assert event_b.sequence == 0 + # Both sequencers' first event should get sequence=0 from their own counter. + assert event_a.sequence == 0 + assert event_b.sequence == 0 diff --git a/tests/test_tier1_fixes_2026_07.py b/tests/test_tier1_fixes_2026_07.py index 0d6d137..922eece 100644 --- a/tests/test_tier1_fixes_2026_07.py +++ b/tests/test_tier1_fixes_2026_07.py @@ -18,227 +18,220 @@ import subsequence.sequencer -def test_parameter_only_harmony_recall_keeps_style (patch_midi: None) -> None: +def test_parameter_only_harmony_recall_keeps_style(patch_midi: None) -> None: + """harmony(gravity=...) after harmony(style=...) keeps the configured style. - """harmony(gravity=...) after harmony(style=...) keeps the configured style. + A parameter-only re-call used to fall back to functional_major, + silently replacing the configured graph. + """ - A parameter-only re-call used to fall back to functional_major, - silently replacing the configured graph. - """ + comp = subsequence.Composition(bpm=120, key="A") + comp.harmony(style="aeolian_minor") - comp = subsequence.Composition(bpm=120, key="A") - comp.harmony(style="aeolian_minor") + comp.harmony(gravity=0.2) - comp.harmony(gravity=0.2) + assert comp._harmony_style == "aeolian_minor" - assert comp._harmony_style == "aeolian_minor" +def test_first_harmony_call_still_defaults_to_functional_major( + patch_midi: None, +) -> None: + """With no style ever configured, a bare harmony() call keeps today's default.""" -def test_first_harmony_call_still_defaults_to_functional_major (patch_midi: None) -> None: + comp = subsequence.Composition(bpm=120, key="C") + comp.harmony(gravity=0.5) - """With no style ever configured, a bare harmony() call keeps today's default.""" + assert comp._harmony_style == "functional_major" - comp = subsequence.Composition(bpm=120, key="C") - comp.harmony(gravity=0.5) - assert comp._harmony_style == "functional_major" +def test_performer_mute_claims_ownership_from_transition(patch_midi: None) -> None: + """mute() during a transition approach window survives the boundary. + The performer's mute removes the name from _transition_muted, so the + section-boundary restore pass no longer silently unmutes it. + """ -def test_performer_mute_claims_ownership_from_transition (patch_midi: None) -> None: + comp = subsequence.Composition(bpm=120) - """mute() during a transition approach window survives the boundary. + class _Stub: + _muted = False - The performer's mute removes the name from _transition_muted, so the - section-boundary restore pass no longer silently unmutes it. - """ + comp._running_patterns["bass"] = _Stub() + comp._transition_muted.add("bass") - comp = subsequence.Composition(bpm=120) + comp.mute("bass") - class _Stub: - _muted = False + assert "bass" not in comp._transition_muted + assert comp._running_patterns["bass"]._muted is True - comp._running_patterns["bass"] = _Stub() - comp._transition_muted.add("bass") + # The boundary restore pass only touches names still in the set — + # simulate it directly and confirm the performer's mute holds. + for name in comp._transition_muted: + comp._running_patterns[name]._muted = False + comp._transition_muted.clear() - comp.mute("bass") + assert comp._running_patterns["bass"]._muted is True - assert "bass" not in comp._transition_muted - assert comp._running_patterns["bass"]._muted is True - # The boundary restore pass only touches names still in the set — - # simulate it directly and confirm the performer's mute holds. - for name in comp._transition_muted: - comp._running_patterns[name]._muted = False - comp._transition_muted.clear() +def test_performer_unmute_claims_ownership_from_transition(patch_midi: None) -> None: + """unmute() also removes the transition machinery's claim on the pattern.""" - assert comp._running_patterns["bass"]._muted is True + comp = subsequence.Composition(bpm=120) + class _Stub: + _muted = True -def test_performer_unmute_claims_ownership_from_transition (patch_midi: None) -> None: + comp._running_patterns["lead"] = _Stub() + comp._transition_muted.add("lead") - """unmute() also removes the transition machinery's claim on the pattern.""" + comp.unmute("lead") - comp = subsequence.Composition(bpm=120) + assert "lead" not in comp._transition_muted + assert comp._running_patterns["lead"]._muted is False - class _Stub: - _muted = True - comp._running_patterns["lead"] = _Stub() - comp._transition_muted.add("lead") +def test_failing_builder_leaves_pattern_silent(patch_midi: None) -> None: + """A builder that raises mid-build discards what it already placed. - comp.unmute("lead") + The log promises the pattern "will be silent this cycle" — events + placed before the exception used to play anyway. + """ - assert "lead" not in comp._transition_muted - assert comp._running_patterns["lead"]._muted is False + comp = subsequence.Composition(bpm=120, seed=1) + @comp.pattern(channel=1, beats=4) + def partial(p: typing.Any) -> None: + p.note(60, beat=0.0, duration=0.5) + raise RuntimeError("halfway through the build") -def test_failing_builder_leaves_pattern_silent (patch_midi: None) -> None: + pending = comp._pending_patterns[0] + pattern = comp._build_pattern_from_pending(pending) - """A builder that raises mid-build discards what it already placed. + pattern._rebuild() - The log promises the pattern "will be silent this cycle" — events - placed before the exception used to play anyway. - """ + assert pattern.steps == {} + assert pattern.cc_events == [] + assert pattern.raw_note_events == [] - comp = subsequence.Composition(bpm=120, seed=1) - @comp.pattern(channel=1, beats=4) - def partial (p: typing.Any) -> None: +def test_set_bpm_validates_before_link_proposal(patch_midi: None) -> None: + """set_bpm(0) raises without proposing the tempo to the Link session.""" - p.note(60, beat=0.0, duration=0.5) - raise RuntimeError("halfway through the build") + seq = subsequence.sequencer.Sequencer(initial_bpm=120) - pending = comp._pending_patterns[0] - pattern = comp._build_pattern_from_pending(pending) + class _LinkSpy: + def __init__(self) -> None: + self.proposed: typing.List[float] = [] - pattern._rebuild() + def request_tempo(self, bpm: float) -> None: + self.proposed.append(bpm) - assert pattern.steps == {} - assert pattern.cc_events == [] - assert pattern.raw_note_events == [] + spy = _LinkSpy() + seq._link_clock = spy + seq.running = True + with pytest.raises(ValueError): + seq.set_bpm(0) -def test_set_bpm_validates_before_link_proposal (patch_midi: None) -> None: + assert spy.proposed == [] - """set_bpm(0) raises without proposing the tempo to the Link session.""" - seq = subsequence.sequencer.Sequencer(initial_bpm=120) +def test_dashboard_page_carries_ws_port_token() -> None: + """index.html uses the __WS_PORT__ token web_ui.py substitutes at serve time. - class _LinkSpy: + The page hardcoding 8765 made WebUI(ws_port=...) a dashboard that + could never connect; the raw file must keep a regex fallback so it + still works opened directly from disk. + """ - def __init__ (self) -> None: + import os + import subsequence.web_ui - self.proposed: typing.List[float] = [] + page_path = os.path.join( + os.path.dirname(subsequence.web_ui.__file__), "assets", "web", "index.html" + ) + page = open(page_path, encoding="utf-8").read() - def request_tempo (self, bpm: float) -> None: + assert "__WS_PORT__" in page + assert "8765" in page # the no-server fallback - self.proposed.append(bpm) + substituted = page.replace("__WS_PORT__", "9999") + assert "9999" in substituted - spy = _LinkSpy() - seq._link_clock = spy - seq.running = True - with pytest.raises(ValueError): - seq.set_bpm(0) +def test_queue_next_error_names_the_operation() -> None: + """An unknown section error says which navigation call failed.""" - assert spy.proposed == [] + state = subsequence.form_state.FormState([subsequence.forms.Section("verse", 4)]) + with pytest.raises(ValueError) as exc: + state.queue_next("nope") -def test_dashboard_page_carries_ws_port_token () -> None: + assert "queue_next" in str(exc.value) + assert "nope" in str(exc.value) - """index.html uses the __WS_PORT__ token web_ui.py substitutes at serve time. - The page hardcoding 8765 made WebUI(ws_port=...) a dashboard that - could never connect; the raw file must keep a regex fallback so it - still works opened directly from disk. - """ +def test_chord_pattern_class_removed() -> None: + """ChordPattern (zero callers, zero tests) is gone; the helpers remain.""" - import os - import subsequence.web_ui + assert not hasattr(subsequence.harmony, "ChordPattern") + assert callable(subsequence.harmony.diatonic_chords) + assert callable(subsequence.harmony.diatonic_chord_sequence) - page_path = os.path.join(os.path.dirname(subsequence.web_ui.__file__), "assets", "web", "index.html") - page = open(page_path, encoding="utf-8").read() - assert "__WS_PORT__" in page - assert "8765" in page # the no-server fallback +def test_section_info_at_bar_honours_loop_and_hold() -> None: + """The layout lookup agrees with the playhead about the loop seam. - substituted = page.replace("__WS_PORT__", "9999") - assert "9999" in substituted + The last section of a looping form leads back to the first (so + ``.ending`` is True there); a holding form's last section repeats + itself; a stopping form still reports None. + """ + sections = [ + subsequence.forms.Section("verse", 2), + subsequence.forms.Section("chorus", 2), + ] -def test_queue_next_error_names_the_operation () -> None: + looping = subsequence.form_state.FormState(list(sections), loop=True) + info = looping.section_info_at_bar(4) # last bar of chorus + assert info is not None + assert info.next_section == "verse" - """An unknown section error says which navigation call failed.""" + holding = subsequence.form_state.FormState(list(sections), at_end="hold") + info = holding.section_info_at_bar(4) + assert info is not None + assert info.next_section == "chorus" - state = subsequence.form_state.FormState([subsequence.forms.Section("verse", 4)]) + stopping = subsequence.form_state.FormState(list(sections)) + info = stopping.section_info_at_bar(4) + assert info is not None + assert info.next_section is None - with pytest.raises(ValueError) as exc: - state.queue_next("nope") - assert "queue_next" in str(exc.value) - assert "nope" in str(exc.value) +def test_stop_completes_cleanup_after_cancelled_loop(patch_midi: None) -> None: + """A cancelled run loop no longer aborts stop()'s cleanup. + CancelledError is a BaseException, so the old ``except Exception`` + let it propagate out of stop(), skipping pending-send cancellation, + panic, port close, and recording save (the Ctrl-C path). + """ -def test_chord_pattern_class_removed () -> None: + import asyncio - """ChordPattern (zero callers, zero tests) is gone; the helpers remain.""" + async def drive() -> None: + seq = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=240 + ) - assert not hasattr(subsequence.harmony, "ChordPattern") - assert callable(subsequence.harmony.diatonic_chords) - assert callable(subsequence.harmony.diatonic_chord_sequence) + await seq.start() + assert seq.task is not None + seq.task.cancel() -def test_section_info_at_bar_honours_loop_and_hold () -> None: + # Must not raise, and must run the full cleanup. + await seq.stop() - """The layout lookup agrees with the playhead about the loop seam. + assert seq.running is False + assert len(seq._output_devices) == 0 # close_all() was reached - The last section of a looping form leads back to the first (so - ``.ending`` is True there); a holding form's last section repeats - itself; a stopping form still reports None. - """ - - sections = [subsequence.forms.Section("verse", 2), subsequence.forms.Section("chorus", 2)] - - looping = subsequence.form_state.FormState(list(sections), loop=True) - info = looping.section_info_at_bar(4) # last bar of chorus - assert info is not None - assert info.next_section == "verse" - - holding = subsequence.form_state.FormState(list(sections), at_end="hold") - info = holding.section_info_at_bar(4) - assert info is not None - assert info.next_section == "chorus" - - stopping = subsequence.form_state.FormState(list(sections)) - info = stopping.section_info_at_bar(4) - assert info is not None - assert info.next_section is None - - -def test_stop_completes_cleanup_after_cancelled_loop (patch_midi: None) -> None: - - """A cancelled run loop no longer aborts stop()'s cleanup. - - CancelledError is a BaseException, so the old ``except Exception`` - let it propagate out of stop(), skipping pending-send cancellation, - panic, port close, and recording save (the Ctrl-C path). - """ - - import asyncio - - async def drive () -> None: - - seq = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=240) - - await seq.start() - assert seq.task is not None - - seq.task.cancel() - - # Must not raise, and must run the full cleanup. - await seq.stop() - - assert seq.running is False - assert len(seq._output_devices) == 0 # close_all() was reached - - asyncio.run(drive()) + asyncio.run(drive()) diff --git a/tests/test_trigger.py b/tests/test_trigger.py index c2778bf..a33b143 100644 --- a/tests/test_trigger.py +++ b/tests/test_trigger.py @@ -9,242 +9,227 @@ @pytest.mark.asyncio -async def test_trigger_immediate_creates_pattern (patch_midi: None) -> None: +async def test_trigger_immediate_creates_pattern(patch_midi: None) -> None: + """Calling trigger() with quantize=0 should schedule a pattern immediately.""" - """Calling trigger() with quantize=0 should schedule a pattern immediately.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - key="C" - ) + # Record the pulse when trigger is called + current_pulse = composition._sequencer.pulse_count - # Record the pulse when trigger is called - current_pulse = composition._sequencer.pulse_count + # Define a simple trigger function + def builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0, velocity=100, duration=0.5) - # Define a simple trigger function - def builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0, velocity=100, duration=0.5) + # Manually set up the event loop before calling trigger + # (In real playback this is done by play() calling asyncio.run(_run())) + import asyncio - # Manually set up the event loop before calling trigger - # (In real playback this is done by play() calling asyncio.run(_run())) - import asyncio - await composition._sequencer.start() + await composition._sequencer.start() - # Call trigger - composition.trigger(builder, channel=1, quantize=0) + # Call trigger + composition.trigger(builder, channel=1, quantize=0) - # Wait a moment for the async scheduling to complete - await asyncio.sleep(0.01) + # Wait a moment for the async scheduling to complete + await asyncio.sleep(0.01) - # Check that the event queue has been populated - assert len(composition._sequencer.event_queue) > 0 + # Check that the event queue has been populated + assert len(composition._sequencer.event_queue) > 0 - # Verify that we have note_on and note_off events - event_types = [e.message_type for e in composition._sequencer.event_queue] - assert "note_on" in event_types - assert "note_off" in event_types + # Verify that we have note_on and note_off events + event_types = [e.message_type for e in composition._sequencer.event_queue] + assert "note_on" in event_types + assert "note_off" in event_types - await composition._sequencer.stop() + await composition._sequencer.stop() @pytest.mark.asyncio -async def test_trigger_with_quantize_beat_boundary (patch_midi: None) -> None: +async def test_trigger_with_quantize_beat_boundary(patch_midi: None) -> None: + """Calling trigger() with quantize=dur.QUARTER should snap to next beat.""" - """Calling trigger() with quantize=dur.QUARTER should snap to next beat.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - key="C" - ) + await composition._sequencer.start() - await composition._sequencer.start() + # Advance the sequencer to mid-beat (pulse 5 out of 24 pulses per beat) + composition._sequencer.pulse_count = 5 - # Advance the sequencer to mid-beat (pulse 5 out of 24 pulses per beat) - composition._sequencer.pulse_count = 5 + def builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(62, beat=0, velocity=90, duration=0.5) - def builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(62, beat=0, velocity=90, duration=0.5) + composition.trigger(builder, channel=1, quantize=dur.QUARTER) - composition.trigger(builder, channel=1, quantize=dur.QUARTER) + await asyncio.sleep(0.01) - await asyncio.sleep(0.01) + # The next beat boundary is at pulse 24 + # Verify that events are scheduled at or after that pulse + event_pulses = [e.pulse for e in composition._sequencer.event_queue] + assert all(p >= 24 for p in event_pulses), ( + f"Expected events at pulse >= 24, got {event_pulses}" + ) - # The next beat boundary is at pulse 24 - # Verify that events are scheduled at or after that pulse - event_pulses = [e.pulse for e in composition._sequencer.event_queue] - assert all(p >= 24 for p in event_pulses), f"Expected events at pulse >= 24, got {event_pulses}" - - await composition._sequencer.stop() + await composition._sequencer.stop() @pytest.mark.asyncio -async def test_trigger_with_quantize_bar_boundary (patch_midi: None) -> None: - - """Calling trigger() with quantize=dur.WHOLE should snap to next bar (4 beats).""" +async def test_trigger_with_quantize_bar_boundary(patch_midi: None) -> None: + """Calling trigger() with quantize=dur.WHOLE should snap to next bar (4 beats).""" - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - key="C" - ) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - await composition._sequencer.start() + await composition._sequencer.start() - # Advance to middle of a bar (pulse 50 out of 96 pulses per bar at 120 BPM) - composition._sequencer.pulse_count = 50 + # Advance to middle of a bar (pulse 50 out of 96 pulses per bar at 120 BPM) + composition._sequencer.pulse_count = 50 - def builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(64, beat=0, velocity=85, duration=0.5) + def builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(64, beat=0, velocity=85, duration=0.5) - composition.trigger(builder, channel=1, quantize=dur.WHOLE) + composition.trigger(builder, channel=1, quantize=dur.WHOLE) - await asyncio.sleep(0.01) + await asyncio.sleep(0.01) - # The next bar boundary is at pulse 96 - event_pulses = [e.pulse for e in composition._sequencer.event_queue] - assert all(p >= 96 for p in event_pulses), f"Expected events at pulse >= 96, got {event_pulses}" + # The next bar boundary is at pulse 96 + event_pulses = [e.pulse for e in composition._sequencer.event_queue] + assert all(p >= 96 for p in event_pulses), ( + f"Expected events at pulse >= 96, got {event_pulses}" + ) - await composition._sequencer.stop() + await composition._sequencer.stop() @pytest.mark.asyncio -async def test_trigger_with_chord_context (patch_midi: None) -> None: +async def test_trigger_with_chord_context(patch_midi: None) -> None: + """Calling trigger() with chord=True should inject the current chord.""" - """Calling trigger() with chord=True should inject the current chord.""" + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - key="C" - ) + composition.harmony(style="functional_major", cycle_beats=4) - composition.harmony(style="functional_major", cycle_beats=4) + await composition._sequencer.start() - await composition._sequencer.start() + captured_chord = None - captured_chord = None + def builder( + p: "subsequence.pattern_builder.PatternBuilder", + chord: "subsequence.chords.Chord", + ) -> None: + nonlocal captured_chord + # Access the chord from the parameter + captured_chord = chord + p.note(60, beat=0, velocity=100, duration=0.5) - def builder (p: "subsequence.pattern_builder.PatternBuilder", chord: "subsequence.chords.Chord") -> None: - nonlocal captured_chord - # Access the chord from the parameter - captured_chord = chord - p.note(60, beat=0, velocity=100, duration=0.5) + composition.trigger(builder, channel=1, chord=True, quantize=0) - composition.trigger(builder, channel=1, chord=True, quantize=0) + await asyncio.sleep(0.01) - await asyncio.sleep(0.01) + # Verify that the chord was available to the builder + assert captured_chord is not None + assert hasattr(captured_chord, "tones") - # Verify that the chord was available to the builder - assert captured_chord is not None - assert hasattr(captured_chord, 'tones') - - await composition._sequencer.stop() + await composition._sequencer.stop() @pytest.mark.asyncio -async def test_trigger_with_drum_note_map (patch_midi: None) -> None: - - """Calling trigger() with drum_note_map should work with drum names.""" +async def test_trigger_with_drum_note_map(patch_midi: None) -> None: + """Calling trigger() with drum_note_map should work with drum names.""" - import subsequence.constants.instruments.gm_drums as gm_drums + import subsequence.constants.instruments.gm_drums as gm_drums - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - ) + composition = subsequence.Composition( + output_device="Dummy MIDI", + bpm=120, + ) - await composition._sequencer.start() + await composition._sequencer.start() - def builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note("kick_1", beat=0, velocity=100, duration=0.5) + def builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note("kick_1", beat=0, velocity=100, duration=0.5) - composition.trigger( - builder, - channel=9, - drum_note_map=gm_drums.GM_DRUM_MAP, - quantize=0 - ) + composition.trigger( + builder, channel=9, drum_note_map=gm_drums.GM_DRUM_MAP, quantize=0 + ) - await asyncio.sleep(0.01) + await asyncio.sleep(0.01) - # Verify that MIDI notes were generated (kick_1 should map to a specific note) - assert len(composition._sequencer.event_queue) > 0 + # Verify that MIDI notes were generated (kick_1 should map to a specific note) + assert len(composition._sequencer.event_queue) > 0 - await composition._sequencer.stop() + await composition._sequencer.stop() @pytest.mark.asyncio -async def test_trigger_builder_exception_is_logged (patch_midi: None) -> None: +async def test_trigger_builder_exception_is_logged(patch_midi: None) -> None: + """If the trigger builder raises, the pattern should be silent but not crash.""" - """If the trigger builder raises, the pattern should be silent but not crash.""" + composition = subsequence.Composition( + output_device="Dummy MIDI", + bpm=120, + ) - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - ) + await composition._sequencer.start() - await composition._sequencer.start() + def broken_builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + raise ValueError("Test error") - def broken_builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - raise ValueError("Test error") + # Should not raise + composition.trigger(broken_builder, channel=1) - # Should not raise - composition.trigger(broken_builder, channel=1) + await asyncio.sleep(0.01) - await asyncio.sleep(0.01) + # The builder raised, so the pattern produced no events (it's silent) and the + # trigger did not crash the composition. + assert composition._sequencer.event_queue == [] - # The builder raised, so the pattern produced no events (it's silent) and the - # trigger did not crash the composition. - assert composition._sequencer.event_queue == [] - - await composition._sequencer.stop() + await composition._sequencer.stop() @pytest.mark.asyncio -async def test_trigger_before_playback_is_safe (patch_midi: None) -> None: - - """Calling trigger() before playback started should be safe (not crash).""" +async def test_trigger_before_playback_is_safe(patch_midi: None) -> None: + """Calling trigger() before playback started should be safe (not crash).""" - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - ) + composition = subsequence.Composition( + output_device="Dummy MIDI", + bpm=120, + ) - def builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - p.note(60, beat=0, velocity=100, duration=0.5) + def builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + p.note(60, beat=0, velocity=100, duration=0.5) - # Trigger without calling play() - should not crash, and should not leak - # events into the queue before the sequencer is running. - composition.trigger(builder, channel=1) + # Trigger without calling play() - should not crash, and should not leak + # events into the queue before the sequencer is running. + composition.trigger(builder, channel=1) - assert composition._sequencer.event_queue == [] + assert composition._sequencer.event_queue == [] @pytest.mark.asyncio -async def test_trigger_uses_builder_chaining (patch_midi: None) -> None: - - """Trigger should support PatternBuilder method chaining.""" +async def test_trigger_uses_builder_chaining(patch_midi: None) -> None: + """Trigger should support PatternBuilder method chaining.""" - composition = subsequence.Composition( - output_device="Dummy MIDI", - bpm=120, - ) + composition = subsequence.Composition( + output_device="Dummy MIDI", + bpm=120, + ) - await composition._sequencer.start() + await composition._sequencer.start() - def builder (p: "subsequence.pattern_builder.PatternBuilder") -> None: - # Use method chaining - p.note(60, beat=0, velocity=100, duration=0.5).note(62, beat=0.5, velocity=90, duration=0.5) + def builder(p: "subsequence.pattern_builder.PatternBuilder") -> None: + # Use method chaining + p.note(60, beat=0, velocity=100, duration=0.5).note( + 62, beat=0.5, velocity=90, duration=0.5 + ) - composition.trigger(builder, channel=1) + composition.trigger(builder, channel=1) - await asyncio.sleep(0.01) + await asyncio.sleep(0.01) - # Verify that both notes were added to the event queue - event_types = [e.message_type for e in composition._sequencer.event_queue] - note_on_count = event_types.count("note_on") - assert note_on_count >= 2, f"Expected at least 2 note_on events, got {note_on_count}" + # Verify that both notes were added to the event queue + event_types = [e.message_type for e in composition._sequencer.event_queue] + note_on_count = event_types.count("note_on") + assert note_on_count >= 2, ( + f"Expected at least 2 note_on events, got {note_on_count}" + ) - await composition._sequencer.stop() + await composition._sequencer.stop() diff --git a/tests/test_tuning.py b/tests/test_tuning.py index 6bc4c2e..3b165ce 100644 --- a/tests/test_tuning.py +++ b/tests/test_tuning.py @@ -15,49 +15,54 @@ # ── Helpers ─────────────────────────────────────────────────────────────────── -def _make_builder ( - channel: int = 0, - length: float = 4, - cycle: int = 0, - data: typing.Optional[dict] = None, -) -> typing.Tuple[subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder]: - """Create a Pattern and PatternBuilder pair for testing.""" +def _make_builder( + channel: int = 0, + length: float = 4, + cycle: int = 0, + data: typing.Optional[dict] = None, +) -> typing.Tuple[ + subsequence.pattern.Pattern, subsequence.pattern_builder.PatternBuilder +]: + """Create a Pattern and PatternBuilder pair for testing.""" - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - pattern = subsequence.pattern.Pattern(channel=channel, length=length) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pattern, - cycle=cycle, - default_grid=default_grid, - data=data if data is not None else {}, - ) - return pattern, builder + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pattern = subsequence.pattern.Pattern(channel=channel, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pattern, + cycle=cycle, + default_grid=default_grid, + data=data if data is not None else {}, + ) + return pattern, builder -def _pitches (pattern: subsequence.pattern.Pattern) -> typing.List[int]: +def _pitches(pattern: subsequence.pattern.Pattern) -> typing.List[int]: + """Return all note pitches in pulse order.""" - """Return all note pitches in pulse order.""" + return [ + n.pitch for pulse in sorted(pattern.steps) for n in pattern.steps[pulse].notes + ] - return [n.pitch for pulse in sorted(pattern.steps) for n in pattern.steps[pulse].notes] +def _channels(pattern: subsequence.pattern.Pattern) -> typing.List[int]: + """Return all note channels in pulse order.""" -def _channels (pattern: subsequence.pattern.Pattern) -> typing.List[int]: + return [ + n.channel for pulse in sorted(pattern.steps) for n in pattern.steps[pulse].notes + ] - """Return all note channels in pulse order.""" - return [n.channel for pulse in sorted(pattern.steps) for n in pattern.steps[pulse].notes] +def _bend_events( + pattern: subsequence.pattern.Pattern, +) -> typing.List[typing.Tuple[int, float, int]]: + """Return list of (pulse, value, channel) for all pitchwheel events.""" - -def _bend_events (pattern: subsequence.pattern.Pattern) -> typing.List[typing.Tuple[int, float, int]]: - - """Return list of (pulse, value, channel) for all pitchwheel events.""" - - return [ - (ev.pulse, ev.value, ev.channel) - for ev in pattern.cc_events - if ev.message_type == "pitchwheel" - ] + return [ + (ev.pulse, ev.value, ev.channel) + for ev in pattern.cc_events + if ev.message_type == "pitchwheel" + ] # ── .scl parser ─────────────────────────────────────────────────────────────── @@ -119,413 +124,476 @@ def _bend_events (pattern: subsequence.pattern.Pattern) -> typing.List[typing.Tu """ -def test_parse_12tet_cents () -> None: - t = subsequence.tuning.Tuning.from_scl_string(SCL_12TET) - assert t.size == 12 - assert abs(t.cents[0] - 100.0) < 1e-6 - assert abs(t.cents[-1] - 1200.0) < 1e-6 - assert t.description == "12-tone equal temperament" +def test_parse_12tet_cents() -> None: + t = subsequence.tuning.Tuning.from_scl_string(SCL_12TET) + assert t.size == 12 + assert abs(t.cents[0] - 100.0) < 1e-6 + assert abs(t.cents[-1] - 1200.0) < 1e-6 + assert t.description == "12-tone equal temperament" -def test_parse_meantone_mixed () -> None: - """Quarter-comma meantone uses both cents and ratio notation.""" - t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) - assert t.size == 12 - # 5/4 = 386.313... cents - assert abs(t.cents[3] - 1200.0 * math.log2(5 / 4)) < 0.001 - # 2/1 = 1200 cents - assert abs(t.cents[-1] - 1200.0) < 0.001 +def test_parse_meantone_mixed() -> None: + """Quarter-comma meantone uses both cents and ratio notation.""" + t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) + assert t.size == 12 + # 5/4 = 386.313... cents + assert abs(t.cents[3] - 1200.0 * math.log2(5 / 4)) < 0.001 + # 2/1 = 1200 cents + assert abs(t.cents[-1] - 1200.0) < 0.001 -def test_parse_just_intonation_ratios () -> None: - t = subsequence.tuning.Tuning.from_scl_string(SCL_JUST) - assert t.size == 7 - assert abs(t.cents[0] - 1200.0 * math.log2(9 / 8)) < 0.001 # 203.91 cents - assert abs(t.cents[2] - 1200.0 * math.log2(4 / 3)) < 0.001 # 498.04 cents - assert abs(t.cents[-1] - 1200.0) < 0.001 +def test_parse_just_intonation_ratios() -> None: + t = subsequence.tuning.Tuning.from_scl_string(SCL_JUST) + assert t.size == 7 + assert abs(t.cents[0] - 1200.0 * math.log2(9 / 8)) < 0.001 # 203.91 cents + assert abs(t.cents[2] - 1200.0 * math.log2(4 / 3)) < 0.001 # 498.04 cents + assert abs(t.cents[-1] - 1200.0) < 0.001 -def test_parse_bad_count_raises () -> None: - with pytest.raises(ValueError, match="expected 3 pitch values"): - subsequence.tuning.Tuning.from_scl_string(SCL_BAD_COUNT) +def test_parse_bad_count_raises() -> None: + with pytest.raises(ValueError, match="expected 3 pitch values"): + subsequence.tuning.Tuning.from_scl_string(SCL_BAD_COUNT) -def test_parse_comment_lines_ignored () -> None: - """Lines starting with ! are comments and must not affect the parse.""" - t = subsequence.tuning.Tuning.from_scl_string(SCL_12TET) - assert t.size == 12 # not 13 (the ! lines aren't counted) +def test_parse_comment_lines_ignored() -> None: + """Lines starting with ! are comments and must not affect the parse.""" + t = subsequence.tuning.Tuning.from_scl_string(SCL_12TET) + assert t.size == 12 # not 13 (the ! lines aren't counted) -def test_from_scl_reads_file (tmp_path: pathlib.Path) -> None: - """from_scl() opens a real .scl file from disk, via a PathLike or a str path.""" - scl_path = tmp_path / "twelve.scl" - scl_path.write_text(SCL_12TET, encoding="utf-8") +def test_from_scl_reads_file(tmp_path: pathlib.Path) -> None: + """from_scl() opens a real .scl file from disk, via a PathLike or a str path.""" + scl_path = tmp_path / "twelve.scl" + scl_path.write_text(SCL_12TET, encoding="utf-8") - t = subsequence.tuning.Tuning.from_scl(scl_path) - assert t.size == 12 - assert t.description == "12-tone equal temperament" - assert abs(t.cents[0] - 100.0) < 1e-6 + t = subsequence.tuning.Tuning.from_scl(scl_path) + assert t.size == 12 + assert t.description == "12-tone equal temperament" + assert abs(t.cents[0] - 100.0) < 1e-6 - # A plain string path parses identically. - t_str = subsequence.tuning.Tuning.from_scl(str(scl_path)) - assert t_str.cents == t.cents + # A plain string path parses identically. + t_str = subsequence.tuning.Tuning.from_scl(str(scl_path)) + assert t_str.cents == t.cents # ── Factory methods ─────────────────────────────────────────────────────────── -def test_from_cents_roundtrip () -> None: - cents_in = [100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0, 1100.0, 1200.0] - t = subsequence.tuning.Tuning.from_cents(cents_in) - assert t.cents == cents_in - assert t.size == 12 - - -def test_from_ratios () -> None: - t = subsequence.tuning.Tuning.from_ratios([9 / 8, 5 / 4, 4 / 3, 3 / 2, 5 / 3, 15 / 8, 2.0]) - assert t.size == 7 - assert abs(t.cents[3] - 1200.0 * math.log2(3 / 2)) < 0.001 # 701.955 cents - - -def test_equal_12 () -> None: - t = subsequence.tuning.Tuning.equal(12) - assert t.size == 12 - for i, c in enumerate(t.cents): - assert abs(c - (i + 1) * 100.0) < 1e-9 - - -def test_equal_19 () -> None: - t = subsequence.tuning.Tuning.equal(19) - assert t.size == 19 - step = 1200.0 / 19 - assert abs(t.cents[0] - step) < 1e-9 - assert abs(t.cents[-1] - 1200.0) < 1e-9 - - -def test_period_cents () -> None: - t = subsequence.tuning.Tuning.equal(12) - assert abs(t.period_cents - 1200.0) < 1e-9 - - -# ── pitch_bend_for_note() ───────────────────────────────────────────────────── - -def test_12tet_tuning_zero_bend () -> None: - """Standard 12-TET produces (same_note, 0.0) for every note.""" - t = subsequence.tuning.Tuning.equal(12) - for midi in range(21, 109): - nearest, bend = t.pitch_bend_for_note(midi, reference_note=60) - assert nearest == midi, f"note {midi}: nearest={nearest}" - assert abs(bend) < 1e-9, f"note {midi}: bend={bend}" - -def test_meantone_major_third () -> None: - """Quarter-comma meantone major third is 386.31 cents; 12-TET is 400 cents. +def test_from_cents_roundtrip() -> None: + cents_in = [ + 100.0, + 200.0, + 300.0, + 400.0, + 500.0, + 600.0, + 700.0, + 800.0, + 900.0, + 1000.0, + 1100.0, + 1200.0, + ] + t = subsequence.tuning.Tuning.from_cents(cents_in) + assert t.cents == cents_in + assert t.size == 12 - For MIDI 64 (E4, degree 4 from C4=60), the tuning is 386.31 cents above 60. - 12-TET equivalent: 400 cents = MIDI 64. Offset = -13.69 cents = -0.1369 semitones. - With bend_range=2: bend ≈ -0.0684. - """ - t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) - nearest, bend = t.pitch_bend_for_note(64, reference_note=60, bend_range=2.0) - # Major third in meantone is 386.31 cents → continuous note = 60 + 3.8631 = 63.8631 - # Nearest = 64, bend = (63.8631 - 64) / 2.0 = -0.0684 - assert nearest == 64 - assert abs(bend - (-0.0684)) < 0.001 +def test_from_ratios() -> None: + t = subsequence.tuning.Tuning.from_ratios( + [9 / 8, 5 / 4, 4 / 3, 3 / 2, 5 / 3, 15 / 8, 2.0] + ) + assert t.size == 7 + assert abs(t.cents[3] - 1200.0 * math.log2(3 / 2)) < 0.001 # 701.955 cents -def test_just_perfect_fifth () -> None: - """Just perfect fifth is 701.955 cents; 12-TET is 700 cents. - Scale has 7 degrees (indices 0-6 for cents list). - MIDI 60 = degree 0, MIDI 67 = degree 0 of next octave. - MIDI 64 = steps_from_root 4, degree = 4 % 7 = 4, octave = 0. - cents[4] = 3/2 = 701.955 cents (the perfect fifth). - """ - t = subsequence.tuning.Tuning.from_ratios([9 / 8, 5 / 4, 4 / 3, 3 / 2, 5 / 3, 15 / 8, 2.0]) - # MIDI 64: steps_from_root = 4, degree = 4, octave = 0 - # total_cents = 0 + cents[3] = 701.955 cents - # continuous = 60 + 701.955/100 = 67.01955 → nearest = 67 - nearest, bend = t.pitch_bend_for_note(64, reference_note=60, bend_range=2.0) - assert nearest == 67 - assert abs(bend - 0.00977) < 0.001 +def test_equal_12() -> None: + t = subsequence.tuning.Tuning.equal(12) + assert t.size == 12 + for i, c in enumerate(t.cents): + assert abs(c - (i + 1) * 100.0) < 1e-9 -def test_bend_clamp () -> None: - """Offsets wider than the wheel range clamp to the ±1.0 normalised limit. +def test_equal_19() -> None: + t = subsequence.tuning.Tuning.equal(19) + assert t.size == 19 + step = 1200.0 / 19 + assert abs(t.cents[0] - step) < 1e-9 + assert abs(t.cents[-1] - 1200.0) < 1e-9 - Degree 1 sits at 50 cents, so MIDI 61 lands exactly between 12-TET notes: - nearest = 60 (banker's rounding), offset = +0.5 semitones. With - bend_range=0.25 the raw bend is 0.5 / 0.25 = +2.0 → clamped to +1.0. - Degree 2 at 150 cents mirrors this on the flat side: nearest = 62, - offset = -0.5, raw bend -2.0 → clamped to -1.0. - """ - t = subsequence.tuning.Tuning.from_cents([50.0, 150.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0, 1100.0, 1200.0]) +def test_period_cents() -> None: + t = subsequence.tuning.Tuning.equal(12) + assert abs(t.period_cents - 1200.0) < 1e-9 - nearest_sharp, bend_sharp = t.pitch_bend_for_note(61, reference_note=60, bend_range=0.25) - assert nearest_sharp == 60 - assert bend_sharp == 1.0 - nearest_flat, bend_flat = t.pitch_bend_for_note(62, reference_note=60, bend_range=0.25) - assert nearest_flat == 62 - assert bend_flat == -1.0 +# ── pitch_bend_for_note() ───────────────────────────────────────────────────── -def test_reference_note_shifts_root () -> None: - t = subsequence.tuning.Tuning.equal(12) - # With reference_note=69 (A4), MIDI 69 should map to itself with 0 bend - nearest, bend = t.pitch_bend_for_note(69, reference_note=69, bend_range=2.0) - assert nearest == 69 - assert abs(bend) < 1e-9 +def test_12tet_tuning_zero_bend() -> None: + """Standard 12-TET produces (same_note, 0.0) for every note.""" + t = subsequence.tuning.Tuning.equal(12) + for midi in range(21, 109): + nearest, bend = t.pitch_bend_for_note(midi, reference_note=60) + assert nearest == midi, f"note {midi}: nearest={nearest}" + assert abs(bend) < 1e-9, f"note {midi}: bend={bend}" + + +def test_meantone_major_third() -> None: + """Quarter-comma meantone major third is 386.31 cents; 12-TET is 400 cents. + + For MIDI 64 (E4, degree 4 from C4=60), the tuning is 386.31 cents above 60. + 12-TET equivalent: 400 cents = MIDI 64. Offset = -13.69 cents = -0.1369 semitones. + With bend_range=2: bend ≈ -0.0684. + """ + t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) + nearest, bend = t.pitch_bend_for_note(64, reference_note=60, bend_range=2.0) + # Major third in meantone is 386.31 cents → continuous note = 60 + 3.8631 = 63.8631 + # Nearest = 64, bend = (63.8631 - 64) / 2.0 = -0.0684 + assert nearest == 64 + assert abs(bend - (-0.0684)) < 0.001 + + +def test_just_perfect_fifth() -> None: + """Just perfect fifth is 701.955 cents; 12-TET is 700 cents. + + Scale has 7 degrees (indices 0-6 for cents list). + MIDI 60 = degree 0, MIDI 67 = degree 0 of next octave. + MIDI 64 = steps_from_root 4, degree = 4 % 7 = 4, octave = 0. + cents[4] = 3/2 = 701.955 cents (the perfect fifth). + """ + t = subsequence.tuning.Tuning.from_ratios( + [9 / 8, 5 / 4, 4 / 3, 3 / 2, 5 / 3, 15 / 8, 2.0] + ) + # MIDI 64: steps_from_root = 4, degree = 4, octave = 0 + # total_cents = 0 + cents[3] = 701.955 cents + # continuous = 60 + 701.955/100 = 67.01955 → nearest = 67 + nearest, bend = t.pitch_bend_for_note(64, reference_note=60, bend_range=2.0) + assert nearest == 67 + assert abs(bend - 0.00977) < 0.001 + + +def test_bend_clamp() -> None: + """Offsets wider than the wheel range clamp to the ±1.0 normalised limit. + + Degree 1 sits at 50 cents, so MIDI 61 lands exactly between 12-TET notes: + nearest = 60 (banker's rounding), offset = +0.5 semitones. With + bend_range=0.25 the raw bend is 0.5 / 0.25 = +2.0 → clamped to +1.0. + Degree 2 at 150 cents mirrors this on the flat side: nearest = 62, + offset = -0.5, raw bend -2.0 → clamped to -1.0. + """ + + t = subsequence.tuning.Tuning.from_cents( + [ + 50.0, + 150.0, + 300.0, + 400.0, + 500.0, + 600.0, + 700.0, + 800.0, + 900.0, + 1000.0, + 1100.0, + 1200.0, + ] + ) + + nearest_sharp, bend_sharp = t.pitch_bend_for_note( + 61, reference_note=60, bend_range=0.25 + ) + assert nearest_sharp == 60 + assert bend_sharp == 1.0 + + nearest_flat, bend_flat = t.pitch_bend_for_note( + 62, reference_note=60, bend_range=0.25 + ) + assert nearest_flat == 62 + assert bend_flat == -1.0 + + +def test_reference_note_shifts_root() -> None: + t = subsequence.tuning.Tuning.equal(12) + # With reference_note=69 (A4), MIDI 69 should map to itself with 0 bend + nearest, bend = t.pitch_bend_for_note(69, reference_note=69, bend_range=2.0) + assert nearest == 69 + assert abs(bend) < 1e-9 # ── ChannelAllocator ────────────────────────────────────────────────────────── -def test_allocator_single_channel () -> None: - alloc = subsequence.tuning.ChannelAllocator([3]) - # First note: channel 3, released at pulse 100 - assert alloc.allocate(0, 100) == 3 - # New note after release: channel 3 again - assert alloc.allocate(100, 50) == 3 - - -def test_allocator_round_robin () -> None: - alloc = subsequence.tuning.ChannelAllocator([1, 2, 3]) - # Three simultaneous notes get different channels - ch1 = alloc.allocate(0, 200) - ch2 = alloc.allocate(0, 200) - ch3 = alloc.allocate(0, 200) - assert sorted([ch1, ch2, ch3]) == [1, 2, 3] - - -def test_allocator_recycles_freed_channels () -> None: - alloc = subsequence.tuning.ChannelAllocator([1, 2]) - alloc.allocate(0, 100) # channel 1 busy until 100 - alloc.allocate(0, 100) # channel 2 busy until 100 - # At pulse 100, both are free - ch = alloc.allocate(100, 50) - assert ch in [1, 2] - - -def test_allocator_empty_raises () -> None: - with pytest.raises(ValueError): - subsequence.tuning.ChannelAllocator([]) - - -# ── subsequence.tuning.apply_tuning_to_pattern() ───────────────────────────────────────────────── - -def test_12tet_noop () -> None: - """12-TET tuning produces zero-value bend events but doesn't change pitches.""" - pattern, builder = _make_builder(channel=0) - builder.note(60, beat=0, velocity=80, duration=0.5) - builder.note(64, beat=1, velocity=80, duration=0.5) - - t = subsequence.tuning.Tuning.equal(12) - subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) - # Pitches unchanged - assert _pitches(pattern) == [60, 64] - # Bend events injected but all zero - bends = _bend_events(pattern) - assert len(bends) == 2 - for _, val, _ in bends: - assert val == 0 +def test_allocator_single_channel() -> None: + alloc = subsequence.tuning.ChannelAllocator([3]) + # First note: channel 3, released at pulse 100 + assert alloc.allocate(0, 100) == 3 + # New note after release: channel 3 again + assert alloc.allocate(100, 50) == 3 -def test_monophonic_pitch_and_bend_injected () -> None: - """Each note gets its pitch corrected and a pitchwheel event at onset.""" - pattern, builder = _make_builder(channel=0) - builder.note(60, beat=0, velocity=80, duration=0.5) - builder.note(61, beat=1, velocity=80, duration=0.5) - builder.note(62, beat=2, velocity=80, duration=0.5) +def test_allocator_round_robin() -> None: + alloc = subsequence.tuning.ChannelAllocator([1, 2, 3]) + # Three simultaneous notes get different channels + ch1 = alloc.allocate(0, 200) + ch2 = alloc.allocate(0, 200) + ch3 = alloc.allocate(0, 200) + assert sorted([ch1, ch2, ch3]) == [1, 2, 3] - t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) - subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) - # Same number of pitch-bend events as notes - bends = _bend_events(pattern) - assert len(bends) == 3 +def test_allocator_recycles_freed_channels() -> None: + alloc = subsequence.tuning.ChannelAllocator([1, 2]) + alloc.allocate(0, 100) # channel 1 busy until 100 + alloc.allocate(0, 100) # channel 2 busy until 100 + # At pulse 100, both are free + ch = alloc.allocate(100, 50) + assert ch in [1, 2] -def test_monophonic_all_on_same_channel () -> None: - """Without channel rotation, all notes stay on the pattern channel.""" - pattern, builder = _make_builder(channel=2) - for i in range(4): - builder.note(60 + i, beat=float(i), velocity=80, duration=0.5) +def test_allocator_empty_raises() -> None: + with pytest.raises(ValueError): + subsequence.tuning.ChannelAllocator([]) - t = subsequence.tuning.Tuning.equal(19) - subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0, channels=None) - assert all(ch == 2 for ch in _channels(pattern)) - - -def test_polyphonic_channel_rotation () -> None: - """Overlapping notes get different channels from the pool.""" - # Two notes at the same beat (pulse 0) — polyphonic - pattern = subsequence.pattern.Pattern(channel=0, length=4) - qn = subsequence.constants.MIDI_QUARTER_NOTE - dur = qn * 2 # 2 beat duration — notes overlap - - step = subsequence.pattern.Step() - step.notes.append(subsequence.pattern.Note(pitch=60, velocity=80, duration=dur, channel=0)) - step.notes.append(subsequence.pattern.Note(pitch=64, velocity=80, duration=dur, channel=0)) - pattern.steps[0] = step - - t = subsequence.tuning.Tuning.equal(12) - subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0, channels=[1, 2, 3]) - - channels_used = {n.channel for step in pattern.steps.values() for n in step.notes} - assert len(channels_used) == 2 # Two notes → two different channels - assert channels_used.issubset({1, 2, 3}) - - -def test_bend_events_have_correct_channel () -> None: - """Pitchwheel events for channel-rotated notes must carry the note's channel.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) - qn = subsequence.constants.MIDI_QUARTER_NOTE - dur = qn * 2 - - step = subsequence.pattern.Step() - step.notes.append(subsequence.pattern.Note(pitch=60, velocity=80, duration=dur, channel=0)) - step.notes.append(subsequence.pattern.Note(pitch=64, velocity=80, duration=dur, channel=0)) - pattern.steps[0] = step - - t = subsequence.tuning.Tuning.equal(12) - subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0, channels=[4, 5]) - - # Each note's channel is in the pool, and the corresponding bend event has the same channel - note_channels = {n.channel for step in pattern.steps.values() for n in step.notes} - bend_channels = {ch for _, _, ch in _bend_events(pattern)} - assert bend_channels == note_channels - - -def test_additive_composition_with_existing_bend () -> None: - """Tuning offsets are added to existing pitchwheel events from user bends.""" - pattern, builder = _make_builder(channel=0) - builder.note(60, beat=0, velocity=80, duration=1.0) - - # Manually inject a user pitchwheel event at pulse 0 with value 1000 - qn = subsequence.constants.MIDI_QUARTER_NOTE - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=0, message_type="pitchwheel", value=1000) - ) - - # Apply 12-TET (bend = 0 for note 60), so the existing bend should be unchanged - t = subsequence.tuning.Tuning.equal(12) - subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) - - # The original event at pulse 0 is shifted by tuning offset (0 for 12-TET) = 1000 - user_events = [ev for ev in pattern.cc_events if ev.value == 1000] - assert len(user_events) >= 1 - - -def test_bend_reset_becomes_tuning_offset () -> None: - """A bend-reset (value=0) event is replaced with the active tuning offset.""" - # Set up a pattern with meantone tuning on note 64 (major third, -14 cents from 12-TET) - pattern, builder = _make_builder(channel=0) - builder.note(64, beat=0, velocity=80, duration=2.0) - - # Inject a portamento-style reset at mid-note - qn = subsequence.constants.MIDI_QUARTER_NOTE - pattern.cc_events.append( - subsequence.pattern.CcEvent(pulse=qn, message_type="pitchwheel", value=0) - ) - - t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) - subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) - - # The value=0 reset should have been replaced with the tuning offset (non-zero for meantone) - reset_events = [ev for ev in pattern.cc_events if ev.pulse == qn and ev.message_type == "pitchwheel"] - assert len(reset_events) == 1 - # For meantone major third, offset is about -560 raw MIDI units (about -0.0684 normalized) - assert reset_events[0].value != 0 # not zero — replaced with tuning offset - - -def test_empty_pattern_no_error () -> None: - """apply_tuning_to_pattern on an empty pattern raises no error.""" - pattern = subsequence.pattern.Pattern(channel=0, length=4) - t = subsequence.tuning.Tuning.equal(12) - subsequence.tuning.apply_tuning_to_pattern(pattern, t) # should not raise - - -def test_drums_not_affected_by_global_tuning (patch_midi: None) -> None: +# ── subsequence.tuning.apply_tuning_to_pattern() ───────────────────────────────────────────────── - """The _tuning_exclude_drums guard protects drum patterns in auto-application. - Exercises the REAL guard path: two decorator patterns built through - ``Composition._build_pattern_from_pending`` under a global 19-TET tuning. - The drum-mapped pattern must come out untouched (no tuning bends, pitch - kept) while the melodic pattern is tuned; with ``exclude_drums=False`` - the same drum pattern IS tuned — proving the guard, not the channel, is - what protects drums. - """ +def test_12tet_noop() -> None: + """12-TET tuning produces zero-value bend events but doesn't change pitches.""" + pattern, builder = _make_builder(channel=0) + builder.note(60, beat=0, velocity=80, duration=0.5) + builder.note(64, beat=1, velocity=80, duration=0.5) + + t = subsequence.tuning.Tuning.equal(12) + subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) + + # Pitches unchanged + assert _pitches(pattern) == [60, 64] + # Bend events injected but all zero + bends = _bend_events(pattern) + assert len(bends) == 2 + for _, val, _ in bends: + assert val == 0 + + +def test_monophonic_pitch_and_bend_injected() -> None: + """Each note gets its pitch corrected and a pitchwheel event at onset.""" + pattern, builder = _make_builder(channel=0) + builder.note(60, beat=0, velocity=80, duration=0.5) + builder.note(61, beat=1, velocity=80, duration=0.5) + builder.note(62, beat=2, velocity=80, duration=0.5) + + t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) + subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) + + # Same number of pitch-bend events as notes + bends = _bend_events(pattern) + assert len(bends) == 3 + + +def test_monophonic_all_on_same_channel() -> None: + """Without channel rotation, all notes stay on the pattern channel.""" + pattern, builder = _make_builder(channel=2) + for i in range(4): + builder.note(60 + i, beat=float(i), velocity=80, duration=0.5) + + t = subsequence.tuning.Tuning.equal(19) + subsequence.tuning.apply_tuning_to_pattern( + pattern, t, bend_range=2.0, channels=None + ) + + assert all(ch == 2 for ch in _channels(pattern)) + + +def test_polyphonic_channel_rotation() -> None: + """Overlapping notes get different channels from the pool.""" + # Two notes at the same beat (pulse 0) — polyphonic + pattern = subsequence.pattern.Pattern(channel=0, length=4) + qn = subsequence.constants.MIDI_QUARTER_NOTE + dur = qn * 2 # 2 beat duration — notes overlap + + step = subsequence.pattern.Step() + step.notes.append( + subsequence.pattern.Note(pitch=60, velocity=80, duration=dur, channel=0) + ) + step.notes.append( + subsequence.pattern.Note(pitch=64, velocity=80, duration=dur, channel=0) + ) + pattern.steps[0] = step + + t = subsequence.tuning.Tuning.equal(12) + subsequence.tuning.apply_tuning_to_pattern( + pattern, t, bend_range=2.0, channels=[1, 2, 3] + ) + + channels_used = {n.channel for step in pattern.steps.values() for n in step.notes} + assert len(channels_used) == 2 # Two notes → two different channels + assert channels_used.issubset({1, 2, 3}) + + +def test_bend_events_have_correct_channel() -> None: + """Pitchwheel events for channel-rotated notes must carry the note's channel.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) + qn = subsequence.constants.MIDI_QUARTER_NOTE + dur = qn * 2 + + step = subsequence.pattern.Step() + step.notes.append( + subsequence.pattern.Note(pitch=60, velocity=80, duration=dur, channel=0) + ) + step.notes.append( + subsequence.pattern.Note(pitch=64, velocity=80, duration=dur, channel=0) + ) + pattern.steps[0] = step + + t = subsequence.tuning.Tuning.equal(12) + subsequence.tuning.apply_tuning_to_pattern( + pattern, t, bend_range=2.0, channels=[4, 5] + ) + + # Each note's channel is in the pool, and the corresponding bend event has the same channel + note_channels = {n.channel for step in pattern.steps.values() for n in step.notes} + bend_channels = {ch for _, _, ch in _bend_events(pattern)} + assert bend_channels == note_channels + + +def test_additive_composition_with_existing_bend() -> None: + """Tuning offsets are added to existing pitchwheel events from user bends.""" + pattern, builder = _make_builder(channel=0) + builder.note(60, beat=0, velocity=80, duration=1.0) + + # Manually inject a user pitchwheel event at pulse 0 with value 1000 + qn = subsequence.constants.MIDI_QUARTER_NOTE + pattern.cc_events.append( + subsequence.pattern.CcEvent(pulse=0, message_type="pitchwheel", value=1000) + ) + + # Apply 12-TET (bend = 0 for note 60), so the existing bend should be unchanged + t = subsequence.tuning.Tuning.equal(12) + subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) + + # The original event at pulse 0 is shifted by tuning offset (0 for 12-TET) = 1000 + user_events = [ev for ev in pattern.cc_events if ev.value == 1000] + assert len(user_events) >= 1 + + +def test_bend_reset_becomes_tuning_offset() -> None: + """A bend-reset (value=0) event is replaced with the active tuning offset.""" + # Set up a pattern with meantone tuning on note 64 (major third, -14 cents from 12-TET) + pattern, builder = _make_builder(channel=0) + builder.note(64, beat=0, velocity=80, duration=2.0) - import subsequence + # Inject a portamento-style reset at mid-note + qn = subsequence.constants.MIDI_QUARTER_NOTE + pattern.cc_events.append( + subsequence.pattern.CcEvent(pulse=qn, message_type="pitchwheel", value=0) + ) + + t = subsequence.tuning.Tuning.from_scl_string(SCL_MEANTONE) + subsequence.tuning.apply_tuning_to_pattern(pattern, t, bend_range=2.0) + + # The value=0 reset should have been replaced with the tuning offset (non-zero for meantone) + reset_events = [ + ev + for ev in pattern.cc_events + if ev.pulse == qn and ev.message_type == "pitchwheel" + ] + assert len(reset_events) == 1 + # For meantone major third, offset is about -560 raw MIDI units (about -0.0684 normalized) + assert reset_events[0].value != 0 # not zero — replaced with tuning offset + + +def test_empty_pattern_no_error() -> None: + """apply_tuning_to_pattern on an empty pattern raises no error.""" + pattern = subsequence.pattern.Pattern(channel=0, length=4) + t = subsequence.tuning.Tuning.equal(12) + subsequence.tuning.apply_tuning_to_pattern(pattern, t) # should not raise + + +def test_drums_not_affected_by_global_tuning(patch_midi: None) -> None: + """The _tuning_exclude_drums guard protects drum patterns in auto-application. + + Exercises the REAL guard path: two decorator patterns built through + ``Composition._build_pattern_from_pending`` under a global 19-TET tuning. + The drum-mapped pattern must come out untouched (no tuning bends, pitch + kept) while the melodic pattern is tuned; with ``exclude_drums=False`` + the same drum pattern IS tuned — proving the guard, not the channel, is + what protects drums. + """ - composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - composition.tuning(equal=19, bend_range=2.0) + import subsequence - @composition.pattern(channel=10, beats=4, drum_note_map={"kick": 36}) - def drums (p: typing.Any) -> None: - p.hit_steps("kick", [0]) + composition = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + composition.tuning(equal=19, bend_range=2.0) - @composition.pattern(channel=1, beats=4) - def bass (p: typing.Any) -> None: - p.note(50, beat=0) + @composition.pattern(channel=10, beats=4, drum_note_map={"kick": 36}) + def drums(p: typing.Any) -> None: + p.hit_steps("kick", [0]) - drum_pattern = composition._build_pattern_from_pending(composition._pending_patterns[0]) - bass_pattern = composition._build_pattern_from_pending(composition._pending_patterns[1]) + @composition.pattern(channel=1, beats=4) + def bass(p: typing.Any) -> None: + p.note(50, beat=0) + + drum_pattern = composition._build_pattern_from_pending( + composition._pending_patterns[0] + ) + bass_pattern = composition._build_pattern_from_pending( + composition._pending_patterns[1] + ) - # The drum pattern is untouched: no tuning bends, the GM pitch kept. - assert [e for e in drum_pattern.cc_events if e.message_type == "pitchwheel"] == [] - assert _pitches(drum_pattern) == [36] + # The drum pattern is untouched: no tuning bends, the GM pitch kept. + assert [e for e in drum_pattern.cc_events if e.message_type == "pitchwheel"] == [] + assert _pitches(drum_pattern) == [36] - # The melodic pattern was tuned (bends prove the auto-apply ran). - assert [e for e in bass_pattern.cc_events if e.message_type == "pitchwheel"] != [] + # The melodic pattern was tuned (bends prove the auto-apply ran). + assert [e for e in bass_pattern.cc_events if e.message_type == "pitchwheel"] != [] - # Control: with the guard off, the SAME drum pattern is tuned. - unguarded = subsequence.Composition(output_device="Dummy MIDI", bpm=120) - unguarded.tuning(equal=19, bend_range=2.0, exclude_drums=False) + # Control: with the guard off, the SAME drum pattern is tuned. + unguarded = subsequence.Composition(output_device="Dummy MIDI", bpm=120) + unguarded.tuning(equal=19, bend_range=2.0, exclude_drums=False) - @unguarded.pattern(channel=10, beats=4, drum_note_map={"kick": 36}) - def drums_unguarded (p: typing.Any) -> None: - p.hit_steps("kick", [0]) + @unguarded.pattern(channel=10, beats=4, drum_note_map={"kick": 36}) + def drums_unguarded(p: typing.Any) -> None: + p.hit_steps("kick", [0]) - tuned_drums = unguarded._build_pattern_from_pending(unguarded._pending_patterns[0]) + tuned_drums = unguarded._build_pattern_from_pending(unguarded._pending_patterns[0]) - assert [e for e in tuned_drums.cc_events if e.message_type == "pitchwheel"] != [] + assert [e for e in tuned_drums.cc_events if e.message_type == "pitchwheel"] != [] # ── PatternBuilder.apply_tuning() ───────────────────────────────────────────── -def test_builder_apply_tuning_method () -> None: - """PatternBuilder.apply_tuning() is a method chaining transform.""" - pattern, builder = _make_builder(channel=0) - builder.note(60, beat=0, velocity=80, duration=0.5) - builder.note(64, beat=1, velocity=80, duration=0.5) - t = subsequence.tuning.Tuning.equal(12) - result = builder.apply_tuning(t, bend_range=2.0) +def test_builder_apply_tuning_method() -> None: + """PatternBuilder.apply_tuning() is a method chaining transform.""" + pattern, builder = _make_builder(channel=0) + builder.note(60, beat=0, velocity=80, duration=0.5) + builder.note(64, beat=1, velocity=80, duration=0.5) + + t = subsequence.tuning.Tuning.equal(12) + result = builder.apply_tuning(t, bend_range=2.0) - assert result is builder # method chaining - bends = _bend_events(pattern) - assert len(bends) == 2 + assert result is builder # method chaining + bends = _bend_events(pattern) + assert len(bends) == 2 -def test_builder_apply_tuning_sets_flag () -> None: - """apply_tuning() sets _tuning_applied so auto-application is skipped.""" - pattern, builder = _make_builder(channel=0) - builder.note(60, beat=0, velocity=80, duration=0.5) - t = subsequence.tuning.Tuning.equal(12) - builder.apply_tuning(t) - assert builder._tuning_applied is True +def test_builder_apply_tuning_sets_flag() -> None: + """apply_tuning() sets _tuning_applied so auto-application is skipped.""" + pattern, builder = _make_builder(channel=0) + builder.note(60, beat=0, velocity=80, duration=0.5) + t = subsequence.tuning.Tuning.equal(12) + builder.apply_tuning(t) + assert builder._tuning_applied is True # ── Package-level export ────────────────────────────────────────────────────── -def test_tuning_exported_from_package () -> None: - """Tuning is accessible as subsequence.Tuning.""" +def test_tuning_exported_from_package() -> None: + """Tuning is accessible as subsequence.Tuning.""" + + import subsequence - import subsequence - assert hasattr(subsequence, "Tuning") - assert subsequence.Tuning is subsequence.tuning.Tuning + assert hasattr(subsequence, "Tuning") + assert subsequence.Tuning is subsequence.tuning.Tuning diff --git a/tests/test_turnaround_graph.py b/tests/test_turnaround_graph.py index 473d010..20dc804 100644 --- a/tests/test_turnaround_graph.py +++ b/tests/test_turnaround_graph.py @@ -4,76 +4,68 @@ import subsequence.chords -def test_turnaround_edges_present () -> None: +def test_turnaround_edges_present() -> None: + """ + Turnaround graph should include ii->V7->I in the chosen key. + """ - """ - Turnaround graph should include ii->V7->I in the chosen key. - """ + graph, tonic = subsequence.chord_graphs.turnaround_global.build_graph( + key_name="C", include_dominant_7th=True, minor_turnaround_weight=0.0 + ) - graph, tonic = subsequence.chord_graphs.turnaround_global.build_graph( - key_name = "C", - include_dominant_7th = True, - minor_turnaround_weight = 0.0 - ) + supertonic = subsequence.chords.Chord(root_pc=2, quality="minor") + dominant_7th = subsequence.chords.Chord(root_pc=7, quality="dominant_7th") - supertonic = subsequence.chords.Chord(root_pc=2, quality="minor") - dominant_7th = subsequence.chords.Chord(root_pc=7, quality="dominant_7th") + transitions = graph.get_transitions(supertonic) + assert any(chord == dominant_7th for chord, _ in transitions) - transitions = graph.get_transitions(supertonic) - assert any(chord == dominant_7th for chord, _ in transitions) + transitions_7th = graph.get_transitions(dominant_7th) + assert any(chord == tonic for chord, _ in transitions_7th) - transitions_7th = graph.get_transitions(dominant_7th) - assert any(chord == tonic for chord, _ in transitions_7th) +def test_minor_turnaround_weight_toggle() -> None: + """ + Minor turnarounds should appear only when weight is enabled. + """ -def test_minor_turnaround_weight_toggle () -> None: + graph_disabled, _ = subsequence.chord_graphs.turnaround_global.build_graph( + key_name="A", include_dominant_7th=True, minor_turnaround_weight=0.0 + ) - """ - Minor turnarounds should appear only when weight is enabled. - """ + supertonic_half_dim = subsequence.chords.Chord( + root_pc=2, quality="half_diminished_7th" + ) + transitions_disabled = graph_disabled.get_transitions(supertonic_half_dim) + assert transitions_disabled == [] - graph_disabled, _ = subsequence.chord_graphs.turnaround_global.build_graph( - key_name = "A", - include_dominant_7th = True, - minor_turnaround_weight = 0.0 - ) + graph_enabled, _ = subsequence.chord_graphs.turnaround_global.build_graph( + key_name="A", include_dominant_7th=True, minor_turnaround_weight=0.5 + ) - supertonic_half_dim = subsequence.chords.Chord(root_pc=2, quality="half_diminished_7th") - transitions_disabled = graph_disabled.get_transitions(supertonic_half_dim) - assert transitions_disabled == [] + transitions_enabled = graph_enabled.get_transitions(supertonic_half_dim) + assert transitions_enabled != [] - graph_enabled, _ = subsequence.chord_graphs.turnaround_global.build_graph( - key_name = "A", - include_dominant_7th = True, - minor_turnaround_weight = 0.5 - ) - transitions_enabled = graph_enabled.get_transitions(supertonic_half_dim) - assert transitions_enabled != [] +def test_minor_turnaround_supertonic_is_reachable() -> None: + """The iio7 chords must have incoming edges, or the minor turnaround can never start. -def test_minor_turnaround_supertonic_is_reachable () -> None: + Regression: entry edges (i -> iio7 and I -> iio7) were missing, leaving all + twelve half-diminished nodes orphaned - present in the graph but unreachable + from any other chord. + """ - """The iio7 chords must have incoming edges, or the minor turnaround can never start. + graph, _ = subsequence.chord_graphs.turnaround_global.build_graph( + key_name="C", include_dominant_7th=True, minor_turnaround_weight=0.5 + ) - Regression: entry edges (i -> iio7 and I -> iio7) were missing, leaving all - twelve half-diminished nodes orphaned - present in the graph but unreachable - from any other chord. - """ + incoming: dict = {} - graph, _ = subsequence.chord_graphs.turnaround_global.build_graph( - key_name = "C", - include_dominant_7th = True, - minor_turnaround_weight = 0.5 - ) + for source in list(graph._edges.keys()): + for target, weight in graph.get_transitions(source): + incoming[target] = incoming.get(target, 0) + 1 - incoming: dict = {} + all_nodes = set(graph._edges.keys()) | set(incoming.keys()) + half_dim = [n for n in all_nodes if n.quality == "half_diminished_7th"] - for source in list(graph._edges.keys()): - for target, weight in graph.get_transitions(source): - incoming[target] = incoming.get(target, 0) + 1 - - all_nodes = set(graph._edges.keys()) | set(incoming.keys()) - half_dim = [n for n in all_nodes if n.quality == "half_diminished_7th"] - - assert len(half_dim) == 12 - assert all(incoming.get(n, 0) > 0 for n in half_dim), "orphaned iio7 nodes" + assert len(half_dim) == 12 + assert all(incoming.get(n, 0) > 0 for n in half_dim), "orphaned iio7 nodes" diff --git a/tests/test_unregister.py b/tests/test_unregister.py index e722972..2e6f8e0 100644 --- a/tests/test_unregister.py +++ b/tests/test_unregister.py @@ -20,202 +20,206 @@ import tests.conftest as conftest -def _running_pattern_stub ( - channel: int = 0, - device: int = 0, - mirrors: typing.Optional[list] = None, +def _running_pattern_stub( + channel: int = 0, + device: int = 0, + mirrors: typing.Optional[list] = None, ) -> typing.Any: + """Minimal stand-in for a running pattern with the fields unregister() touches.""" - """Minimal stand-in for a running pattern with the fields unregister() touches.""" - - pat = subsequence.pattern.Pattern(channel=channel, length=4, device=device, mirrors=mirrors) - pat._muted = False - pat._tweaks = {} - pat._cycle_count = 0 - return pat + pat = subsequence.pattern.Pattern( + channel=channel, length=4, device=device, mirrors=mirrors + ) + pat._muted = False + pat._tweaks = {} + pat._cycle_count = 0 + return pat # ── Flag and dict mutation ────────────────────────────────────────────────── -def test_unregister_sets_removed_flag (patch_midi: None) -> None: - - """unregister() sets pattern._removed = True.""" - - composition = subsequence.Composition(bpm=120) - pattern = _running_pattern_stub() - composition._running_patterns["drums"] = pattern - - composition.unregister("drums") - - assert pattern._removed is True +def test_unregister_sets_removed_flag(patch_midi: None) -> None: + """unregister() sets pattern._removed = True.""" + composition = subsequence.Composition(bpm=120) + pattern = _running_pattern_stub() + composition._running_patterns["drums"] = pattern -def test_unregister_removes_from_running_patterns (patch_midi: None) -> None: + composition.unregister("drums") - """After unregister(), the name is gone from _running_patterns.""" + assert pattern._removed is True - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub() - composition.unregister("drums") +def test_unregister_removes_from_running_patterns(patch_midi: None) -> None: + """After unregister(), the name is gone from _running_patterns.""" - assert "drums" not in composition._running_patterns + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub() + composition.unregister("drums") -def test_unregister_unknown_pattern_is_silent (patch_midi: None, caplog: pytest.LogCaptureFixture) -> None: + assert "drums" not in composition._running_patterns - """unregister() on a non-existent name is a debug-log no-op.""" - composition = subsequence.Composition(bpm=120) +def test_unregister_unknown_pattern_is_silent( + patch_midi: None, caplog: pytest.LogCaptureFixture +) -> None: + """unregister() on a non-existent name is a debug-log no-op.""" - with caplog.at_level(logging.DEBUG): - composition.unregister("not_running") + composition = subsequence.Composition(bpm=120) - assert any("no-op" in r.message for r in caplog.records) + with caplog.at_level(logging.DEBUG): + composition.unregister("not_running") + assert any("no-op" in r.message for r in caplog.records) -def test_unregister_idempotent (patch_midi: None) -> None: - """Calling unregister() twice on the same name doesn't crash.""" +def test_unregister_idempotent(patch_midi: None) -> None: + """Calling unregister() twice on the same name doesn't crash.""" - composition = subsequence.Composition(bpm=120) - composition._running_patterns["drums"] = _running_pattern_stub() + composition = subsequence.Composition(bpm=120) + composition._running_patterns["drums"] = _running_pattern_stub() - composition.unregister("drums") - composition.unregister("drums") # already gone — second call is no-op + composition.unregister("drums") + composition.unregister("drums") # already gone — second call is no-op - assert "drums" not in composition._running_patterns + assert "drums" not in composition._running_patterns # ── Reschedule loop skip ──────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_reschedule_loop_skips_removed_patterns (patch_midi: None) -> None: +async def test_reschedule_loop_skips_removed_patterns(patch_midi: None) -> None: + """A ScheduledPattern with pattern._removed=True is popped but never re-added. - """A ScheduledPattern with pattern._removed=True is popped but never re-added. + Direct test against the sequencer: schedule a pattern, set the flag, + pump the reschedule loop, verify the queue is empty after. + """ - Direct test against the sequencer: schedule a pattern, set the flag, - pump the reschedule loop, verify the queue is empty after. - """ + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) + # Schedule the pattern so it lives in reschedule_queue. + await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) + assert len(sequencer.reschedule_queue) == 1 - # Schedule the pattern so it lives in reschedule_queue. - await sequencer.schedule_pattern_repeating(pattern, start_pulse=0) - assert len(sequencer.reschedule_queue) == 1 + # Flag it for removal. + pattern._removed = True - # Flag it for removal. - pattern._removed = True + # Pump _maybe_reschedule_patterns at a pulse past the next_reschedule_pulse + # to force the pop. The reschedule loop runs inside this method. + await sequencer._maybe_reschedule_patterns(pulse=1000) - # Pump _maybe_reschedule_patterns at a pulse past the next_reschedule_pulse - # to force the pop. The reschedule loop runs inside this method. - await sequencer._maybe_reschedule_patterns(pulse=1000) - - # Queue is empty: the pattern was popped and not re-added. - assert sequencer.reschedule_queue == [] + # Queue is empty: the pattern was popped and not re-added. + assert sequencer.reschedule_queue == [] # ── Note-off targeting ────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_unregister_stops_active_notes_on_primary (patch_midi: None) -> None: - - """unregister() sends note_off for the pattern's notes on its primary destination.""" +async def test_unregister_stops_active_notes_on_primary(patch_midi: None) -> None: + """unregister() sends note_off for the pattern's notes on its primary destination.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - # Inject a SpyMidiOut so we can inspect outgoing messages. - spy = conftest.SpyMidiOut() - sequencer.midi_out = spy + # Inject a SpyMidiOut so we can inspect outgoing messages. + spy = conftest.SpyMidiOut() + sequencer.midi_out = spy - pattern = subsequence.pattern.Pattern(channel=5, length=4, device=0) + pattern = subsequence.pattern.Pattern(channel=5, length=4, device=0) - # Simulate a sounding note: (device=0, channel=5, note=60). - sequencer.active_notes.add((0, 5, 60)) + # Simulate a sounding note: (device=0, channel=5, note=60). + sequencer.active_notes.add((0, 5, 60)) - await sequencer._stop_pattern_notes(pattern) + await sequencer._stop_pattern_notes(pattern) - # Spy received exactly one note_off on (channel=5, note=60). - note_offs = [m for m in spy.sent if m.type == 'note_off'] - assert len(note_offs) == 1 - assert note_offs[0].channel == 5 - assert note_offs[0].note == 60 + # Spy received exactly one note_off on (channel=5, note=60). + note_offs = [m for m in spy.sent if m.type == "note_off"] + assert len(note_offs) == 1 + assert note_offs[0].channel == 5 + assert note_offs[0].note == 60 - # Active note cleared. - assert (0, 5, 60) not in sequencer.active_notes + # Active note cleared. + assert (0, 5, 60) not in sequencer.active_notes @pytest.mark.asyncio -async def test_unregister_stops_active_notes_on_mirrors (patch_midi: None) -> None: - - """Pattern with mirrors: note_offs go to every (device, channel) destination.""" +async def test_unregister_stops_active_notes_on_mirrors(patch_midi: None) -> None: + """Pattern with mirrors: note_offs go to every (device, channel) destination.""" - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) - spy0 = conftest.SpyMidiOut() - spy1 = conftest.SpyMidiOut() - sequencer.midi_out = spy0 - sequencer._output_devices.add("Secondary", spy1) + spy0 = conftest.SpyMidiOut() + spy1 = conftest.SpyMidiOut() + sequencer.midi_out = spy0 + sequencer._output_devices.add("Secondary", spy1) - # Pattern primary (device=0, channel=2), mirror (device=1, channel=7). - pattern = subsequence.pattern.Pattern(channel=2, length=4, device=0, mirrors=[(1, 7)]) + # Pattern primary (device=0, channel=2), mirror (device=1, channel=7). + pattern = subsequence.pattern.Pattern( + channel=2, length=4, device=0, mirrors=[(1, 7)] + ) - # Sounding notes on each destination. - sequencer.active_notes.add((0, 2, 60)) - sequencer.active_notes.add((1, 7, 60)) - # Unrelated note on a different channel — should NOT be touched. - sequencer.active_notes.add((0, 9, 36)) + # Sounding notes on each destination. + sequencer.active_notes.add((0, 2, 60)) + sequencer.active_notes.add((1, 7, 60)) + # Unrelated note on a different channel — should NOT be touched. + sequencer.active_notes.add((0, 9, 36)) - await sequencer._stop_pattern_notes(pattern) + await sequencer._stop_pattern_notes(pattern) - # Each spy got exactly one note_off matching its destination. - primary_offs = [m for m in spy0.sent if m.type == 'note_off'] - mirror_offs = [m for m in spy1.sent if m.type == 'note_off'] + # Each spy got exactly one note_off matching its destination. + primary_offs = [m for m in spy0.sent if m.type == "note_off"] + mirror_offs = [m for m in spy1.sent if m.type == "note_off"] - assert len(primary_offs) == 1 - assert primary_offs[0].channel == 2 and primary_offs[0].note == 60 + assert len(primary_offs) == 1 + assert primary_offs[0].channel == 2 and primary_offs[0].note == 60 - assert len(mirror_offs) == 1 - assert mirror_offs[0].channel == 7 and mirror_offs[0].note == 60 + assert len(mirror_offs) == 1 + assert mirror_offs[0].channel == 7 and mirror_offs[0].note == 60 - # The unrelated note survives. - assert (0, 9, 36) in sequencer.active_notes - assert (0, 2, 60) not in sequencer.active_notes - assert (1, 7, 60) not in sequencer.active_notes + # The unrelated note survives. + assert (0, 9, 36) in sequencer.active_notes + assert (0, 2, 60) not in sequencer.active_notes + assert (1, 7, 60) not in sequencer.active_notes @pytest.mark.asyncio -async def test_unregister_with_no_active_notes_does_not_crash (patch_midi: None) -> None: +async def test_unregister_with_no_active_notes_does_not_crash(patch_midi: None) -> None: + """Calling _stop_pattern_notes on a pattern with no sounding notes is a no-op.""" - """Calling _stop_pattern_notes on a pattern with no sounding notes is a no-op.""" + sequencer = subsequence.sequencer.Sequencer( + output_device_name="Dummy MIDI", initial_bpm=120 + ) + pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) - sequencer = subsequence.sequencer.Sequencer(output_device_name="Dummy MIDI", initial_bpm=120) - pattern = subsequence.pattern.Pattern(channel=0, length=4, device=0) + await sequencer._stop_pattern_notes(pattern) - await sequencer._stop_pattern_notes(pattern) - - assert sequencer.active_notes == set() + assert sequencer.active_notes == set() # ── live_info() excludes unregistered patterns ───────────────────────────── -def test_live_info_excludes_unregistered_pattern (patch_midi: None) -> None: - - """composition.live_info() doesn't list patterns that have been unregistered.""" +def test_live_info_excludes_unregistered_pattern(patch_midi: None) -> None: + """composition.live_info() doesn't list patterns that have been unregistered.""" - composition = subsequence.Composition(bpm=120) - composition._running_patterns["keeper"] = _running_pattern_stub(channel=0) - composition._running_patterns["goner"] = _running_pattern_stub(channel=1) + composition = subsequence.Composition(bpm=120) + composition._running_patterns["keeper"] = _running_pattern_stub(channel=0) + composition._running_patterns["goner"] = _running_pattern_stub(channel=1) - composition.unregister("goner") + composition.unregister("goner") - info = composition.live_info() - pattern_names = [p["name"] for p in info["patterns"]] + info = composition.live_info() + pattern_names = [p["name"] for p in info["patterns"]] - assert "keeper" in pattern_names - assert "goner" not in pattern_names + assert "keeper" in pattern_names + assert "goner" not in pattern_names diff --git a/tests/test_velocity_tuple.py b/tests/test_velocity_tuple.py index 64239b8..d2d51ac 100644 --- a/tests/test_velocity_tuple.py +++ b/tests/test_velocity_tuple.py @@ -14,275 +14,263 @@ import subsequence.pattern_builder -def _make_builder (channel: int = 0, length: float = 4, drum_note_map: typing.Optional[dict] = None, default_grid: typing.Optional[int] = None, seed: int = 0) -> tuple: - - """Mirror of the fixture in tests/test_pattern_builder.py.""" - - if default_grid is None: - default_grid = round(length / subsequence.constants.durations.SIXTEENTH) - pat = subsequence.pattern.Pattern(channel=channel, length=length) - builder = subsequence.pattern_builder.PatternBuilder( - pattern=pat, - cycle=0, - drum_note_map=drum_note_map, - default_grid=default_grid, - rng=random.Random(seed), - ) - return pat, builder +def _make_builder( + channel: int = 0, + length: float = 4, + drum_note_map: typing.Optional[dict] = None, + default_grid: typing.Optional[int] = None, + seed: int = 0, +) -> tuple: + """Mirror of the fixture in tests/test_pattern_builder.py.""" + + if default_grid is None: + default_grid = round(length / subsequence.constants.durations.SIXTEENTH) + pat = subsequence.pattern.Pattern(channel=channel, length=length) + builder = subsequence.pattern_builder.PatternBuilder( + pattern=pat, + cycle=0, + drum_note_map=drum_note_map, + default_grid=default_grid, + rng=random.Random(seed), + ) + return pat, builder # --- _resolve_velocity helper itself --- -def test_resolve_velocity_int_returns_unchanged () -> None: - - _, builder = _make_builder() - assert builder._resolve_velocity(90) == 90 - - -def test_resolve_velocity_float_coerces_to_int () -> None: - - _, builder = _make_builder() - assert builder._resolve_velocity(90.7) == 90 - - -def test_resolve_velocity_tuple_draws_from_range () -> None: +def test_resolve_velocity_int_returns_unchanged() -> None: + _, builder = _make_builder() + assert builder._resolve_velocity(90) == 90 - _, builder = _make_builder(seed=42) - for _ in range(20): - v = builder._resolve_velocity((60, 90)) - assert 60 <= v <= 90 +def test_resolve_velocity_float_coerces_to_int() -> None: + _, builder = _make_builder() + assert builder._resolve_velocity(90.7) == 90 -def test_resolve_velocity_rejects_reversed_range () -> None: - """A swapped (high, low) tuple fails at the call site, not deep in randint.""" +def test_resolve_velocity_tuple_draws_from_range() -> None: + _, builder = _make_builder(seed=42) + for _ in range(20): + v = builder._resolve_velocity((60, 90)) + assert 60 <= v <= 90 - _, builder = _make_builder() - with pytest.raises(ValueError, match="low <= high"): - builder._resolve_velocity((110, 80)) +def test_resolve_velocity_rejects_reversed_range() -> None: + """A swapped (high, low) tuple fails at the call site, not deep in randint.""" + _, builder = _make_builder() -def test_resolve_velocity_tuple_consumes_exactly_one_draw () -> None: + with pytest.raises(ValueError, match="low <= high"): + builder._resolve_velocity((110, 80)) - """A (low, high) range advances the RNG by exactly one ``randint`` draw. - This is the load-bearing reproducibility invariant — if it ever drew twice (or - differently), every seeded composition downstream would shift bit-for-bit. A - range-membership check alone would not catch a two-draw regression. - """ +def test_resolve_velocity_tuple_consumes_exactly_one_draw() -> None: + """A (low, high) range advances the RNG by exactly one ``randint`` draw. - _, builder = _make_builder() + This is the load-bearing reproducibility invariant — if it ever drew twice (or + differently), every seeded composition downstream would shift bit-for-bit. A + range-membership check alone would not catch a two-draw regression. + """ - rng_resolve = random.Random(12345) - rng_direct = random.Random(12345) + _, builder = _make_builder() - result = builder._resolve_velocity((40, 80), rng_resolve) - expected = rng_direct.randint(40, 80) + rng_resolve = random.Random(12345) + rng_direct = random.Random(12345) - assert result == expected # same value as a single randint - assert rng_resolve.getstate() == rng_direct.getstate() # …and exactly one draw of it + result = builder._resolve_velocity((40, 80), rng_resolve) + expected = rng_direct.randint(40, 80) + assert result == expected # same value as a single randint + assert ( + rng_resolve.getstate() == rng_direct.getstate() + ) # …and exactly one draw of it -def test_resolve_velocity_tuple_uses_explicit_rng () -> None: - _, builder = _make_builder() - rng_a = random.Random(123) - rng_b = random.Random(123) - a = builder._resolve_velocity((40, 100), rng=rng_a) - b = builder._resolve_velocity((40, 100), rng=rng_b) - assert a == b # same seed, same draw +def test_resolve_velocity_tuple_uses_explicit_rng() -> None: + _, builder = _make_builder() + rng_a = random.Random(123) + rng_b = random.Random(123) + a = builder._resolve_velocity((40, 100), rng=rng_a) + b = builder._resolve_velocity((40, 100), rng=rng_b) + assert a == b # same seed, same draw -def test_resolve_velocity_wrong_tuple_length_raises () -> None: +def test_resolve_velocity_wrong_tuple_length_raises() -> None: + _, builder = _make_builder() + with pytest.raises(ValueError, match="velocity tuple must be"): + builder._resolve_velocity((60, 70, 80)) - _, builder = _make_builder() - with pytest.raises(ValueError, match="velocity tuple must be"): - builder._resolve_velocity((60, 70, 80)) +def test_resolve_velocity_string_raises() -> None: + _, builder = _make_builder() + with pytest.raises(TypeError, match="velocity must be int or"): + builder._resolve_velocity("loud") -def test_resolve_velocity_string_raises () -> None: - _, builder = _make_builder() - with pytest.raises(TypeError, match="velocity must be int or"): - builder._resolve_velocity("loud") - - -def test_resolve_velocity_bool_raises () -> None: - - """bool is a subclass of int; reject it explicitly so True/False don't sneak through.""" - _, builder = _make_builder() - with pytest.raises(TypeError, match="bool"): - builder._resolve_velocity(True) +def test_resolve_velocity_bool_raises() -> None: + """bool is a subclass of int; reject it explicitly so True/False don't sneak through.""" + _, builder = _make_builder() + with pytest.raises(TypeError, match="bool"): + builder._resolve_velocity(True) # --- Note-placement methods accept tuple velocity without crashing --- -def test_note_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.note(pitch=60, beat=0.0, velocity=(50, 70)) - notes = next(iter(pattern.steps.values())).notes - assert 50 <= notes[0].velocity <= 70 - - -def test_hit_steps_accepts_tuple_velocity_per_step () -> None: - - """Each step should get a fresh random draw.""" - pattern, builder = _make_builder(seed=1) - builder.hit_steps(pitch=60, steps=list(range(16)), velocity=(40, 90)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - # 16 steps; with a healthy range we expect at least a few distinct values. - assert len({v for v in velocities}) >= 4 - assert all(40 <= v <= 90 for v in velocities) - - -def test_hit_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.hit(pitch=60, beats=[0.0, 1.0, 2.0, 3.0], velocity=(60, 100)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(60 <= v <= 100 for v in velocities) - - -def test_repeat_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.repeat(pitch=60, spacing=0.5, velocity=(70, 95)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(70 <= v <= 95 for v in velocities) - - -def test_chord_accepts_tuple_velocity_per_voice () -> None: - - """Each chord voice should get an independent random velocity.""" - pattern, builder = _make_builder(seed=1) - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.chord(chord, root=60, velocity=(50, 100), count=5) - # All chord notes at pulse 0 - notes = pattern.steps[0].notes - velocities = [n.velocity for n in notes] - assert all(50 <= v <= 100 for v in velocities) - # 5 voices; with a 50-unit range we'd expect multiple distinct values. - assert len(set(velocities)) >= 2 - - -def test_strum_accepts_tuple_velocity_per_voice () -> None: - - pattern, builder = _make_builder(seed=1) - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.strum(chord, root=60, velocity=(60, 100), spacing=0.1, count=4) - all_notes = [n for step in pattern.steps.values() for n in step.notes] - velocities = [n.velocity for n in all_notes] - assert all(60 <= v <= 100 for v in velocities) - - -def test_sequence_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.sequence(steps=[0, 4, 8, 12], pitches=60, velocities=(55, 85)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(55 <= v <= 85 for v in velocities) - - -def test_sequence_list_velocity_still_works () -> None: - - """Regression: list of velocities still works after the tuple branch.""" - pattern, builder = _make_builder() - builder.sequence(steps=[0, 4, 8, 12], pitches=60, velocities=[60, 70, 80, 90]) - assert [step.notes[0].velocity for step in pattern.steps.values()] == [60, 70, 80, 90] - - -def test_arpeggio_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1, length=4) - builder.arpeggio(notes=[60, 64, 67], spacing=0.5, velocity=(70, 100)) - all_notes = [n for step in pattern.steps.values() for n in step.notes] - velocities = [n.velocity for n in all_notes] - assert all(70 <= v <= 100 for v in velocities) - - -def test_arpeggio_chord_form_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1, length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.arpeggio(chord, root=60, spacing=0.5, velocity=(70, 100)) - all_notes = [n for step in pattern.steps.values() for n in step.notes] - velocities = [n.velocity for n in all_notes] - assert all(70 <= v <= 100 for v in velocities) - assert len(set(velocities)) > 1 - - -def test_euclidean_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.euclidean(pitch=60, pulses=4, velocity=(50, 80)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(50 <= v <= 80 for v in velocities) - - -def test_bresenham_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.bresenham(pitch=60, pulses=4, velocity=(40, 70)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(40 <= v <= 70 for v in velocities) - - -def test_cellular_1d_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.cellular_1d(pitch=60, rule=30, velocity=(45, 75)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(45 <= v <= 75 for v in velocities) - - -def test_markov_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.markov( - transitions={"a": [("b", 1)], "b": [("a", 1)]}, - pitch_map={"a": 60, "b": 64}, - velocity=(50, 90), - spacing=0.5, - ) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(50 <= v <= 90 for v in velocities) - - -def test_thue_morse_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1) - builder.thue_morse(pitch=60, velocity=(40, 80)) - velocities = [step.notes[0].velocity for step in pattern.steps.values()] - assert all(40 <= v <= 80 for v in velocities) - - -def test_broken_chord_accepts_tuple_velocity () -> None: - - pattern, builder = _make_builder(seed=1, length=4) - chord = subsequence.chords.Chord(root_pc=0, quality="major") - builder.broken_chord(chord, root=60, order=[0, 1, 2, 0], spacing=0.25, velocity=(60, 95)) - all_notes = [n for step in pattern.steps.values() for n in step.notes] - velocities = [n.velocity for n in all_notes] - assert all(60 <= v <= 95 for v in velocities) +def test_note_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.note(pitch=60, beat=0.0, velocity=(50, 70)) + notes = next(iter(pattern.steps.values())).notes + assert 50 <= notes[0].velocity <= 70 + + +def test_hit_steps_accepts_tuple_velocity_per_step() -> None: + """Each step should get a fresh random draw.""" + pattern, builder = _make_builder(seed=1) + builder.hit_steps(pitch=60, steps=list(range(16)), velocity=(40, 90)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + # 16 steps; with a healthy range we expect at least a few distinct values. + assert len({v for v in velocities}) >= 4 + assert all(40 <= v <= 90 for v in velocities) + + +def test_hit_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.hit(pitch=60, beats=[0.0, 1.0, 2.0, 3.0], velocity=(60, 100)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(60 <= v <= 100 for v in velocities) + + +def test_repeat_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.repeat(pitch=60, spacing=0.5, velocity=(70, 95)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(70 <= v <= 95 for v in velocities) + + +def test_chord_accepts_tuple_velocity_per_voice() -> None: + """Each chord voice should get an independent random velocity.""" + pattern, builder = _make_builder(seed=1) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.chord(chord, root=60, velocity=(50, 100), count=5) + # All chord notes at pulse 0 + notes = pattern.steps[0].notes + velocities = [n.velocity for n in notes] + assert all(50 <= v <= 100 for v in velocities) + # 5 voices; with a 50-unit range we'd expect multiple distinct values. + assert len(set(velocities)) >= 2 + + +def test_strum_accepts_tuple_velocity_per_voice() -> None: + pattern, builder = _make_builder(seed=1) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.strum(chord, root=60, velocity=(60, 100), spacing=0.1, count=4) + all_notes = [n for step in pattern.steps.values() for n in step.notes] + velocities = [n.velocity for n in all_notes] + assert all(60 <= v <= 100 for v in velocities) + + +def test_sequence_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.sequence(steps=[0, 4, 8, 12], pitches=60, velocities=(55, 85)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(55 <= v <= 85 for v in velocities) + + +def test_sequence_list_velocity_still_works() -> None: + """Regression: list of velocities still works after the tuple branch.""" + pattern, builder = _make_builder() + builder.sequence(steps=[0, 4, 8, 12], pitches=60, velocities=[60, 70, 80, 90]) + assert [step.notes[0].velocity for step in pattern.steps.values()] == [ + 60, + 70, + 80, + 90, + ] + + +def test_arpeggio_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1, length=4) + builder.arpeggio(notes=[60, 64, 67], spacing=0.5, velocity=(70, 100)) + all_notes = [n for step in pattern.steps.values() for n in step.notes] + velocities = [n.velocity for n in all_notes] + assert all(70 <= v <= 100 for v in velocities) + + +def test_arpeggio_chord_form_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1, length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.arpeggio(chord, root=60, spacing=0.5, velocity=(70, 100)) + all_notes = [n for step in pattern.steps.values() for n in step.notes] + velocities = [n.velocity for n in all_notes] + assert all(70 <= v <= 100 for v in velocities) + assert len(set(velocities)) > 1 + + +def test_euclidean_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.euclidean(pitch=60, pulses=4, velocity=(50, 80)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(50 <= v <= 80 for v in velocities) + + +def test_bresenham_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.bresenham(pitch=60, pulses=4, velocity=(40, 70)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(40 <= v <= 70 for v in velocities) + + +def test_cellular_1d_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.cellular_1d(pitch=60, rule=30, velocity=(45, 75)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(45 <= v <= 75 for v in velocities) + + +def test_markov_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.markov( + transitions={"a": [("b", 1)], "b": [("a", 1)]}, + pitch_map={"a": 60, "b": 64}, + velocity=(50, 90), + spacing=0.5, + ) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(50 <= v <= 90 for v in velocities) + + +def test_thue_morse_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1) + builder.thue_morse(pitch=60, velocity=(40, 80)) + velocities = [step.notes[0].velocity for step in pattern.steps.values()] + assert all(40 <= v <= 80 for v in velocities) + + +def test_broken_chord_accepts_tuple_velocity() -> None: + pattern, builder = _make_builder(seed=1, length=4) + chord = subsequence.chords.Chord(root_pc=0, quality="major") + builder.broken_chord( + chord, root=60, order=[0, 1, 2, 0], spacing=0.25, velocity=(60, 95) + ) + all_notes = [n for step in pattern.steps.values() for n in step.notes] + velocities = [n.velocity for n in all_notes] + assert all(60 <= v <= 95 for v in velocities) # --- Bad inputs surface at the builder, not later in the sequencer --- -def test_invalid_velocity_raises_at_builder () -> None: - - """Issue 1's root cause: a bad velocity must raise at the builder call site - (where the per-pattern try/except in Composition._rebuild catches it), - not later in the sequencer dispatch loop where it would crash everything.""" +def test_invalid_velocity_raises_at_builder() -> None: + """Issue 1's root cause: a bad velocity must raise at the builder call site + (where the per-pattern try/except in Composition._rebuild catches it), + not later in the sequencer dispatch loop where it would crash everything.""" - _, builder = _make_builder() + _, builder = _make_builder() - with pytest.raises(TypeError): - builder.hit_steps(pitch=60, steps=[0, 4, 8, 12], velocity="loud") + with pytest.raises(TypeError): + builder.hit_steps(pitch=60, steps=[0, 4, 8, 12], velocity="loud") - with pytest.raises(ValueError, match="velocity tuple must be"): - builder.hit_steps(pitch=60, steps=[0, 4, 8, 12], velocity=(60, 80, 100)) + with pytest.raises(ValueError, match="velocity tuple must be"): + builder.hit_steps(pitch=60, steps=[0, 4, 8, 12], velocity=(60, 80, 100)) diff --git a/tests/test_voicings.py b/tests/test_voicings.py index b4ab574..00a5c6c 100644 --- a/tests/test_voicings.py +++ b/tests/test_voicings.py @@ -2,275 +2,251 @@ import subsequence.voicings -def test_root_position_identity () -> None: +def test_root_position_identity() -> None: + """Inversion 0 should return a copy of the original intervals.""" - """Inversion 0 should return a copy of the original intervals.""" + assert subsequence.voicings.invert_chord([0, 4, 7], 0) == [0, 4, 7] - assert subsequence.voicings.invert_chord([0, 4, 7], 0) == [0, 4, 7] +def test_first_inversion_triad() -> None: + """First inversion of a major triad: bottom note up an octave.""" -def test_first_inversion_triad () -> None: + result = subsequence.voicings.invert_chord([0, 4, 7], 1) - """First inversion of a major triad: bottom note up an octave.""" + # E is bass, G above, C up an octave - same pitch classes as root position. + assert result == [4, 7, 12] - result = subsequence.voicings.invert_chord([0, 4, 7], 1) - # E is bass, G above, C up an octave - same pitch classes as root position. - assert result == [4, 7, 12] +def test_second_inversion_triad() -> None: + """Second inversion of a major triad: two bottom notes up an octave.""" + result = subsequence.voicings.invert_chord([0, 4, 7], 2) -def test_second_inversion_triad () -> None: + # G is bass, C and E up an octave. + assert result == [7, 12, 16] - """Second inversion of a major triad: two bottom notes up an octave.""" - result = subsequence.voicings.invert_chord([0, 4, 7], 2) +def test_first_inversion_seventh() -> None: + """First inversion of a dominant seventh chord.""" - # G is bass, C and E up an octave. - assert result == [7, 12, 16] + result = subsequence.voicings.invert_chord([0, 4, 7, 10], 1) + # E bass; the root wraps up an octave. + assert result == [4, 7, 10, 12] -def test_first_inversion_seventh () -> None: - """First inversion of a dominant seventh chord.""" +def test_second_inversion_seventh() -> None: + """Second inversion of a dominant seventh chord.""" - result = subsequence.voicings.invert_chord([0, 4, 7, 10], 1) + result = subsequence.voicings.invert_chord([0, 4, 7, 10], 2) - # E bass; the root wraps up an octave. - assert result == [4, 7, 10, 12] + # G bass; root and third wrap up an octave. + assert result == [7, 10, 12, 16] -def test_second_inversion_seventh () -> None: +def test_third_inversion_seventh() -> None: + """Third inversion of a dominant seventh chord.""" - """Second inversion of a dominant seventh chord.""" + result = subsequence.voicings.invert_chord([0, 4, 7, 10], 3) - result = subsequence.voicings.invert_chord([0, 4, 7, 10], 2) + # Bb bass; root, third, and fifth wrap up an octave. + assert result == [10, 12, 16, 19] - # G bass; root and third wrap up an octave. - assert result == [7, 10, 12, 16] +def test_inversion_wraps_around() -> None: + """Inversion >= note count should wrap (like an extra octave cycle).""" -def test_third_inversion_seventh () -> None: + # Inversion 3 on a triad wraps to 0 (root position). + assert subsequence.voicings.invert_chord([0, 4, 7], 3) == [0, 4, 7] - """Third inversion of a dominant seventh chord.""" + # Inversion 4 on a triad wraps to 1 (first inversion). + assert subsequence.voicings.invert_chord([0, 4, 7], 4) == [4, 7, 12] - result = subsequence.voicings.invert_chord([0, 4, 7, 10], 3) - # Bb bass; root, third, and fifth wrap up an octave. - assert result == [10, 12, 16, 19] +def test_empty_intervals() -> None: + """Empty interval list should return empty.""" + assert subsequence.voicings.invert_chord([], 0) == [] + assert subsequence.voicings.invert_chord([], 1) == [] -def test_inversion_wraps_around () -> None: - """Inversion >= note count should wrap (like an extra octave cycle).""" +def test_minor_triad_inversions() -> None: + """Minor triad inversions should work correctly.""" - # Inversion 3 on a triad wraps to 0 (root position). - assert subsequence.voicings.invert_chord([0, 4, 7], 3) == [0, 4, 7] + minor = [0, 3, 7] - # Inversion 4 on a triad wraps to 1 (first inversion). - assert subsequence.voicings.invert_chord([0, 4, 7], 4) == [4, 7, 12] + # First inversion: Eb bass, C up an octave. + assert subsequence.voicings.invert_chord(minor, 1) == [3, 7, 12] - -def test_empty_intervals () -> None: - - """Empty interval list should return empty.""" - - assert subsequence.voicings.invert_chord([], 0) == [] - assert subsequence.voicings.invert_chord([], 1) == [] - - -def test_minor_triad_inversions () -> None: - - """Minor triad inversions should work correctly.""" - - minor = [0, 3, 7] - - # First inversion: Eb bass, C up an octave. - assert subsequence.voicings.invert_chord(minor, 1) == [3, 7, 12] - - # Second inversion: G bass, C and Eb up an octave. - assert subsequence.voicings.invert_chord(minor, 2) == [7, 12, 15] + # Second inversion: G bass, C and Eb up an octave. + assert subsequence.voicings.invert_chord(minor, 2) == [7, 12, 15] # ─── Voice leading ────────────────────────────────────────────── -def test_voice_lead_no_previous () -> None: - - """With no previous voicing, return root position.""" - - result = subsequence.voicings.voice_lead([0, 4, 7], 60, None) - - assert result == [60, 64, 67] +def test_voice_lead_no_previous() -> None: + """With no previous voicing, return root position.""" + result = subsequence.voicings.voice_lead([0, 4, 7], 60, None) -def test_voice_lead_picks_closest () -> None: + assert result == [60, 64, 67] - """Voice leading should pick the inversion closest to the previous voicing.""" - # Previous: C major root position [60, 64, 67] - # Target: F major (root 65). Best candidates: - # root: [65, 69, 72] - cost = 5+5+5 = 15 - # 2nd inv -8va: [60, 65, 69] - cost = 0+1+2 = 3 (C-F-A, common tone C) - # The octave-down second inversion is the classic smooth choice. - result = subsequence.voicings.voice_lead([0, 4, 7], 65, [60, 64, 67]) +def test_voice_lead_picks_closest() -> None: + """Voice leading should pick the inversion closest to the previous voicing.""" - assert result == [60, 65, 69] + # Previous: C major root position [60, 64, 67] + # Target: F major (root 65). Best candidates: + # root: [65, 69, 72] - cost = 5+5+5 = 15 + # 2nd inv -8va: [60, 65, 69] - cost = 0+1+2 = 3 (C-F-A, common tone C) + # The octave-down second inversion is the classic smooth choice. + result = subsequence.voicings.voice_lead([0, 4, 7], 65, [60, 64, 67]) + assert result == [60, 65, 69] -def test_voice_lead_prefers_smaller_movement () -> None: - """Voice leading should prefer the inversion with smallest total movement.""" +def test_voice_lead_prefers_smaller_movement() -> None: + """Voice leading should prefer the inversion with smallest total movement.""" - # Previous: first inversion C major = [64, 67, 72] - # Target: F major (root 65). Best candidates: - # root: [65, 69, 72] - cost = 1+2+0 = 3 - # 2nd inv -8va: [60, 65, 69] - cost = 4+2+3 = 9 - # Root position wins with the smallest total movement. - result = subsequence.voicings.voice_lead([0, 4, 7], 65, [64, 67, 72]) + # Previous: first inversion C major = [64, 67, 72] + # Target: F major (root 65). Best candidates: + # root: [65, 69, 72] - cost = 1+2+0 = 3 + # 2nd inv -8va: [60, 65, 69] - cost = 4+2+3 = 9 + # Root position wins with the smallest total movement. + result = subsequence.voicings.voice_lead([0, 4, 7], 65, [64, 67, 72]) - assert result == [65, 69, 72] + assert result == [65, 69, 72] -def test_voice_lead_size_mismatch_falls_back () -> None: +def test_voice_lead_size_mismatch_falls_back() -> None: + """When chord sizes differ, fall back to root position.""" - """When chord sizes differ, fall back to root position.""" + # Previous was a seventh chord (4 notes), new is a triad (3 notes). + result = subsequence.voicings.voice_lead([0, 4, 7], 60, [60, 64, 67, 70]) - # Previous was a seventh chord (4 notes), new is a triad (3 notes). - result = subsequence.voicings.voice_lead([0, 4, 7], 60, [60, 64, 67, 70]) + assert result == [60, 64, 67] - assert result == [60, 64, 67] +def test_voice_lead_empty_intervals() -> None: + """Empty intervals should return empty.""" -def test_voice_lead_empty_intervals () -> None: - - """Empty intervals should return empty.""" - - assert subsequence.voicings.voice_lead([], 60, None) == [] - assert subsequence.voicings.voice_lead([], 60, [60, 64, 67]) == [] + assert subsequence.voicings.voice_lead([], 60, None) == [] + assert subsequence.voicings.voice_lead([], 60, [60, 64, 67]) == [] # ─── VoiceLeadingState ────────────────────────────────────────── -def test_state_first_call_root_position () -> None: - - """First call to VoiceLeadingState should return root position.""" +def test_state_first_call_root_position() -> None: + """First call to VoiceLeadingState should return root position.""" - state = subsequence.voicings.VoiceLeadingState() - result = state.next([0, 4, 7], 60) + state = subsequence.voicings.VoiceLeadingState() + result = state.next([0, 4, 7], 60) - assert result == [60, 64, 67] + assert result == [60, 64, 67] -def test_state_persists_across_calls () -> None: +def test_state_persists_across_calls() -> None: + """VoiceLeadingState should use the previous voicing for the next call.""" - """VoiceLeadingState should use the previous voicing for the next call.""" + state = subsequence.voicings.VoiceLeadingState() - state = subsequence.voicings.VoiceLeadingState() + # First: C major root position. + v1 = state.next([0, 4, 7], 60) - # First: C major root position. - v1 = state.next([0, 4, 7], 60) + assert v1 == [60, 64, 67] - assert v1 == [60, 64, 67] + # Second: the state should now pick an inversion that's close to v1. + v2 = state.next([0, 3, 7], 62) - # Second: the state should now pick an inversion that's close to v1. - v2 = state.next([0, 3, 7], 62) + # D minor candidates relative to [60, 64, 67]: + # root: [62, 65, 69] - cost = 2+1+2 = 5 + # 2nd inv -8va: [57, 62, 65] - cost = 3+2+2 = 7 + assert v2 == [62, 65, 69] - # D minor candidates relative to [60, 64, 67]: - # root: [62, 65, 69] - cost = 2+1+2 = 5 - # 2nd inv -8va: [57, 62, 65] - cost = 3+2+2 = 7 - assert v2 == [62, 65, 69] + # Third call should use v2 as the reference. + assert state.previous_voicing == v2 - # Third call should use v2 as the reference. - assert state.previous_voicing == v2 +def test_state_size_change_resets() -> None: + """When chord size changes, state should fall back to root position.""" -def test_state_size_change_resets () -> None: + state = subsequence.voicings.VoiceLeadingState() - """When chord size changes, state should fall back to root position.""" + # Triad. + state.next([0, 4, 7], 60) - state = subsequence.voicings.VoiceLeadingState() + # Seventh chord - different size, falls back to root position. + v2 = state.next([0, 4, 7, 10], 60) - # Triad. - state.next([0, 4, 7], 60) + assert v2 == [60, 64, 67, 70] - # Seventh chord - different size, falls back to root position. - v2 = state.next([0, 4, 7, 10], 60) - - assert v2 == [60, 64, 67, 70] - - # State should now have the seventh chord voicing. - assert state.previous_voicing == [60, 64, 67, 70] + # State should now have the seventh chord voicing. + assert state.previous_voicing == [60, 64, 67, 70] # ─── Chord.tones() count parameter ───────────────────────────── -def test_tones_count_matches_natural () -> None: - - """count equal to natural note count should match default.""" - - chord = subsequence.chords.Chord(root_pc=0, quality="major") - - assert chord.tones(root=60, count=3) == chord.tones(root=60) - - -def test_tones_count_extends_triad () -> None: - - """count > 3 on a triad should cycle intervals into higher octaves.""" - - chord = subsequence.chords.Chord(root_pc=0, quality="major") +def test_tones_count_matches_natural() -> None: + """count equal to natural note count should match default.""" - # C major: [0, 4, 7] -> 5 notes: C E G C' E' - assert chord.tones(root=60, count=5) == [60, 64, 67, 72, 76] + chord = subsequence.chords.Chord(root_pc=0, quality="major") + assert chord.tones(root=60, count=3) == chord.tones(root=60) -def test_tones_count_extends_two_octaves () -> None: - """count=7 on a triad should span two full octaves plus the root.""" +def test_tones_count_extends_triad() -> None: + """count > 3 on a triad should cycle intervals into higher octaves.""" - chord = subsequence.chords.Chord(root_pc=0, quality="major") + chord = subsequence.chords.Chord(root_pc=0, quality="major") - assert chord.tones(root=60, count=7) == [60, 64, 67, 72, 76, 79, 84] + # C major: [0, 4, 7] -> 5 notes: C E G C' E' + assert chord.tones(root=60, count=5) == [60, 64, 67, 72, 76] -def test_tones_count_one () -> None: +def test_tones_count_extends_two_octaves() -> None: + """count=7 on a triad should span two full octaves plus the root.""" - """count=1 should return just the root.""" + chord = subsequence.chords.Chord(root_pc=0, quality="major") - chord = subsequence.chords.Chord(root_pc=0, quality="major") + assert chord.tones(root=60, count=7) == [60, 64, 67, 72, 76, 79, 84] - assert chord.tones(root=60, count=1) == [60] +def test_tones_count_one() -> None: + """count=1 should return just the root.""" -def test_tones_count_with_inversion () -> None: + chord = subsequence.chords.Chord(root_pc=0, quality="major") - """count should work together with inversion.""" + assert chord.tones(root=60, count=1) == [60] - chord = subsequence.chords.Chord(root_pc=0, quality="major") - # First inversion intervals: [4, 7, 12] - E, G, C ascending. - # 5 notes: E G C' E' G' - result = chord.tones(root=60, inversion=1, count=5) +def test_tones_count_with_inversion() -> None: + """count should work together with inversion.""" - assert result == [64, 67, 72, 76, 79] + chord = subsequence.chords.Chord(root_pc=0, quality="major") + # First inversion intervals: [4, 7, 12] - E, G, C ascending. + # 5 notes: E G C' E' G' + result = chord.tones(root=60, inversion=1, count=5) -def test_tones_count_seventh_chord () -> None: + assert result == [64, 67, 72, 76, 79] - """count should extend seventh chords correctly.""" - chord = subsequence.chords.Chord(root_pc=0, quality="dominant_7th") +def test_tones_count_seventh_chord() -> None: + """count should extend seventh chords correctly.""" - # [0, 4, 7, 10] -> 6 notes: C E G Bb C' E' - assert chord.tones(root=60, count=6) == [60, 64, 67, 70, 72, 76] + chord = subsequence.chords.Chord(root_pc=0, quality="dominant_7th") + # [0, 4, 7, 10] -> 6 notes: C E G Bb C' E' + assert chord.tones(root=60, count=6) == [60, 64, 67, 70, 72, 76] -def test_tones_count_minor () -> None: - """count should extend minor triads correctly.""" +def test_tones_count_minor() -> None: + """count should extend minor triads correctly.""" - chord = subsequence.chords.Chord(root_pc=0, quality="minor") + chord = subsequence.chords.Chord(root_pc=0, quality="minor") - # [0, 3, 7] -> 5 notes: C Eb G C' Eb' - assert chord.tones(root=60, count=5) == [60, 63, 67, 72, 75] + # [0, 3, 7] -> 5 notes: C Eb G C' Eb' + assert chord.tones(root=60, count=5) == [60, 63, 67, 72, 75] diff --git a/tests/test_web_ui.py b/tests/test_web_ui.py index f65c5ea..7702805 100644 --- a/tests/test_web_ui.py +++ b/tests/test_web_ui.py @@ -12,70 +12,77 @@ import subsequence.web_ui -def test_web_ui_get_state_has_expected_shape (patch_midi: None) -> None: +def test_web_ui_get_state_has_expected_shape(patch_midi: None) -> None: + """_get_state() returns a JSON-serialisable dict with the keys the frontend reads.""" - """_get_state() returns a JSON-serialisable dict with the keys the frontend reads.""" + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + ui = subsequence.web_ui.WebUI(comp) - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - ui = subsequence.web_ui.WebUI(comp) + state = ui._get_state(comp) - state = ui._get_state(comp) + for key in ( + "bpm", + "key", + "patterns", + "signals", + "playhead_pulse", + "pulses_per_beat", + "global_bar", + ): + assert key in state - for key in ("bpm", "key", "patterns", "signals", "playhead_pulse", "pulses_per_beat", "global_bar"): - assert key in state + assert state["bpm"] == 120 + assert state["key"] == "C" + assert isinstance(state["patterns"], list) + assert isinstance(state["signals"], dict) - assert state["bpm"] == 120 - assert state["key"] == "C" - assert isinstance(state["patterns"], list) - assert isinstance(state["signals"], dict) + # It is sent over the WebSocket, so it must serialise. + json.dumps(state) - # It is sent over the WebSocket, so it must serialise. - json.dumps(state) +def test_web_ui_http_server_starts_and_stops_cleanly(patch_midi: None) -> None: + """_start_http_server() binds a worker thread; stop() shuts it down and joins it. -def test_web_ui_http_server_starts_and_stops_cleanly (patch_midi: None) -> None: + Guards the port/thread cleanup path that stop() exists to provide. + """ - """_start_http_server() binds a worker thread; stop() shuts it down and joins it. + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + ui = subsequence.web_ui.WebUI( + comp, http_port=0 + ) # ephemeral port — avoids conflicts - Guards the port/thread cleanup path that stop() exists to provide. - """ + ui._start_http_server() - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") - ui = subsequence.web_ui.WebUI(comp, http_port=0) # ephemeral port — avoids conflicts + assert ui._httpd is not None + assert ui._http_thread is not None + assert ui._http_thread.is_alive() - ui._start_http_server() + ui.stop() - assert ui._httpd is not None - assert ui._http_thread is not None - assert ui._http_thread.is_alive() + assert ui._httpd is None + assert ui._http_thread is None - ui.stop() - assert ui._httpd is None - assert ui._http_thread is None +def test_web_ui_get_state_serialises_running_pattern(patch_midi: None) -> None: + """A running pattern appears in state["patterns"] with its name, mute state, and notes.""" + comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") -def test_web_ui_get_state_serialises_running_pattern (patch_midi: None) -> None: + pattern = subsequence.pattern.Pattern(channel=0, length=4) + pattern.add_note(position=24, pitch=60, velocity=90, duration=12) + comp._running_patterns["bass"] = pattern - """A running pattern appears in state["patterns"] with its name, mute state, and notes.""" + ui = subsequence.web_ui.WebUI(comp) + state = ui._get_state(comp) - comp = subsequence.Composition(output_device="Dummy MIDI", bpm=120, key="C") + assert len(state["patterns"]) == 1 - pattern = subsequence.pattern.Pattern(channel=0, length=4) - pattern.add_note(position=24, pitch=60, velocity=90, duration=12) - comp._running_patterns["bass"] = pattern + entry = state["patterns"][0] + assert entry["name"] == "bass" + assert entry["muted"] is False + assert entry["length_pulses"] == 96 + assert entry["drum_map"] is None + assert entry["notes"] == [{"p": 60, "s": 24, "d": 12, "v": 90}] - ui = subsequence.web_ui.WebUI(comp) - state = ui._get_state(comp) - - assert len(state["patterns"]) == 1 - - entry = state["patterns"][0] - assert entry["name"] == "bass" - assert entry["muted"] is False - assert entry["length_pulses"] == 96 - assert entry["drum_map"] is None - assert entry["notes"] == [{"p": 60, "s": 24, "d": 12, "v": 90}] - - # The pattern entry rides the same WebSocket payload, so it must serialise. - json.dumps(state) + # The pattern entry rides the same WebSocket payload, so it must serialise. + json.dumps(state) diff --git a/tests/test_weighted_graph.py b/tests/test_weighted_graph.py index 4141077..0d2c6ff 100644 --- a/tests/test_weighted_graph.py +++ b/tests/test_weighted_graph.py @@ -7,68 +7,74 @@ import subsequence.weighted_graph -def test_label_stored_and_read () -> None: +def test_label_stored_and_read() -> None: + """A labelled transition reports its label; unlabelled ones report None.""" - """A labelled transition reports its label; unlabelled ones report None.""" + graph: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) + graph.add_transition("V", "I", 10, label="cadence") + graph.add_transition("V", "vi", 5) - graph: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - graph.add_transition("V", "I", 10, label="cadence") - graph.add_transition("V", "vi", 5) + assert graph.get_label("V", "I") == "cadence" + assert graph.get_label("V", "vi") is None + assert graph.get_label("I", "V") is None - assert graph.get_label("V", "I") == "cadence" - assert graph.get_label("V", "vi") is None - assert graph.get_label("I", "V") is None +def test_label_survives_weight_accumulation() -> None: + """Re-adding a transition strengthens the edge without losing its label.""" -def test_label_survives_weight_accumulation () -> None: + graph: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) + graph.add_transition("V", "I", 10, label="cadence") + graph.add_transition("V", "I", 5) - """Re-adding a transition strengthens the edge without losing its label.""" + assert graph.get_transitions("V") == [("I", 15)] + assert graph.get_label("V", "I") == "cadence" - graph: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - graph.add_transition("V", "I", 10, label="cadence") - graph.add_transition("V", "I", 5) - assert graph.get_transitions("V") == [("I", 15)] - assert graph.get_label("V", "I") == "cadence" +def test_label_replaced_on_relabel() -> None: + """A label given on a re-add replaces the previous one.""" + graph: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) + graph.add_transition("V", "vi", 5, label="weak") + graph.add_transition("V", "vi", 5, label="deceptive") -def test_label_replaced_on_relabel () -> None: + assert graph.get_label("V", "vi") == "deceptive" - """A label given on a re-add replaces the previous one.""" - graph: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - graph.add_transition("V", "vi", 5, label="weak") - graph.add_transition("V", "vi", 5, label="deceptive") +def test_transitions_with_label() -> None: + """transitions_with_label filters outgoing edges by their label.""" - assert graph.get_label("V", "vi") == "deceptive" + graph: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) + graph.add_transition("V", "I", 10, label="cadence") + graph.add_transition("V", "vi", 5, label="deceptive") + graph.add_transition("V", "IV", 3) + assert graph.transitions_with_label("V", "cadence") == [("I", 10)] + assert graph.transitions_with_label("V", "deceptive") == [("vi", 5)] + assert graph.transitions_with_label("V", "missing") == [] -def test_transitions_with_label () -> None: - """transitions_with_label filters outgoing edges by their label.""" +def test_choose_next_is_unaffected_by_labels() -> None: + """Labels are metadata — the weighted walk ignores them.""" - graph: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - graph.add_transition("V", "I", 10, label="cadence") - graph.add_transition("V", "vi", 5, label="deceptive") - graph.add_transition("V", "IV", 3) + labelled: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) + bare: subsequence.weighted_graph.WeightedGraph = ( + subsequence.weighted_graph.WeightedGraph() + ) - assert graph.transitions_with_label("V", "cadence") == [("I", 10)] - assert graph.transitions_with_label("V", "deceptive") == [("vi", 5)] - assert graph.transitions_with_label("V", "missing") == [] + for graph, label in ((labelled, "cadence"), (bare, None)): + graph.add_transition("a", "b", 3, label=label) + graph.add_transition("a", "c", 7) - -def test_choose_next_is_unaffected_by_labels () -> None: - - """Labels are metadata — the weighted walk ignores them.""" - - labelled: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - bare: subsequence.weighted_graph.WeightedGraph = subsequence.weighted_graph.WeightedGraph() - - for graph, label in ((labelled, "cadence"), (bare, None)): - graph.add_transition("a", "b", 3, label=label) - graph.add_transition("a", "c", 7) - - assert ( - labelled.choose_next("a", random.Random(5)) - == bare.choose_next("a", random.Random(5)) - ) + assert labelled.choose_next("a", random.Random(5)) == bare.choose_next( + "a", random.Random(5) + ) diff --git a/tests/test_wing_helper.py b/tests/test_wing_helper.py index b8313db..c9f2022 100644 --- a/tests/test_wing_helper.py +++ b/tests/test_wing_helper.py @@ -15,203 +15,202 @@ # ── Helpers ─────────────────────────────────────────────────────────────────── -def _build_osc_response (address: str, type_tags: str, *values: typing.Any) -> bytes: - """Build a minimal OSC response packet for use in socket mocks. - Constructs address + type tag string + values following the OSC 1.0 spec: - all segments are null-terminated and zero-padded to 4-byte boundaries. - """ - def _pad (data: bytes) -> bytes: - """Null-terminate and pad to 4-byte boundary.""" - data = data + b"\x00" - remainder = len(data) % 4 - if remainder: - data = data + b"\x00" * (4 - remainder) - return data +def _build_osc_response(address: str, type_tags: str, *values: typing.Any) -> bytes: + """Build a minimal OSC response packet for use in socket mocks. - blob = _pad(address.encode()) - blob += _pad(("," + type_tags).encode()) + Constructs address + type tag string + values following the OSC 1.0 spec: + all segments are null-terminated and zero-padded to 4-byte boundaries. + """ - for tag, value in zip(type_tags, values): - if tag == "s": - blob += _pad(value.encode()) - elif tag == "f": - blob += struct.pack(">f", value) - elif tag == "i": - blob += struct.pack(">i", value) + def _pad(data: bytes) -> bytes: + """Null-terminate and pad to 4-byte boundary.""" + data = data + b"\x00" + remainder = len(data) % 4 + if remainder: + data = data + b"\x00" * (4 - remainder) + return data - return blob + blob = _pad(address.encode()) + blob += _pad(("," + type_tags).encode()) + for tag, value in zip(type_tags, values): + if tag == "s": + blob += _pad(value.encode()) + elif tag == "f": + blob += struct.pack(">f", value) + elif tag == "i": + blob += struct.pack(">i", value) -def _discovery_response_bytes (ip: str = "192.168.0.116") -> bytes: - """Build the bytes the WING sends in reply to a /? broadcast.""" - info = f"WING,{ip},WING-PP-20021049,wing-rack,0100A9P0604AAE,3.1-0-g9f314617:release" - return _build_osc_response("/*", "s", info) + return blob -# ── discover() ──────────────────────────────────────────────────────────────── - +def _discovery_response_bytes(ip: str = "192.168.0.116") -> bytes: + """Build the bytes the WING sends in reply to a /? broadcast.""" + info = ( + f"WING,{ip},WING-PP-20021049,wing-rack,0100A9P0604AAE,3.1-0-g9f314617:release" + ) + return _build_osc_response("/*", "s", info) -def test_discover_returns_device_info () -> None: - """discover() should parse the WING reply into a dict with ip/model/firmware.""" +# ── discover() ──────────────────────────────────────────────────────────────── - response = _discovery_response_bytes("192.168.1.50") - with unittest.mock.patch("socket.socket") as MockSocket: - mock_sock = unittest.mock.MagicMock() - MockSocket.return_value.__enter__ = unittest.mock.MagicMock(return_value=mock_sock) - MockSocket.return_value.__exit__ = unittest.mock.MagicMock(return_value=False) - MockSocket.return_value = mock_sock - mock_sock.recvfrom.return_value = (response, ("192.168.1.50", 2223)) +def test_discover_returns_device_info() -> None: + """discover() should parse the WING reply into a dict with ip/model/firmware.""" - result = wing.discover(timeout=1.0) + response = _discovery_response_bytes("192.168.1.50") - assert result is not None - assert result["ip"] == "192.168.1.50" - assert result["device"] == "WING" - assert result["model"] == "WING-PP-20021049" - assert result["form_factor"] == "wing-rack" - assert "3.1" in result["firmware"] + with unittest.mock.patch("socket.socket") as MockSocket: + mock_sock = unittest.mock.MagicMock() + MockSocket.return_value.__enter__ = unittest.mock.MagicMock( + return_value=mock_sock + ) + MockSocket.return_value.__exit__ = unittest.mock.MagicMock(return_value=False) + MockSocket.return_value = mock_sock + mock_sock.recvfrom.return_value = (response, ("192.168.1.50", 2223)) + result = wing.discover(timeout=1.0) -def test_discover_timeout_returns_none () -> None: + assert result is not None + assert result["ip"] == "192.168.1.50" + assert result["device"] == "WING" + assert result["model"] == "WING-PP-20021049" + assert result["form_factor"] == "wing-rack" + assert "3.1" in result["firmware"] - """discover() should return None when no device responds.""" - with unittest.mock.patch("socket.socket") as MockSocket: - mock_sock = unittest.mock.MagicMock() - MockSocket.return_value = mock_sock - mock_sock.recvfrom.side_effect = socket.timeout +def test_discover_timeout_returns_none() -> None: + """discover() should return None when no device responds.""" - result = wing.discover(timeout=0.1) + with unittest.mock.patch("socket.socket") as MockSocket: + mock_sock = unittest.mock.MagicMock() + MockSocket.return_value = mock_sock + mock_sock.recvfrom.side_effect = socket.timeout - assert result is None + result = wing.discover(timeout=0.1) + assert result is None -def test_discover_builds_correct_osc_address () -> None: - """discover() should send an OSC message with address '/?'.""" +def test_discover_builds_correct_osc_address() -> None: + """discover() should send an OSC message with address '/?'.""" - import pythonosc.osc_message + import pythonosc.osc_message - response = _discovery_response_bytes() - sent_packets: typing.List[bytes] = [] + response = _discovery_response_bytes() + sent_packets: typing.List[bytes] = [] - with unittest.mock.patch("socket.socket") as MockSocket: - mock_sock = unittest.mock.MagicMock() - MockSocket.return_value = mock_sock + with unittest.mock.patch("socket.socket") as MockSocket: + mock_sock = unittest.mock.MagicMock() + MockSocket.return_value = mock_sock - def capture_sendto (data: bytes, addr: tuple) -> None: - sent_packets.append(data) + def capture_sendto(data: bytes, addr: tuple) -> None: + sent_packets.append(data) - mock_sock.sendto.side_effect = capture_sendto - mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) + mock_sock.sendto.side_effect = capture_sendto + mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) - wing.discover(timeout=1.0) + wing.discover(timeout=1.0) - assert len(sent_packets) >= 1 - # The sent packet should be a valid OSC message with address "/?". - msg = pythonosc.osc_message.OscMessage(sent_packets[0]) - assert msg.address == "/?" + assert len(sent_packets) >= 1 + # The sent packet should be a valid OSC message with address "/?". + msg = pythonosc.osc_message.OscMessage(sent_packets[0]) + assert msg.address == "/?" # ── query() ─────────────────────────────────────────────────────────────────── -def test_query_node_returns_children () -> None: - - """query() on a node address should return type='node' with children list.""" - - # Build a response like /ch/1 → ['in', 'flt', 'fdr', 'mute'] - response = _build_osc_response("/ch/1", "ssss", "in", "flt", "fdr", "mute") - - with unittest.mock.patch("socket.socket") as MockSocket: - mock_sock = unittest.mock.MagicMock() - MockSocket.return_value = mock_sock - mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) - - result = wing.query("192.168.0.116", "/ch/1") +def test_query_node_returns_children() -> None: + """query() on a node address should return type='node' with children list.""" - assert result is not None - assert result["type"] == "node" - assert result["children"] == ["in", "flt", "fdr", "mute"] - assert result["address"] == "/ch/1" + # Build a response like /ch/1 → ['in', 'flt', 'fdr', 'mute'] + response = _build_osc_response("/ch/1", "ssss", "in", "flt", "fdr", "mute") + with unittest.mock.patch("socket.socket") as MockSocket: + mock_sock = unittest.mock.MagicMock() + MockSocket.return_value = mock_sock + mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) -def test_query_numeric_leaf_extracts_float_and_int () -> None: + result = wing.query("192.168.0.116", "/ch/1") - """query() on a numeric leaf should return value_f (float) and value_i (int).""" + assert result is not None + assert result["type"] == "node" + assert result["children"] == ["in", "flt", "fdr", "mute"] + assert result["address"] == "/ch/1" - # Build a response like /ch/1/fdr → ('0.0', 0.75, 0) — string + float + int - response = _build_osc_response("/ch/1/fdr", "sfi", "0.0", 0.75, 0) - with unittest.mock.patch("socket.socket") as MockSocket: - mock_sock = unittest.mock.MagicMock() - MockSocket.return_value = mock_sock - mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) +def test_query_numeric_leaf_extracts_float_and_int() -> None: + """query() on a numeric leaf should return value_f (float) and value_i (int).""" - result = wing.query("192.168.0.116", "/ch/1/fdr") + # Build a response like /ch/1/fdr → ('0.0', 0.75, 0) — string + float + int + response = _build_osc_response("/ch/1/fdr", "sfi", "0.0", 0.75, 0) - assert result is not None - assert result["type"] == "leaf" - assert result["value"] == "0.0" - assert result["value_f"] == pytest.approx(0.75, abs=0.01) - assert result["address"] == "/ch/1/fdr" + with unittest.mock.patch("socket.socket") as MockSocket: + mock_sock = unittest.mock.MagicMock() + MockSocket.return_value = mock_sock + mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) + result = wing.query("192.168.0.116", "/ch/1/fdr") -def test_query_string_leaf () -> None: + assert result is not None + assert result["type"] == "leaf" + assert result["value"] == "0.0" + assert result["value_f"] == pytest.approx(0.75, abs=0.01) + assert result["address"] == "/ch/1/fdr" - """query() on a string-only leaf should return type='leaf' with value.""" - response = _build_osc_response("/ch/1/name", "s", "Kick") +def test_query_string_leaf() -> None: + """query() on a string-only leaf should return type='leaf' with value.""" - with unittest.mock.patch("socket.socket") as MockSocket: - mock_sock = unittest.mock.MagicMock() - MockSocket.return_value = mock_sock - mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) + response = _build_osc_response("/ch/1/name", "s", "Kick") - result = wing.query("192.168.0.116", "/ch/1/name") + with unittest.mock.patch("socket.socket") as MockSocket: + mock_sock = unittest.mock.MagicMock() + MockSocket.return_value = mock_sock + mock_sock.recvfrom.return_value = (response, ("192.168.0.116", 2223)) - assert result is not None - assert result["type"] == "leaf" - assert result["value"] == "Kick" - assert result["value_f"] is None - assert result["value_i"] is None + result = wing.query("192.168.0.116", "/ch/1/name") + assert result is not None + assert result["type"] == "leaf" + assert result["value"] == "Kick" + assert result["value_f"] is None + assert result["value_i"] is None -def test_query_timeout_returns_none () -> None: - """query() should return None when the WING does not reply.""" +def test_query_timeout_returns_none() -> None: + """query() should return None when the WING does not reply.""" - with unittest.mock.patch("socket.socket") as MockSocket: - mock_sock = unittest.mock.MagicMock() - MockSocket.return_value = mock_sock - mock_sock.recvfrom.side_effect = socket.timeout + with unittest.mock.patch("socket.socket") as MockSocket: + mock_sock = unittest.mock.MagicMock() + MockSocket.return_value = mock_sock + mock_sock.recvfrom.side_effect = socket.timeout - result = wing.query("192.168.0.116", "/ch/1", timeout=0.1) + result = wing.query("192.168.0.116", "/ch/1", timeout=0.1) - assert result is None + assert result is None # ── _classify() ─────────────────────────────────────────────────────────────── -def test_classify_node_multiple_strings () -> None: - assert wing._classify(["in", "flt", "fdr"]) == "node" +def test_classify_node_multiple_strings() -> None: + assert wing._classify(["in", "flt", "fdr"]) == "node" -def test_classify_leaf_single_string () -> None: - assert wing._classify(["Kick"]) == "leaf" +def test_classify_leaf_single_string() -> None: + assert wing._classify(["Kick"]) == "leaf" -def test_classify_leaf_empty_string () -> None: - assert wing._classify([""]) == "leaf" +def test_classify_leaf_empty_string() -> None: + assert wing._classify([""]) == "leaf" -def test_classify_leaf_has_float () -> None: - assert wing._classify(["0.0", 0.75, 0]) == "leaf" +def test_classify_leaf_has_float() -> None: + assert wing._classify(["0.0", 0.75, 0]) == "leaf" -def test_classify_empty () -> None: - assert wing._classify([]) == "leaf" +def test_classify_empty() -> None: + assert wing._classify([]) == "leaf" From 54104905b2864a9acb72389946bed1a19d688ad7 Mon Sep 17 00:00:00 2001 From: Brian Sorahan Date: Sun, 12 Jul 2026 18:07:50 -0500 Subject: [PATCH 2/3] emacs config --- .config.el | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .config.el diff --git a/.config.el b/.config.el new file mode 100644 index 0000000..1a2b776 --- /dev/null +++ b/.config.el @@ -0,0 +1,4 @@ +(remove-hook 'python-mode-hook 'blacken-mode) + +(require 'ruff-format) +(add-hook 'python-mode-hook 'ruff-format-on-save-mode) From e8ce0d87e0104500bae464e98b00806dfa2a5aec Mon Sep 17 00:00:00 2001 From: Brian Sorahan Date: Sun, 12 Jul 2026 18:51:22 -0500 Subject: [PATCH 3/3] check ruff/mypy in github actions --- .github/workflows/build.yaml | 35 ++ .github/workflows/tests.yml | 14 +- pyproject.toml | 5 + tasks.py | 34 ++ uv.lock | 774 +++++++++++++++++++++++++++++++++++ 5 files changed, 854 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/build.yaml create mode 100644 tasks.py create mode 100644 uv.lock diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..3661f66 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,35 @@ +name: CI Build + +on: [push, workflow_dispatch] + +jobs: + execute: + name: CI Build + runs-on: enverus-ubuntu + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v3 + + - name: Sync dependencies + env: + JFROG_ARTIFACTORY_USER: ${{ secrets.SRE_ARTIFACTORY_USER_VIRTUAL_REPO_RW }} + JFROG_ARTIFACTORY_TOKEN: ${{ secrets.SRE_ARTIFACTORY_TOKEN_VIRTUAL_REPO_RW }} + run: | + set -euo pipefail + ./run_with_artifactory_creds.sh uv sync --all-groups --all-extras --frozen + + - name: Build + env: + JFROG_ARTIFACTORY_USER: ${{ secrets.SRE_ARTIFACTORY_USER_VIRTUAL_REPO_RW }} + JFROG_ARTIFACTORY_TOKEN: ${{ secrets.SRE_ARTIFACTORY_TOKEN_VIRTUAL_REPO_RW }} + run: | + set -euo pipefail + ./run_with_artifactory_creds.sh uv run invoke build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa40044..0804a28 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,20 +10,18 @@ jobs: pytest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.10' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[test]" + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b - - name: Check docstring markup - run: python scripts/check_docstring_markup.py subsequence + - name: Install dependencies + run: uv sync --all-gropus --all-extras --frozen - name: Run tests - run: python -m pytest tests/ -q + run: uv run invoke diff --git a/pyproject.toml b/pyproject.toml index daab692..0780378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,4 +75,9 @@ module = "rtmidi.*" ignore_missing_imports = true [tool.setuptools_scm] + +[dependency-groups] +dev = [ + "invoke>=3.0.3", +] # Enable setuptools_scm to derive version from git tags diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..92b896b --- /dev/null +++ b/tasks.py @@ -0,0 +1,34 @@ +import os + +from invoke import task +from invoke.context import Context + + +DIRNAME = os.path.dirname(__file__) + + +@task +def format_check(ctx: Context) -> None: + cmd = "ruff format --check" + print(cmd) + ctx.run(cmd) + + +@task +def mypy(ctx: Context) -> None: + src = os.path.join(DIRNAME, "subsequence") + cmd = f"mypy {src}" + print(cmd) + ctx.run(cmd) + + +@task +def pytest(ctx: Context) -> None: + cmd = "python -m pytest tests/ -q" + print(cmd) + ctx.run(cmd) + + +@task(format_check, mypy, pytest, default=True) +def build(_): + pass diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a30f315 --- /dev/null +++ b/uv.lock @@ -0,0 +1,774 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "aalink" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/0c/b50ad59e61ed3fb7957882eac3e0d1ac40eb17edc7a857b27ecb9ccea305/aalink-0.2.3.tar.gz", hash = "sha256:2b8d5388ced3e412d03cbde30cc186c8d7eb84031c00d405baec7108d5d8406b", size = 5696489, upload-time = "2026-07-03T20:18:11.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/0a/91930883528c94c5f4eb8db52cbe7c7ae200ea6c0cdc05c2712344fc0651/aalink-0.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c29553decfc30b5a2fb246e2b359e34572e41adeef3f9e39c61694fe5ff7fe66", size = 203807, upload-time = "2026-07-03T20:16:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/b4/20/d7e5a18a9289358fd88331fea987e9d78223d5850328f21f31ab32c237cf/aalink-0.2.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:8968eb3ae70a77cdf12782df46573239b9beffb401dc515c4be07efc3ac897a9", size = 219218, upload-time = "2026-07-03T20:16:49.208Z" }, + { url = "https://files.pythonhosted.org/packages/94/b3/305687ad0d645c4e62423a7cb173dcbe0b7e3712a62e030556aae422a78f/aalink-0.2.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9bd3a9e04103b3e130e400317b8e0ba2c05a8675613787f9a1a0fa4c30edccab", size = 217083, upload-time = "2026-07-03T20:16:50.834Z" }, + { url = "https://files.pythonhosted.org/packages/a0/db/56ffd706b8ce4ca61aa23d38ed88f44bc79d071954f29a2fcb843915cb5f/aalink-0.2.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ee3ee4e6b7feed577ef4f40d1565c930e4c3929a88eb8ff67090f4a1dc374aae", size = 229111, upload-time = "2026-07-03T20:16:52.352Z" }, + { url = "https://files.pythonhosted.org/packages/70/54/7f7d2282a70aadc964ac9e555bc03eab85a0300d8a1a6e08d20f11d528cb/aalink-0.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc618954963895ca65d208a296952a1f38b1e45551ecfaad25333ea9bfb5233e", size = 1210661, upload-time = "2026-07-03T20:16:53.781Z" }, + { url = "https://files.pythonhosted.org/packages/4c/84/657d499372c13726df47344d536c855867e5370c1aac94a2da2bcde8428c/aalink-0.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bb0a97c491f1b166353b9c2431122302274e5c06d286346560e3f539843bbc8e", size = 1273075, upload-time = "2026-07-03T20:16:55.487Z" }, + { url = "https://files.pythonhosted.org/packages/b5/89/689ccbfc1d32610d4d91b2d14524b57a3a572aaa1b8c1b5d0576ea42a92a/aalink-0.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:3ca1daebc07aede6c648240cd2dd8a0d7fcc7d8f4d7d636c106f39e51597be39", size = 382614, upload-time = "2026-07-03T20:16:57.107Z" }, + { url = "https://files.pythonhosted.org/packages/c0/eb/52a1ed10e7ce7b0b9e5a1f85af866f21180bf6657dd3f725e38ea41f7f89/aalink-0.2.3-cp310-cp310-win_arm64.whl", hash = "sha256:7b57047b91b23f05e50d86c3f24161c2ec668e3511ef25dc94f3317b4c240f84", size = 552109, upload-time = "2026-07-03T20:16:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b9/90abe29c8431452d43c9d2a5b08a5c2a86f220fa3a09881df10f1c275ae3/aalink-0.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fbbe750aa082436e333ea3233ff11f544893f6d37fb4ce261d1d2df4d4048aee", size = 204948, upload-time = "2026-07-03T20:17:00.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/a5f80305d99049106ddf1a9f5438c105c33a12ad670ade51f97504f4dfcf/aalink-0.2.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:bb1290e0511c88058a843788d5e6c194a10c735d8dcc148991cec9f9548695ce", size = 220503, upload-time = "2026-07-03T20:17:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/39/b8/a0ceba38ea19e2fd3b69b5c5c20ddfbceb3b99daa3e8dc1df6e56de65c73/aalink-0.2.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:06991646de355b9c2019175b96ad78b7059c10091ed88a22eb80d27105d98f73", size = 217892, upload-time = "2026-07-03T20:17:02.973Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/e89ae13d6a26ad4f8102a41dac3be353812f9a9df58a10714f8ee0eb202a/aalink-0.2.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:269a54c701614a91bada441d412b6967dbf332ea444d1d806bde466311fca303", size = 229602, upload-time = "2026-07-03T20:17:04.192Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1b/a8a22bf018da02a523471936fe9adbad800665553d92087e9966859ce84c/aalink-0.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a8c5572735a0be1d17bf5299ea4d03f16883b3ee0637c013f0ac80407b46abae", size = 1211334, upload-time = "2026-07-03T20:17:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/6e/85/ababdc5e152d6be99463c9d7fc664cabcb8fc3282776f4a04e7b4127d662/aalink-0.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a57bd3a1d803003be52d676f73acd1e82ad482ed57a2144c6dfcaaf4c01ff65b", size = 1274365, upload-time = "2026-07-03T20:17:08.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/61/141cf9048b15ba5aaa41e5a9d3b96d6b41f79ad1ab578b84437143496cdf/aalink-0.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:adb9b2a52b4d85897f5d885d8901f74330f362a2575992a557bca0315d13c95a", size = 383385, upload-time = "2026-07-03T20:17:09.845Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4c/4a3b18f0130baa0e5935e5909f2052f12c3b559e189b4951e41b59153234/aalink-0.2.3-cp311-cp311-win_arm64.whl", hash = "sha256:4c76b145aa301e0c86e089d879af15451113d44dd85d99b748081657721165f8", size = 553149, upload-time = "2026-07-03T20:17:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/11/96/b9b1f5fe518a5b3fce3eac8503b46b5ebcbe2c7175c75a450e060e08916f/aalink-0.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e68800b20e31c4c229532ec6a52114a6d8cf91c726947a77828693c8b896b580", size = 205325, upload-time = "2026-07-03T20:17:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/e46f25794a3a25c2d9bc836959a36c8f92fa2daf74f65d8d46b87ef95c5a/aalink-0.2.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:cd9a1a277a3a6acedde70d500848a1bf1b17c93139a1da84c65d4dd2622d9197", size = 221238, upload-time = "2026-07-03T20:17:14.322Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7b/b745adb80df99a9d8c524bf2d800ad4ae83c19ebd9cd162798dfecca8957/aalink-0.2.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0aa6a67c501b3423e1dfc1da8fac55570ddae89238d11e5d346b2c9c1127622d", size = 218236, upload-time = "2026-07-03T20:17:15.839Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bc/b0cb7dec5047a3b873e09b7f13abe5fd73fb965f853de7245de8be76ae2e/aalink-0.2.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d86d40f63e7b6744dddf5b52ae273a0218f96d48332a95f7b429bc6a6eb608a", size = 230648, upload-time = "2026-07-03T20:17:17.158Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/bbca1946d4ca7614636b0ecbb6a60b722cab599f37b6fc826e9691475b00/aalink-0.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f93bc313aee2c9b7b268c59f1155783566ef24e81a1ab3d0f3f493e39c763d2", size = 1211323, upload-time = "2026-07-03T20:17:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/d34d012dbd114a5cf0d98ba070578124c1e0628e09ac08525878d2183869/aalink-0.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bf04fddb1ec5bec89e87eaeef5a3978002643fe4889a28b833c01a90edd23d8a", size = 1274461, upload-time = "2026-07-03T20:17:20.314Z" }, + { url = "https://files.pythonhosted.org/packages/93/d4/34e1f9feff4e3ffbebc1f5571e3e4d3deecb26561290e27d30de012df5af/aalink-0.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:3be1c13a5cd50d10079be00b810c3252206137b587bd07592a8451681c9fdb22", size = 384328, upload-time = "2026-07-03T20:17:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f1/33df6cd62ba476f9621bf2ec89e0c6799353100d6c55b2c3c49b481b3060/aalink-0.2.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4a2e1b9f5f23ac83b7e1bd7320ef4499bdd0caae04d426d815fc140f27906b0", size = 552160, upload-time = "2026-07-03T20:17:23.592Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f6/9b238f02f3756b2f1b9d981d02addda12c8f67abfc189f862e6058237861/aalink-0.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33c054b88c184b83a478e9be1500722b236c72175732a54e5290a06375e054d7", size = 205380, upload-time = "2026-07-03T20:17:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/174637b79ecec83a820ced4bf138f69008ba00e27ac2fec8a48cd5bf6908/aalink-0.2.3-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:f90a2f84c096bd58d5e0fb16f787379a00fa0ca3e0470c292b66768b94f39c9e", size = 221305, upload-time = "2026-07-03T20:17:26.308Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e8/7eab93471ac24ebb221fc0a452c6a780c9d59c43202c8875cb450d8d77d0/aalink-0.2.3-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d8d25e30e98a9a5cd6e9fe2cfe8e69390157b01cfecbebccc81202797cd41c85", size = 218285, upload-time = "2026-07-03T20:17:27.695Z" }, + { url = "https://files.pythonhosted.org/packages/99/2f/c893472ddc696a7ffe8793bda795b02ef002a2030d7cbc1d93c4cc0fbd67/aalink-0.2.3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9c67fcf5c6e796754156446a118897d0683fde5c6c1fb425deff23640cef8e33", size = 230661, upload-time = "2026-07-03T20:17:29.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/a8/8db62780e4f39523a1c9e231d479a9b234289db4de1b01b0b2f71405e28f/aalink-0.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e50addd453904a50f00839865863569e186e1af9be271d670cf1c692f8d06", size = 1211358, upload-time = "2026-07-03T20:17:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/ae/25/51cba60c1f59637f90f239929140edb35c3986bc33723db91362ec8a0f65/aalink-0.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:693d9b1a418f838d1eb23a945370dd1c089c26b3627e3bcdd5374ffc83f1e441", size = 1274558, upload-time = "2026-07-03T20:17:32.003Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/39ffb63ccd3753463dd3f57b6ea8d8be6f271932e0e0cb98971102bcfc85/aalink-0.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:c962b9281bb976d762a247b80e9d511c3374fe09e5647067c1a583f43ef90183", size = 384364, upload-time = "2026-07-03T20:17:33.613Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a0/423efb364463f10063f7b7e2fa6fdb218f7d0d3f1cb265b5cf024e8e9644/aalink-0.2.3-cp313-cp313-win_arm64.whl", hash = "sha256:08521a3c20f92a38bf34715a00f37f491d838dc72c56517bf9b9a09111f4ee17", size = 552169, upload-time = "2026-07-03T20:17:35.112Z" }, + { url = "https://files.pythonhosted.org/packages/93/6b/24f15086bc8f850e509cb55dfb284cf4fb4a17530608fee987a476aa46bd/aalink-0.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0256cb9009ee050f85504c40676195d65df4c86c1822b071023ddb3e5128dd1f", size = 205557, upload-time = "2026-07-03T20:17:36.584Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/e6818767a094abb2db294a02e572d52a9530db880067bf8bb368f427205a/aalink-0.2.3-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:fd0776925a07959ac736560ff9b101e93b7fa3a24d665f0059ec38400556c8b8", size = 221392, upload-time = "2026-07-03T20:17:37.939Z" }, + { url = "https://files.pythonhosted.org/packages/e0/41/cc66fd5755edf33f32304d9c41c021bba51a444ea0cf7f5e82c467e14449/aalink-0.2.3-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:00002d3a108259f60c6e5180aedaae877692cd69499cd0270fc98e631ed14054", size = 218539, upload-time = "2026-07-03T20:17:39.277Z" }, + { url = "https://files.pythonhosted.org/packages/03/08/918b4aee7cd29075c794a7cbb848b116dc95fb15c14e7f14de824d163fbe/aalink-0.2.3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5aab41f92502f67ac18acc15d9584707fc6440e2051179d5ab16a61ac020c825", size = 231055, upload-time = "2026-07-03T20:17:40.67Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/db938e91b54a7e95c8ebe2837f20789cf8195e73360388fec53dac14c520/aalink-0.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:99a9f671d266c1560a7c86ddd573aeda3bbbcbae08f4211ff81410df774393e1", size = 1211686, upload-time = "2026-07-03T20:17:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/8cb0621266cac04daa0da457f261a99892cd5e0b0711e0f861c1c403e5c5/aalink-0.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0b457a7d2fe6c100d746e9da846d568ba3d542076b40ecfdba36cda60c307915", size = 1274915, upload-time = "2026-07-03T20:17:43.606Z" }, + { url = "https://files.pythonhosted.org/packages/10/2c/71c4a8c31f677ea9a82ae98ed15ef43c24327a5ab4a94d95034ce6579474/aalink-0.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:5d806ed5f011c997718cb0cdc9e75bca028f29d40dcdecbb33a9134c99aa0114", size = 396857, upload-time = "2026-07-03T20:17:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/8014d4548c8bd48108ca0da6aa87c0810dacd7c0e879f29c0016e3369e0a/aalink-0.2.3-cp314-cp314-win_arm64.whl", hash = "sha256:4a81d6534594541ea13ac75d0d61d0b8690c4631a311136091ef863b2b72f8b1", size = 571475, upload-time = "2026-07-03T20:17:46.573Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/51374f9de098922128351b30e42fea8c0f86967acbbac7cc995b1d8daa60/aalink-0.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa7971fdc040788848e514169b196e818d57beb28bf3c2f59d2168776ee0e431", size = 211848, upload-time = "2026-07-03T20:17:48.028Z" }, + { url = "https://files.pythonhosted.org/packages/bf/05/27a98d6fc8c697689efba370facfbf5eed4ab7dc85ee22190e31f828b835/aalink-0.2.3-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:2ab91cd2be147380553559cc952cf930772763d4462738c982291cde1e418670", size = 227330, upload-time = "2026-07-03T20:17:49.398Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/44a00d4c31e09dbb711198082b1d9948e6c6d22d67f06ee663617de6cd50/aalink-0.2.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d022c015f487bca87f7b357386643a0b96d53b7f0fa03027f36ba73804faab39", size = 220210, upload-time = "2026-07-03T20:17:51.175Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/6439f67af7ed0a5906a4c7302f08cf966a4b5668ee19eb38079276b1fd7d/aalink-0.2.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:27c9093d8108e20b4e6f57f330faddefc30447cae0004bfad06a91755d09b7bc", size = 232433, upload-time = "2026-07-03T20:17:52.534Z" }, + { url = "https://files.pythonhosted.org/packages/37/93/469c4fef824c64620a56bf61d7611a906136e2dd14f1b96dc2267e66dcff/aalink-0.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56078c44f28e6c357570941a9d8735c8cc131e712be7c136db314ab4ae473f46", size = 1215496, upload-time = "2026-07-03T20:17:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/79dd0bc7af5cec0d07be20e1a6b4fdab5dd6aab802e29ce7982eb3f964a4/aalink-0.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:970767728875b98023e758df5ace28344a82bb6f3aff7064a29ae8bb785fb30c", size = 1277791, upload-time = "2026-07-03T20:17:55.622Z" }, + { url = "https://files.pythonhosted.org/packages/4c/30/5c2ff77636fc6e7d56c567a741a56ef9623ee50cb9b3a0c8d96c5fb8db2b/aalink-0.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:7fa1a7b886abbe35e789fb765147baa8b1aeb93e90755f4f425fba714681576b", size = 407582, upload-time = "2026-07-03T20:17:57.235Z" }, + { url = "https://files.pythonhosted.org/packages/81/8a/2e0979cbd4ae74094a3980e36c8eac93f05c53d0261c372b833e1de6559c/aalink-0.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:edc3d2c7a9fe589d8ded3481e3d4e6649c8a40910ad17132a6c1d0b84f98831b", size = 577392, upload-time = "2026-07-03T20:17:59.003Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "invoke" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "markdown2" +version = "2.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mido" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/14/cfda3fe61ce4c0f50a9f707ae02b46cb53211732b2cd4522bf06272848f4/mido-1.3.3.tar.gz", hash = "sha256:1aecb30b7f282404f17e43768cbf74a6a31bf22b3b783bdd117a1ce9d22cb74c", size = 124288, upload-time = "2024-10-25T15:05:21.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/28/45deb15c11859d2f10702b32e71de9328a9fa494f989626916db39a9617f/mido-1.3.3-py3-none-any.whl", hash = "sha256:01033c9b10b049e4436fca2762194ca839b09a4334091dd3c34e7f4ae674fd8a", size = 54614, upload-time = "2024-10-25T15:05:20.349Z" }, +] + +[[package]] +name = "mypy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/7e/be536678c6ae49ef058aba4b483d8c7bc104f471479016066f345bc1f5f8/mypy-2.2.0.tar.gz", hash = "sha256:2cdd99d48590dce6f6b7f1961eda75386364398fcdaad86923bc0f0231bf9baf", size = 3950939, upload-time = "2026-07-08T01:37:27.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f1/a818c375094e20a74ec51444cce6979cedd61d476f860c2f084ea8653bd2/mypy-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97d21f3a1582ef30d0b77f9ca3ad88b4263f3c5aea9e7380cb5d7175160aec7e", size = 14903842, upload-time = "2026-07-08T01:33:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/15/f332590488a8e4bb4a24036484c8bc750fe01fbef09701451a8790ac4c44/mypy-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2529017942b951cc43db2e2712b71cc3aa7e9d90567630c03ed63d430aeec61a", size = 13902986, upload-time = "2026-07-08T01:36:59.24Z" }, + { url = "https://files.pythonhosted.org/packages/38/9f/7ae37860922d264b536790a3307caa195eb8abb733f2e81608229bbb0f07/mypy-2.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0823d7f273f3b4a49df7573eac271ddbf77a0e5f53e24d294a4f0f77ad22e1d7", size = 14132163, upload-time = "2026-07-08T01:34:16.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6d/25c31c85a793340a4a9be593f8fe2cea08fd7b615f8b602b3f52f2d822f6/mypy-2.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d78a0fc90704894bc9948d78affad14b882b08183a7c30412d185748aaa9418b", size = 15070110, upload-time = "2026-07-08T01:34:44.933Z" }, + { url = "https://files.pythonhosted.org/packages/cb/67/7adf1caa143f6a0d9c564de24e0c1ed48340185110c3b918adc2f447192b/mypy-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6efab25fa3568569fda06928743382900860c48be9200c1758ef5ce94b532714", size = 15371554, upload-time = "2026-07-08T01:34:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/49/cb/ceca928213076f540d9b068c3655c52d43549f124c1f6dece5ecb747bbeb/mypy-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:daeaf5287091d68f674c91416cabab0f80f36e347ed17fded38d7ebde682e9d1", size = 11063618, upload-time = "2026-07-08T01:36:13.235Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/08caa177a4f2e0c44b96d5ad85f2754761b45bb6d65841451ef5dc9edb86/mypy-2.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:f8d97940a0259e09c219903579720b2df12170c0c3a3b3799837c1fb63deb44e", size = 10070720, upload-time = "2026-07-08T01:37:13.251Z" }, + { url = "https://files.pythonhosted.org/packages/96/2e/1ea8028583fef6561d146b51d738d91268201313ecf69e603e808a740e74/mypy-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea6fe1a30f3e7dc574d641497ffead2428046b0f8bc5804d13b4e4392fdc317b", size = 14739569, upload-time = "2026-07-08T01:35:33.202Z" }, + { url = "https://files.pythonhosted.org/packages/ba/67/c0607da57d78358b3b4fbfa90ee8ca5c6bd1dbb997c9648904ad4d8861d8/mypy-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6965829a6d847a0925f51149472bbeb7a39332fb4801972fdfd9cedd9f8d43a4", size = 13821442, upload-time = "2026-07-08T01:33:45.991Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/0407007d4ec7a762bac20724af1f0a57a9d860186c39e73105b6b8396fa2/mypy-2.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a906bfc9c4c5de3ece6dc4726f8076d56ce9be1720baf0c6f84e926c10262a4", size = 14049813, upload-time = "2026-07-08T01:35:44.282Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/d718e006c8fed4bece7a1bebeea6dcd05f31433af552fc45a82fef426379/mypy-2.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91df92625cb3452758f27f4965b000fb05ad89b00c282cc3430a7bd6b0e5389e", size = 14978473, upload-time = "2026-07-08T01:36:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/82/03/dae7299c45a84efb1590875f92652a5beb2da98a2cff9a567995e2583fe4/mypy-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d42893d15894fd34f395090e2d78315ba7c637d5ee221683e02893f578c2f548", size = 15224649, upload-time = "2026-07-08T01:35:15.988Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8e/738e4e73d030f20852a81383c74319ca4a9a4fdaadc3a75823588fe2c833/mypy-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3abaeec02cdc72e30a147d99eb81852b6b745a2c8d19607e25241d79b1abbce", size = 11049851, upload-time = "2026-07-08T01:34:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/6c58caedb3fa5831a9b179687cd7dc252c66a61dca4800e5ba19c8eff82b/mypy-2.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:c39bdb7ceab252d15011e26d3a254b4aaa3bbf121b551febfa301df9b0c69abe", size = 10060307, upload-time = "2026-07-08T01:33:21.852Z" }, + { url = "https://files.pythonhosted.org/packages/d1/be/fbaba7b0ee89874fb11668416dec9e5585c190b676b0796cff26a9290fe8/mypy-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:484a2be712b245ac6e89847141f1f50c612b0a924aa25917e63e6cfcf4da07cd", size = 14928025, upload-time = "2026-07-08T01:37:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8f/f79a7c5a76671b0f563d4beaa7d99fe90df4500d2c1d2ba1be0432121bcf/mypy-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb0a020dc68480d40e484675558ed637140df1ccbf896a81ba68bca85f2b50a0", size = 13793027, upload-time = "2026-07-08T01:34:33.937Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b9/3db0086bab611d34e26061b86189e6f71de6d22a9b81699a93b006eabcf6/mypy-2.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc361340732ce7108fa0308812caf02bb6868f16112f1efd35bcad88badf3327", size = 14108322, upload-time = "2026-07-08T01:36:08.316Z" }, + { url = "https://files.pythonhosted.org/packages/58/29/4f1e13979a848de2a0fd385462354b58358b6e8b3d9661663e308f6e3d5d/mypy-2.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0179a3a0b833f724a65f22613607cf7ea941ab17ec34fa283f8d6dfe21d9fa9", size = 15190198, upload-time = "2026-07-08T01:36:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/14/f7/7759f6294d9d25d86671957d0974a215a2a24d429526e26a2f603de951c5/mypy-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e0899b13da1e4ba44b880550f247402ce90ffecc71c54b220bcbe7ecb34f394", size = 15424222, upload-time = "2026-07-08T01:33:39.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1b/05b212bef4d2234b5f0b551ea53ce0680d8075b2e79861c765f70b590945/mypy-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:511320b17467402e2906130e185abffffa3d7648aff1444fc2abb61f4c8a087d", size = 11135191, upload-time = "2026-07-08T01:35:03.019Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/495e7122f6589948b36d3820a046461906756a0eb1b6dedc13ebfec7815e/mypy-2.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:7589f370b33dcdd95708f5340f13a67c2c49140957f934b42ef63064d343cca0", size = 10132502, upload-time = "2026-07-08T01:34:04.508Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5f/2d7a9ac5646274cd6e77ce3abcc2a9ece760c2b21f4c4b9f301711e07855/mypy-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6968f27347ef539c443ddfd6897e79db525ddb8c856aa8fbf14c34f310ca5193", size = 14931618, upload-time = "2026-07-08T01:33:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8a/1adaa7caaa104f87021b1ac71252d62e646e9b623d77900ac7a0ae252bf3/mypy-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3df226d2a0ae2c3b03845af217800a68e2965d4b14914c99b78d3a2c8ae23299", size = 13857718, upload-time = "2026-07-08T01:35:27.555Z" }, + { url = "https://files.pythonhosted.org/packages/1d/15/b11586b5aebbb82213e297fc30a6fcf3bed6a9deea3739cd8dd87621f3fd/mypy-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc53553996aca2094216ad9306a6f06c5265d206c1bcb54dd367560bd3557825", size = 14059704, upload-time = "2026-07-08T01:33:57.363Z" }, + { url = "https://files.pythonhosted.org/packages/03/db/071e05ab442596bdf7a845e830d5ef7128a0175281038245b171a6b16873/mypy-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:996abf2f0bebf572556c60720e8dc0cf5292b64060fa68d7f2bc9caa48e01b6f", size = 15128719, upload-time = "2026-07-08T01:33:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/c4b84eafb85ee315da72471523cc1bf7d7c42164085c42333601da7a8817/mypy-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ec287c2381898c652bf8ff79448627fe1a9ee76d22005181fac7315a485c7108", size = 15378692, upload-time = "2026-07-08T01:34:10.468Z" }, + { url = "https://files.pythonhosted.org/packages/68/a4/59a0ee94877fdfe2958cec9b6add72a75393063c79cb60ab4026dd5e10c2/mypy-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2c967a7685fa93fcf1a778b3ebe76e756b28ba14655f539d7b61ff3da69352a", size = 11150911, upload-time = "2026-07-08T01:35:21.603Z" }, + { url = "https://files.pythonhosted.org/packages/90/e4/6a9144be50180ed43d8c92de9b03dff504daa92b5bcc0353e8960799a23b/mypy-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:d59a4b80351ec92e5f7415fcdd008bd77fcbefc7adf9cbc7ffe4eb9f71617734", size = 10125389, upload-time = "2026-07-08T01:36:36.164Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/0aa8d8d197023ca6040f7b25a486cb47037b6350b0d3bae657c8f85fb43f/mypy-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1f6c3d76853071409ac58fc0aadfb276a22af5f190fdaa02152a858088a39ebd", size = 14926083, upload-time = "2026-07-08T01:36:02.365Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/35bbe0cb10e6699f90e988e537aaf4282a6c16e37f58848a242eb0a98bde/mypy-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bdaa177e80cc3292824d4ef3670b5b58771ee8d57c290e0c9c89e7968212332c", size = 13879985, upload-time = "2026-07-08T01:36:31.093Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0c/1597fbebd873e9b63452317740ae3dd32692cec5da180cc65acd96cd28cf/mypy-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80923e6d6e7878291f537ee11052f974954c20cb569798429a5dc265eb780b47", size = 14076883, upload-time = "2026-07-08T01:34:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/2ec021a83ec01b5d522639f78d8b36adade7fa4821db0f48fd6d82e861f3/mypy-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f24bd465a09077c8d64be8f19a6646db467a55490fd315fe7871afe6bb9645", size = 15103567, upload-time = "2026-07-08T01:36:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/53/3a/8cb3529f6d6800c7d069935e5c83a05d80263847b8a947cf6b0b16a9e958/mypy-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db34595464869f474708e769413d1d739fc33a69850f253757b9a4cc20bc1fec", size = 15354641, upload-time = "2026-07-08T01:36:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/320bc9a9553f8a9db5e847ec5ded762ef7ed7403c76c4ba2e8181c80e2f0/mypy-2.2.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b48092132c7b0ef4322773fecae62fc5b0bc339be348badeec8af502122a4a51", size = 7694355, upload-time = "2026-07-08T01:37:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/90/05/bf3b349e2f885cd3aab488111bb9049439c28bc028dac5073350d3df8fbe/mypy-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:6fc0e98b95e31755ca06d89f75fafa7820fbb3ea2caace6d83cba17625cd0acb", size = 11329146, upload-time = "2026-07-08T01:35:38.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/558b06e6cfe17ab88bb38f7b370b6bc68a74ba177c9e138db9748e422d2d/mypy-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:bc73a5b4d40e8a3e6b12ef82eb0c90430964e34016a36c2aff4e3bfe37ba41f0", size = 10316586, upload-time = "2026-07-08T01:36:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/0b/21/f0b96f19a9b8ba111a45ffbe9508e818b7f6990469b38f6888943f7bfd3a/mypy-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6257bd4b4c0ae2148548b869a1ff3758e38645b92c8fe65eca401866c3c551c1", size = 15922565, upload-time = "2026-07-08T01:35:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/f2/1dbcb20b0865d5e992541450a8c73f2fcc90f8bd7d8a4b81313e16934870/mypy-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dfa22b3ae862ac1ce76f5976ddd402651b5f090bcfd49c6d0484b8983a29eaf8", size = 14816515, upload-time = "2026-07-08T01:34:58.167Z" }, + { url = "https://files.pythonhosted.org/packages/84/a2/18cce9c7d5b4d14010d1f13836da11b234dda917b17ca8671fc32c136997/mypy-2.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a430bf26fe8cf372f3933fbd83e633d6561868803645a20e4e6d4523f52a3b", size = 15272246, upload-time = "2026-07-08T01:37:08.72Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/24d720c7924829bd675cbde2d0fa779f50abf676ca617f53d6a8bfef5fa7/mypy-2.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d58655a60e823b1a4c9ebcda072897fb0193c2f3e6f8e7c433e152aa4cb00233", size = 16226295, upload-time = "2026-07-08T01:34:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/b7/5e/785730990fc863ad8340b4ab44ac4ca23270aecff92c180ccdf27f9f5869/mypy-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d4452c955caf14e28bb046cbd0c3671272e6381630a8b81b0da9713558148890", size = 16493275, upload-time = "2026-07-08T01:35:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/33/55b1edf16f639f153972380d6977b81f65509c5b8f9c86b58b94b7990b03/mypy-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:68f5b7f7f755200f68c7181e3dfb28be9858162257690e539759c9f57721e388", size = 11749038, upload-time = "2026-07-08T01:35:50.071Z" }, + { url = "https://files.pythonhosted.org/packages/61/36/67424748a4e65e97f0e05bf00df379dfb6c2d817f82cc3a4ce5c96d99beb/mypy-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:78d201accfafce3801d978f2b8dbbd473a9ce364cc0a0dfd9192fe47d977e129", size = 10704254, upload-time = "2026-07-08T01:37:17.971Z" }, + { url = "https://files.pythonhosted.org/packages/28/cb/142c2097ca02c0d295b00625ff946808bdda65acda17d163c680d8a6a474/mypy-2.2.0-py3-none-any.whl", hash = "sha256:ecc138da861e932d1344214da4bae866b21900a9c2778824b51fe2fb47f5180e", size = 2726094, upload-time = "2026-07-08T01:34:00.075Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pdoc" +version = "16.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown2" }, + { name = "markupsafe" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/fe/ab3f34a5fb08c6b698439a2c2643caf8fef0d61a86dd3fdcd5501c670ab8/pdoc-16.0.0.tar.gz", hash = "sha256:fdadc40cc717ec53919e3cd720390d4e3bcd40405cb51c4918c119447f913514", size = 111890, upload-time = "2025-10-27T16:02:16.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/a1/56a17b7f9e18c2bb8df73f3833345d97083b344708b97bab148fdd7e0b82/pdoc-16.0.0-py3-none-any.whl", hash = "sha256:070b51de2743b9b1a4e0ab193a06c9e6c12cf4151cf9137656eebb16e8556628", size = 100014, upload-time = "2025-10-27T16:02:15.007Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymididefs" +version = "0.2.3" +source = { git = "https://github.com/simonholliday/PyMidiDefs.git?rev=v0.2.3#68cedd11b17ff40bcc8a47a42a03bd9301b974f7" } + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-osc" +version = "1.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/094503dc039c8ba9cead01f4b5e163d795a6a502563d3a31f35d20a1ecf8/python_osc-1.10.2.tar.gz", hash = "sha256:732548f4f467dda9f890a6b0b3bfb085ab117f4bd38a45a58d2ca0f3bd6a546d", size = 33716, upload-time = "2026-04-02T20:46:04.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/a2/ce26e437e36707f0da839dd88b072413825ea8986b73ce7b5c4f3cd1b7f4/python_osc-1.10.2-py3-none-any.whl", hash = "sha256:018b28e1cc06427c2c3d695f4e8d87d0caecfe604ff889acc45235cfd94183a2", size = 45467, upload-time = "2026-04-02T20:46:03.718Z" }, +] + +[[package]] +name = "python-rtmidi" +version = "1.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/ee/0f91965dcc471714c69df21e5ca3d94dc81411b7dee2d31ff1184bea07c9/python_rtmidi-1.5.8.tar.gz", hash = "sha256:7f9ade68b068ae09000ecb562ae9521da3a234361ad5449e83fc734544d004fa", size = 368130, upload-time = "2023-11-20T21:55:02.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/9c/95c0a6a43bd24a17568e1e31008b1fab7e9a2e54c0ed7301e8d5cc9fa109/python_rtmidi-1.5.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efc07413b30b0039c0d35abe25a81d740c7405124eb58eed141a8f24388e6fe0", size = 148826, upload-time = "2023-11-20T21:54:20.658Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/8e452d6edc2c04e3407f542d3185c66ffc2d39c8811cf2b117653a0a4d63/python_rtmidi-1.5.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:844bd12840c9d4e03dfc89b2cd57c55dcbf5ed7246504d69c6c661732249b19c", size = 145363, upload-time = "2023-11-20T21:54:23.023Z" }, + { url = "https://files.pythonhosted.org/packages/b5/48/aa1d4924f7aa238a192d69aa565b315af0037f684c9475e8b860c679a655/python_rtmidi-1.5.8-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:8bbaf7c7164471712a93ac60c8f9ed146b336a294a5103223bbaf8f10709a0bf", size = 293253, upload-time = "2023-11-20T21:54:24.899Z" }, + { url = "https://files.pythonhosted.org/packages/bf/24/32dc239047a56f44d8d8090d55010f85a38ed959ffe517c2e87a2aa34190/python_rtmidi-1.5.8-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:878ce085dfb65c0974810a7e919f73708cbb4c0430c7924b78f25aea1dd4ebee", size = 304051, upload-time = "2023-11-20T21:54:26.82Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0c/cf771eca1b64610e627ca1e67be8390ecdf5e0e1914efbdd9d50ac4c5986/python_rtmidi-1.5.8-cp310-cp310-win_amd64.whl", hash = "sha256:f2138005c6bd3d8b9af05df383679f6d0827d16056e68a941110732310dcb7dd", size = 132157, upload-time = "2023-11-20T21:54:30.059Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0c/23be16b75c90946784b8d233e61db14cf0482def5396821a1ae0bdcd2739/python_rtmidi-1.5.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30d117193dcad8af67c600c405f53eb096e4ff84849760be14c97270af334922", size = 150205, upload-time = "2023-11-20T21:54:31.849Z" }, + { url = "https://files.pythonhosted.org/packages/4b/12/37d41151b08a292719f05dbeae15475537f8aa291cda34c6634b35916dff/python_rtmidi-1.5.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e234dca7f9d783dd3f1e9c9c5c2f295f02b7af3085301d6eed3b428cf49d327", size = 146737, upload-time = "2023-11-20T21:54:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/b866491545135c699bfbed62f54b93c4d6587afc2bba6e2cbbe898570c32/python_rtmidi-1.5.8-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:271d625c489fffb39b3edc5aba67f7c8e29a04a0a0f056ce19e5a888a08b4c59", size = 294847, upload-time = "2023-11-20T21:54:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/a0/79/1ddb4fb1bdb1a8b8bd62007ca4980344a53b7f29633a7bca1088eed964ce/python_rtmidi-1.5.8-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:46bbf32c8a4bf6c8f0df1c02a68689d0757f13cb7a69f27ccbbed3d7b2365918", size = 305433, upload-time = "2023-11-20T21:54:36.322Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/2c55797dbf020d462132d1bc5b34d596b400fa197e2a259b8dd2ea2e5954/python_rtmidi-1.5.8-cp311-cp311-win_amd64.whl", hash = "sha256:cfea32c91752fa7aecfe3d6827535c190ba0e646a9accd6604f4fc70cf4b780f", size = 132937, upload-time = "2023-11-20T21:54:38.3Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/887b0378e0a907489a07bdeb808fa5ed349675245c6ee14d9f6d00304f96/python_rtmidi-1.5.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5443634597eb340cdec0734f76267a827c2d366f00a6f9195141c78828016ac2", size = 158861, upload-time = "2023-11-20T21:54:39.549Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/57cecde253daab896ce53778520cd41eb062641862ebdb0ee6f97511b1d9/python_rtmidi-1.5.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29d9c9d9f82ce679fecad7bb4cb79f3a24574ea84600e377194b4cc1baacec0e", size = 153416, upload-time = "2023-11-20T21:54:40.835Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5b/dc19c53d9d512b74dc2cca3725591cc612b9465645695a0696352a8c8b54/python_rtmidi-1.5.8-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:25f5a5db7be98911c41ca5bebb262fcf9a7c89600b88fd3c207ceafd3101e721", size = 305696, upload-time = "2023-11-20T21:54:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/f6/92/5a60f56dfb2740e644e932233928947423cd2101895319b331f84527eb31/python_rtmidi-1.5.8-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cec30924e305f55284594ccf35a71dee7216fd308dfa2dec1b3ed03e6f243803", size = 315579, upload-time = "2023-11-20T21:54:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/93/46/6af077d262f521ea2bf1ab60b8aad72f34fe6dd55af739176605369d449c/python_rtmidi-1.5.8-cp312-cp312-win_amd64.whl", hash = "sha256:052c89933cae4fca354012d8ca7248f4f9e1e3f062471409d48415a7f7d7e59e", size = 129755, upload-time = "2023-11-20T21:54:44.935Z" }, +] + +[[package]] +name = "subsequence" +source = { editable = "." } +dependencies = [ + { name = "mido" }, + { name = "pymididefs" }, + { name = "python-osc" }, + { name = "python-rtmidi" }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, +] +docs = [ + { name = "pdoc" }, +] +link = [ + { name = "aalink" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.dev-dependencies] +dev = [ + { name = "invoke" }, +] + +[package.metadata] +requires-dist = [ + { name = "aalink", marker = "extra == 'link'", specifier = ">=0.1.1" }, + { name = "mido" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "pdoc", marker = "extra == 'docs'", specifier = ">=14.0.0" }, + { name = "pymididefs", git = "https://github.com/simonholliday/PyMidiDefs.git?rev=v0.2.3" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "python-osc" }, + { name = "python-rtmidi" }, + { name = "websockets" }, +] +provides-extras = ["test", "docs", "dev", "link"] + +[package.metadata.requires-dev] +dev = [{ name = "invoke", specifier = ">=3.0.3" }] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/31/cd11d2796b95c93645bac8e396b0f4bac0896a07a7b87d473bfc359f02c3/websockets-16.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de72a9c611178b15557d98eabd3101c9663c4d68938510478a6d162f99afd213", size = 179772, upload-time = "2026-07-10T06:30:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/34306d802f9b599eab041688a2086318037560cfae616a860234cca575b6/websockets-16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:37b0e4d726ffea3776670092d3d13e1cb605076f036a695fd1259de0d9b9fe02", size = 177457, upload-time = "2026-07-10T06:30:24.636Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/36ebbb978a7af70ff952afe5b22561264967164e9ad68b6734cae94efeb4/websockets-16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:00d50c0a27098fcb7ab47b3d99a1b1159b534dbcd959fbf05113ebc37e5f927b", size = 177737, upload-time = "2026-07-10T06:30:25.954Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/944f341d0d3c0450ffd3d171479531df1818cb1df1623af4065113999c44/websockets-16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1acb698bff1da1782b31aebd8d7a24d7d05453964abcd7d03dbf6e25893908e8", size = 186244, upload-time = "2026-07-10T06:30:27.235Z" }, + { url = "https://files.pythonhosted.org/packages/32/e5/a9b98fc49ef0214718a9c839c6c63856a921877256ec46f371be32decfa8/websockets-16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc2c453f3b5f99c56b16e233aad5299860558487d26adb2ed27a00c14ca24b8c", size = 187484, upload-time = "2026-07-10T06:30:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7a/a575b52ca090b1976ffbe4b5f0762d03f399dfcb48eab883101331be71a9/websockets-16.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1a9f08a0728b0835f1c6abe1d9b746ab3de49b7336a0e1919cf96be1e76273eb", size = 190143, upload-time = "2026-07-10T06:30:29.91Z" }, + { url = "https://files.pythonhosted.org/packages/7c/40/705fbbd5677242fd36f724e9a94103e6bbdcb7d71e8f4498bfc1a8a7d413/websockets-16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a089979d6173b27af18026c8d8b0077f83669a9169174482c4651e9f5739a5b6", size = 188004, upload-time = "2026-07-10T06:30:31.357Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0c/58227c8d66b1c4060c53bac8e066fb4fe2603060408e934f48660a448d72/websockets-16.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3c18dba232ec2b92a68579c9fed8ff5a18f853d1e09fc0b6ca3159e94f689fe", size = 186689, upload-time = "2026-07-10T06:30:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d0/5c1314782594aa347e0f18808ee277a61986a2a2f9f470df9893183995bd/websockets-16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c1eb7df4170d5068892a8834fb5c07b9552353deb0dbeb0bff3820481ae4792", size = 184559, upload-time = "2026-07-10T06:30:34.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e6/109c6f16850fd674b7e3d0e58b8987f05d3881abaa25f42a9faf5e85f097/websockets-16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c522bd48e625b6d557aa228967258d6d3da031c4cc21d3352fb302479aa9ba0a", size = 186997, upload-time = "2026-07-10T06:30:35.397Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ec/6afa1aebc59426438b85cf7a3868c53a89005e2250a648c99e99943b90a4/websockets-16.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d106396927a7f00b0f3a69215c3357f87bf0bca6844247121f7e8291e826a3b1", size = 185621, upload-time = "2026-07-10T06:30:36.88Z" }, + { url = "https://files.pythonhosted.org/packages/27/24/c038fe8682e9345bfa422d2cc5cc68b0491ab942c92e176bf8dfa6e8331f/websockets-16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d71bed12909b8039955536e192867d02d76cd3797cedfd0facf822e7668636c3", size = 187384, upload-time = "2026-07-10T06:30:38.096Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/dc0ef2be39c67394e24bc982a0af59cd6249bf2f4e4272813c5c505d0da9/websockets-16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9c1cf6f9a936b030b5bed0e800c5ee32069338129084546baf5ff5014dc62fa9", size = 185258, upload-time = "2026-07-10T06:30:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/3ffecd83ca3404b41fbdf8e9b178e55b529cd59bf64ea08b5a37b616b568/websockets-16.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3fd3e6a7af2c8fcdcf4ffbeaf7f54a567b91a83267204187797f31faaa2a4efa", size = 186050, upload-time = "2026-07-10T06:30:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/75/26/2e068497c78f31591a610ab7ef6d8d383ecadbe98f9121e1ebda77ef6d2b/websockets-16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dddd27175bf640acae5561fa79b77e8ec71fc445816200523e5c19b6a556fb72", size = 186273, upload-time = "2026-07-10T06:30:42.309Z" }, + { url = "https://files.pythonhosted.org/packages/44/ab/4dc049cb2c9e1be3a2c6fef77118f9c5049979e99cd56a97759d2f40f980/websockets-16.1-cp310-cp310-win32.whl", hash = "sha256:cce36c80b3f2fede7942f1756d3d885fa6fa086766c8c1bcf00695ab80f0d51a", size = 180157, upload-time = "2026-07-10T06:30:43.565Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/5e010ce5f66a8e5df380843f704ada508195a021c0c8a0f933639c9ee1c0/websockets-16.1-cp310-cp310-win_amd64.whl", hash = "sha256:115fc4695b94bb855995b23fb1abcb66099a5995575d3d5bc5605a616c58d0eb", size = 180458, upload-time = "2026-07-10T06:30:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +]