Files
kennethreitz dc70eab069 Fix live export crash + cabinet dropped in two render paths; release 0.57.8
- LiveEngine.export_recording() referenced an undefined NOTE_NAMES and
  crashed with NameError on every call. Define NOTE_NAMES in live.py.
- The cabinet (amp-sim) effect was applied for plain melodic parts but
  silently skipped for (a) parts with automation points and (b) drum
  parts — both effect-presence checks omitted it. Add cabinet to both.

Both are the same "a code path handles only part of the structure" class
as the recent MIDI/render/notation fixes.

New tests: export_recording roundtrip, cabinet-on-drums, cabinet-with-automation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:44:13 -04:00

126 lines
4.2 KiB
Python

import pytest
import numpy
import pytheory
from pytheory import Tone, TonedScale, Fretboard, Chord, Key, Note, TET
from pytheory.charts import CHARTS, NamedChord, charts_for_fretboard, QUALITIES
from pytheory.systems import System, SYSTEMS
from pytheory.rhythm import Duration, TimeSignature, Note as RhythmNote, Rest, Score
from _util import (HAS_PORTAUDIO, needs_portaudio, _write_test_wav,
_roundtrip_melody, _render_test_mix, _chords_roundtrip,
_chord_buffer, _progression_score)
def test_live_percussive_instrument_stays_one_shot():
from pytheory.live import _Channel
sr = 44100
ch = _Channel(synth_name="piano", envelope_name="piano", volume=0.5)
ch.note_on(60, 100)
assert ch.voices[0].loop_end is None
for _ in range(int(4.0 * sr / 512)):
last = ch.render_stereo(512)
assert len(ch.voices) == 0 # decayed and cleaned up
assert numpy.abs(last).max() == 0.0
def test_live_note_off_releases_looping_voice():
from pytheory.live import _Channel
ch = _Channel(synth_name="organ", envelope_name="organ")
ch.note_on(60, 100)
for _ in range(10):
ch.render_stereo(512)
ch.note_off(60)
for _ in range(20):
tail = ch.render_stereo(512)
assert len(ch.voices) == 0
assert numpy.abs(tail).max() == 0.0
def test_live_cc_does_not_clear_cache_for_bus_params():
from pytheory.live import LiveEngine
engine = LiveEngine()
engine.channel(1, instrument="electric_piano")
ch = engine.channels[1]
ch._get_wave(60, 44100 * 3)
assert len(ch._cache) == 1
engine.cc(11, "lowpass", min_val=200, max_val=8000)
engine._apply_cc(1, 11, 64)
assert len(ch._cache) == 1 # no re-render needed
assert ch.lowpass > 200
engine.cc(12, "detune", min_val=0, max_val=20)
engine._apply_cc(1, 12, 64)
assert len(ch._cache) == 0 # baked param → cache cleared
def test_live_engine_link_sync():
pytest.importorskip("link")
import time
from pytheory.live import LiveEngine
e = LiveEngine()
e.channel(1, instrument="electric_piano")
e.drums("rock", volume=0.5)
e.enable_link(quantum=4)
try:
state = e._link.captureSessionState()
now = e._link.clock().micros()
state.setIsPlaying(True, now)
state.setTempo(140, now)
e._link.commitSessionState(state)
time.sleep(0.05)
hits = []
e._drum_channel.note_on = lambda n, v: hits.append(n)
for _ in range(120):
e._on_link_audio(512)
time.sleep(0.005)
assert abs(e._bpm - 140) < 0.5 # tempo followed the session
assert hits # drums fired on the Link grid
finally:
e.stop()
assert e._link is None
def test_live_engine_link_stopped_transport_is_silent():
pytest.importorskip("link")
import time
from pytheory.live import LiveEngine
e = LiveEngine()
e.channel(1, instrument="electric_piano")
e.drums("rock", volume=0.5)
e.enable_link(quantum=4)
try:
state = e._link.captureSessionState()
state.setIsPlaying(False, e._link.clock().micros())
e._link.commitSessionState(state)
time.sleep(0.05)
hits = []
e._drum_channel.note_on = lambda n, v: hits.append(n)
for _ in range(40):
e._on_link_audio(512)
time.sleep(0.002)
assert hits == []
finally:
e.stop()
def test_export_recording_does_not_crash():
"""export_recording() must convert MIDI numbers to note names without a
NameError (NOTE_NAMES was undefined in live.py)."""
from pytheory.live import LiveEngine
e = LiveEngine()
# (timestamp, channel, midi_note, velocity, is_on)
e._record_events = [
(0.0, 1, 60, 100, True), (0.5, 1, 60, 0, False), # C4
(0.5, 1, 64, 90, True), (1.0, 1, 64, 0, False), # E4
(1.0, 1, 67, 80, True), (1.5, 1, 67, 0, False), # G4
]
e._bpm = 120
score = e.export_recording() # no filename -> returns Score
names = [t.name for p in score.parts.values()
for n in p.notes if n.tone is not None for t in [n.tone]]
assert names == ["C", "E", "G"]