1643 lines
60 KiB
Python
1643 lines
60 KiB
Python
"""Tests for the backing track player module.
|
||
|
||
Covers: types, audio loader, playlist manager, transport,
|
||
metronome, and the main backing track player orchestrator.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import json
|
||
import struct
|
||
import tempfile
|
||
import wave
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from src.backing.types import (
|
||
PlayMode,
|
||
TrackState,
|
||
Track,
|
||
Playlist,
|
||
BackingPlayerConfig,
|
||
)
|
||
from src.backing.loader import AudioLoader, AudioData, load_audio, SUPPORTED_EXTENSIONS
|
||
from src.backing.playlist import PlaylistManager
|
||
from src.backing.transport import JACKTransport, TransportState, JACKTransportState
|
||
from src.backing.metronome import Metronome, ClickSound
|
||
from src.backing.player import BackingTrackPlayer
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Test helpers
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _make_wav(
|
||
duration: float = 1.0,
|
||
sample_rate: int = 48000,
|
||
channels: int = 1,
|
||
frequency: float = 440.0,
|
||
) -> bytes:
|
||
"""Generate a WAV file in memory as bytes."""
|
||
n_samples = int(duration * sample_rate)
|
||
t = np.linspace(0, duration, n_samples, endpoint=False, dtype=np.float32)
|
||
|
||
if channels == 1:
|
||
samples = (np.sin(2 * np.pi * frequency * t) * 0.5).astype(np.float32)
|
||
else:
|
||
left = (np.sin(2 * np.pi * frequency * t) * 0.5).astype(np.float32)
|
||
right = (np.sin(2 * np.pi * (frequency * 1.5) * t) * 0.5).astype(np.float32)
|
||
samples = np.column_stack([left, right])
|
||
|
||
# Convert to int16 for WAV
|
||
int_samples = (samples * 32767).astype(np.int16)
|
||
|
||
buf = io.BytesIO()
|
||
with wave.open(buf, "wb") as wf:
|
||
wf.setnchannels(channels)
|
||
wf.setsampwidth(2) # 16-bit
|
||
wf.setframerate(sample_rate)
|
||
wf.writeframes(int_samples.tobytes())
|
||
|
||
return buf.getvalue()
|
||
|
||
|
||
def _write_temp_wav(
|
||
duration: float = 1.0,
|
||
sample_rate: int = 48000,
|
||
channels: int = 1,
|
||
frequency: float = 440.0,
|
||
suffix: str = ".wav",
|
||
) -> Path:
|
||
"""Write a temporary WAV file and return its path."""
|
||
data = _make_wav(duration, sample_rate, channels, frequency)
|
||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||
tmp.write(data)
|
||
tmp.close()
|
||
return Path(tmp.name)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Types tests
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestPlayMode:
|
||
def test_enum_values(self):
|
||
assert PlayMode.ONE_SHOT == "one_shot"
|
||
assert PlayMode.LOOP == "loop"
|
||
assert PlayMode.SEGUE == "segue"
|
||
|
||
def test_str_enum_coercion(self):
|
||
assert PlayMode("loop") == PlayMode.LOOP
|
||
assert PlayMode("one_shot") == PlayMode.ONE_SHOT
|
||
|
||
|
||
class TestTrackState:
|
||
def test_enum_values(self):
|
||
assert TrackState.IDLE == "idle"
|
||
assert TrackState.PLAYING == "playing"
|
||
assert TrackState.PAUSED == "paused"
|
||
assert TrackState.STOPPED == "stopped"
|
||
assert TrackState.FINISHED == "finished"
|
||
assert TrackState.ERROR == "error"
|
||
|
||
|
||
class TestTrack:
|
||
def test_defaults(self):
|
||
t = Track(id="t1")
|
||
assert t.id == "t1"
|
||
assert t.title == ""
|
||
assert t.volume == 1.0
|
||
assert t.pan == 0.5
|
||
assert t.muted is False
|
||
assert t.soloed is False
|
||
assert t.play_mode == PlayMode.ONE_SHOT
|
||
assert t.state == TrackState.IDLE
|
||
assert t.duration == 0.0
|
||
assert t.loaded is False
|
||
|
||
def test_file_name(self):
|
||
t = Track(id="t1", file_path="/music/song.wav")
|
||
assert t.file_name == "song.wav"
|
||
|
||
def test_file_name_empty(self):
|
||
t = Track(id="t2")
|
||
assert t.file_name == ""
|
||
|
||
def test_has_loop_region(self):
|
||
t = Track(id="t1", loop_start=1.0, loop_end=5.0)
|
||
assert t.has_loop_region is True
|
||
|
||
def test_no_loop_region(self):
|
||
t = Track(id="t1", loop_start=1.0, loop_end=0.0)
|
||
assert t.has_loop_region is False
|
||
|
||
def test_effective_loop_end(self):
|
||
t = Track(id="t1", duration=60.0, loop_end=30.0)
|
||
assert t.effective_loop_end == 30.0
|
||
|
||
def test_effective_loop_end_no_set(self):
|
||
t = Track(id="t1", duration=60.0, loop_end=0.0)
|
||
assert t.effective_loop_end == 60.0
|
||
|
||
|
||
class TestPlaylist:
|
||
def test_empty(self):
|
||
pl = Playlist(name="Test")
|
||
assert pl.name == "Test"
|
||
assert pl.track_count == 0
|
||
assert pl.current_track is None
|
||
|
||
def test_add_tracks(self):
|
||
pl = Playlist(name="Setlist")
|
||
pl.tracks = [
|
||
Track(id="t0", title="Song A"),
|
||
Track(id="t1", title="Song B"),
|
||
Track(id="t2", title="Song C"),
|
||
]
|
||
assert pl.track_count == 3
|
||
|
||
def test_current_track(self):
|
||
pl = Playlist(name="Setlist", current_index=1)
|
||
pl.tracks = [Track(id="t0"), Track(id="t1"), Track(id="t2")]
|
||
assert pl.current_track.id == "t1"
|
||
|
||
def test_current_track_none(self):
|
||
pl = Playlist(name="Empty", current_index=-1)
|
||
assert pl.current_track is None
|
||
|
||
def test_next_track(self):
|
||
pl = Playlist(name="Setlist", current_index=0)
|
||
pl.tracks = [Track(id="t0"), Track(id="t1"), Track(id="t2")]
|
||
n = pl.next_track()
|
||
assert n.id == "t1"
|
||
assert pl.current_index == 1
|
||
|
||
def test_next_track_at_end(self):
|
||
pl = Playlist(name="Setlist", current_index=2)
|
||
pl.tracks = [Track(id="t0"), Track(id="t1"), Track(id="t2")]
|
||
assert pl.next_track() is None
|
||
|
||
def test_prev_track(self):
|
||
pl = Playlist(name="Setlist", current_index=1)
|
||
pl.tracks = [Track(id="t0"), Track(id="t1"), Track(id="t2")]
|
||
p = pl.prev_track()
|
||
assert p.id == "t0"
|
||
assert pl.current_index == 0
|
||
|
||
def test_prev_track_at_start(self):
|
||
pl = Playlist(name="Setlist", current_index=0)
|
||
pl.tracks = [Track(id="t0"), Track(id="t1")]
|
||
assert pl.prev_track() is None
|
||
|
||
def test_get_track(self):
|
||
pl = Playlist(name="Setlist")
|
||
pl.tracks = [Track(id="t0"), Track(id="t1")]
|
||
assert pl.get_track(0).id == "t0"
|
||
assert pl.get_track(1).id == "t1"
|
||
assert pl.get_track(2) is None
|
||
assert pl.get_track(-1) is None
|
||
|
||
def test_total_duration(self):
|
||
pl = Playlist(name="Dur")
|
||
pl.tracks = [
|
||
Track(id="t0", duration=10.0),
|
||
Track(id="t1", duration=20.0),
|
||
Track(id="t2", duration=5.0),
|
||
]
|
||
assert pl.total_duration == 35.0
|
||
|
||
|
||
class TestBackingPlayerConfig:
|
||
def test_defaults(self):
|
||
cfg = BackingPlayerConfig()
|
||
assert cfg.sample_rate == 48000
|
||
assert cfg.buffer_size == 256
|
||
assert cfg.channels == 2
|
||
assert cfg.jack_client_name == "pi_mixer_backing"
|
||
assert cfg.jack_transport_enabled is True
|
||
assert cfg.metronome_enabled is True
|
||
assert cfg.count_in_bars == 2
|
||
assert cfg.time_signature_num == 4
|
||
assert cfg.time_signature_den == 4
|
||
assert cfg.auto_advance is True
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# AudioLoader tests
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestAudioLoader:
|
||
def test_load_wav_mono(self):
|
||
"""Load a mono WAV file."""
|
||
wav = _write_temp_wav(duration=1.0, sample_rate=44100, channels=1)
|
||
try:
|
||
audio = AudioLoader.load(wav)
|
||
assert isinstance(audio, AudioData)
|
||
assert audio.sample_rate == 44100
|
||
assert audio.channels == 1
|
||
assert abs(audio.duration - 1.0) < 0.05
|
||
assert audio.num_frames > 0
|
||
assert audio.samples.ndim == 1
|
||
assert audio.samples.dtype == np.float32
|
||
assert not audio.is_stereo
|
||
finally:
|
||
wav.unlink()
|
||
|
||
def test_load_wav_stereo(self):
|
||
"""Load a stereo WAV file."""
|
||
wav = _write_temp_wav(duration=0.5, sample_rate=48000, channels=2)
|
||
try:
|
||
audio = AudioLoader.load(wav)
|
||
assert audio.channels == 2
|
||
assert audio.is_stereo
|
||
assert audio.samples.ndim == 2
|
||
assert audio.samples.shape[1] == 2
|
||
finally:
|
||
wav.unlink()
|
||
|
||
def test_load_nonexistent_file(self):
|
||
"""Loading a nonexistent file raises FileNotFoundError."""
|
||
with pytest.raises(FileNotFoundError):
|
||
AudioLoader.load("/tmp/nonexistent_file_xyz123.wav")
|
||
|
||
def test_load_unsupported_format(self):
|
||
"""Loading an unsupported format raises ValueError."""
|
||
tmp = tempfile.NamedTemporaryFile(suffix=".xyz", delete=False)
|
||
tmp.close()
|
||
try:
|
||
with pytest.raises(ValueError, match="Unsupported audio format"):
|
||
AudioLoader.load(tmp.name)
|
||
finally:
|
||
Path(tmp.name).unlink()
|
||
|
||
def test_probe_wav(self):
|
||
"""Probe WAV metadata without loading samples."""
|
||
wav = _write_temp_wav(duration=2.0, sample_rate=44100, channels=1)
|
||
try:
|
||
info = AudioLoader.probe(wav)
|
||
assert info["sample_rate"] == 44100
|
||
assert info["channels"] == 1
|
||
assert abs(info["duration"] - 2.0) < 0.05
|
||
finally:
|
||
wav.unlink()
|
||
|
||
def test_to_mono(self):
|
||
"""Stereo → mono mixdown."""
|
||
wav = _write_temp_wav(duration=0.2, sample_rate=48000, channels=2)
|
||
try:
|
||
audio = AudioLoader.load(wav)
|
||
mono = audio.to_mono()
|
||
assert mono.ndim == 1
|
||
assert len(mono) == audio.num_frames
|
||
finally:
|
||
wav.unlink()
|
||
|
||
def test_load_with_resample(self):
|
||
"""Load and resample to a target rate."""
|
||
wav = _write_temp_wav(duration=0.5, sample_rate=22050, channels=1)
|
||
try:
|
||
audio = AudioLoader.load(wav, target_sr=44100)
|
||
assert audio.sample_rate == 44100
|
||
assert audio.num_frames > 0
|
||
finally:
|
||
wav.unlink()
|
||
|
||
def test_audio_data_properties(self):
|
||
"""AudioData properties work correctly."""
|
||
samples = np.zeros((1000,), dtype=np.float32)
|
||
ad = AudioData(
|
||
samples=samples,
|
||
sample_rate=48000,
|
||
channels=1,
|
||
duration=1000 / 48000,
|
||
file_path="/tmp/test.wav",
|
||
format_name="WAV",
|
||
)
|
||
assert ad.num_frames == 1000
|
||
assert not ad.is_stereo
|
||
|
||
def test_convenience_load_audio(self):
|
||
"""load_audio convenience function works."""
|
||
wav = _write_temp_wav(duration=0.1, sample_rate=44100, channels=1)
|
||
try:
|
||
audio = load_audio(wav)
|
||
assert isinstance(audio, AudioData)
|
||
finally:
|
||
wav.unlink()
|
||
|
||
def test_supported_extensions(self):
|
||
"""All required formats are in supported extensions."""
|
||
required = {".wav", ".flac", ".mp3", ".aiff", ".aif"}
|
||
assert required.issubset(SUPPORTED_EXTENSIONS)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# PlaylistManager tests
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestPlaylistManager:
|
||
def test_create_and_get(self):
|
||
pm = PlaylistManager()
|
||
pl = pm.create("My Set")
|
||
assert pl.name == "My Set"
|
||
assert pm.get("My Set") is pl
|
||
assert pm.active is pl
|
||
|
||
def test_list_names(self):
|
||
pm = PlaylistManager()
|
||
pm.create("A")
|
||
pm.create("B")
|
||
assert set(pm.list_names()) == {"A", "B"}
|
||
|
||
def test_delete(self):
|
||
pm = PlaylistManager()
|
||
pm.create("Keep")
|
||
pm.create("Delete")
|
||
assert pm.delete("Delete") is True
|
||
assert pm.get("Delete") is None
|
||
assert pm.get("Keep") is not None
|
||
|
||
def test_delete_nonexistent(self):
|
||
pm = PlaylistManager()
|
||
assert pm.delete("Nope") is False
|
||
|
||
def test_delete_active(self):
|
||
pm = PlaylistManager()
|
||
pm.create("Active")
|
||
assert pm.delete("Active") is False # Can't delete active
|
||
|
||
def test_set_active(self):
|
||
pm = PlaylistManager()
|
||
pm.create("First")
|
||
pm.create("Second")
|
||
assert pm.active.name == "First"
|
||
pm.set_active("Second")
|
||
assert pm.active.name == "Second"
|
||
|
||
def test_set_active_nonexistent(self):
|
||
pm = PlaylistManager()
|
||
assert pm.set_active("Nope") is False
|
||
|
||
def test_add_track(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
tid = pm.add_track("/music/song.wav", title="Song 1")
|
||
assert tid == "bt_000"
|
||
assert pm.active.track_count == 1
|
||
|
||
def test_remove_track(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/a.wav")
|
||
pm.add_track("/music/b.wav")
|
||
pm.add_track("/music/c.wav")
|
||
assert pm.remove_track("bt_001") is True
|
||
assert pm.active.track_count == 2
|
||
|
||
def test_move_track(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/a.wav", title="A")
|
||
pm.add_track("/music/b.wav", title="B")
|
||
pm.add_track("/music/c.wav", title="C")
|
||
# Move C (index 2) to front
|
||
pm.move_track("bt_002", 0)
|
||
tracks = pm.active.tracks
|
||
assert tracks[0].id == "bt_002"
|
||
assert tracks[1].id == "bt_000"
|
||
|
||
def test_clear(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/a.wav")
|
||
pm.add_track("/music/b.wav")
|
||
assert pm.clear() == 2
|
||
assert pm.active.track_count == 0
|
||
assert pm.active.current_index == -1
|
||
|
||
def test_track_controls(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/song.wav")
|
||
assert pm.set_volume("bt_000", 0.75)
|
||
assert pm.get_track("bt_000").volume == 0.75
|
||
assert pm.set_pan("bt_000", 0.3)
|
||
assert pm.get_track("bt_000").pan == 0.3
|
||
assert pm.set_mute("bt_000", True)
|
||
assert pm.get_track("bt_000").muted is True
|
||
assert pm.set_solo("bt_000", True)
|
||
assert pm.get_track("bt_000").soloed is True
|
||
assert pm.set_play_mode("bt_000", PlayMode.LOOP)
|
||
assert pm.get_track("bt_000").play_mode == PlayMode.LOOP
|
||
|
||
def test_set_volume_clamped(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/song.wav")
|
||
pm.set_volume("bt_000", 2.0)
|
||
assert pm.get_track("bt_000").volume == 1.0
|
||
pm.set_volume("bt_000", -0.5)
|
||
assert pm.get_track("bt_000").volume == 0.0
|
||
|
||
def test_select_track(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/a.wav", title="A")
|
||
pm.add_track("/music/b.wav", title="B")
|
||
t = pm.select_track(1)
|
||
assert t.title == "B"
|
||
assert pm.active.current_index == 1
|
||
|
||
def test_select_invalid(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
assert pm.select_track(999) is None
|
||
|
||
def test_next_prev_track(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/a.wav")
|
||
pm.add_track("/music/b.wav")
|
||
pm.add_track("/music/c.wav")
|
||
pm.select_track(1)
|
||
n = pm.next_track()
|
||
assert n.id == "bt_002"
|
||
p = pm.prev_track()
|
||
assert p.id == "bt_001"
|
||
|
||
def test_cue_and_loop_region(self):
|
||
pm = PlaylistManager()
|
||
pm.create("List")
|
||
pm.add_track("/music/song.wav")
|
||
pm.set_cue_point("bt_000", 5.0)
|
||
assert pm.get_track("bt_000").cue_point == 5.0
|
||
pm.set_loop_region("bt_000", 2.0, 8.0)
|
||
t = pm.get_track("bt_000")
|
||
assert t.loop_start == 2.0
|
||
assert t.loop_end == 8.0
|
||
|
||
def test_serialization(self):
|
||
pm = PlaylistManager()
|
||
pm.create("Gig")
|
||
pm.add_track("/music/a.wav", title="Opener", play_mode=PlayMode.ONE_SHOT)
|
||
pm.add_track("/music/b.wav", title="Closer", play_mode=PlayMode.LOOP)
|
||
pm.set_volume("bt_000", 0.8)
|
||
pm.set_pan("bt_001", 0.3)
|
||
pm.set_mute("bt_000", True)
|
||
|
||
d = pm.to_dict()
|
||
assert d["name"] == "Gig"
|
||
assert len(d["tracks"]) == 2
|
||
assert d["tracks"][0]["volume"] == 0.8
|
||
assert d["tracks"][0]["muted"] is True
|
||
assert d["tracks"][1]["pan"] == 0.3
|
||
|
||
# Round-trip
|
||
pm2 = PlaylistManager()
|
||
pm2.from_dict(d)
|
||
assert pm2.get("Gig").track_count == 2
|
||
assert pm2.get_track("bt_000").volume == 0.8
|
||
assert pm2.get_track("bt_000").muted is True
|
||
assert pm2.get_track("bt_001").pan == 0.3
|
||
|
||
def test_json_serialization(self):
|
||
pm = PlaylistManager()
|
||
pm.create("Test")
|
||
pm.add_track("/music/a.wav", title="A")
|
||
json_str = pm.to_json()
|
||
data = json.loads(json_str)
|
||
assert data["name"] == "Test"
|
||
assert len(data["tracks"]) == 1
|
||
|
||
pm2 = PlaylistManager()
|
||
pm2.from_json(json_str)
|
||
assert pm2.get("Test").track_count == 1
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# JACK Transport tests
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestJACKTransport:
|
||
def test_init(self):
|
||
"""JACKTransport initializes with safe defaults (JACK may be absent)."""
|
||
jt = JACKTransport(sample_rate=48000)
|
||
assert jt._sample_rate == 48000
|
||
# available may be True or False depending on environment
|
||
assert isinstance(jt.state, JACKTransportState)
|
||
|
||
def test_state_defaults(self):
|
||
"""Transport state has sensible defaults."""
|
||
jt = JACKTransport()
|
||
st = jt.state
|
||
assert st.sample_rate == 48000
|
||
assert st.tempo == 120.0
|
||
assert isinstance(st.state, TransportState)
|
||
|
||
def test_get_state_returns_valid(self):
|
||
"""get_state always returns a valid JACKTransportState."""
|
||
jt = JACKTransport()
|
||
st = jt.get_state()
|
||
assert isinstance(st, JACKTransportState)
|
||
assert st.tempo > 0
|
||
assert st.sample_rate > 0
|
||
|
||
def test_start_stop_no_jack(self):
|
||
"""start/stop don't crash without JACK."""
|
||
jt = JACKTransport()
|
||
# These should not raise
|
||
jt.start()
|
||
jt.stop()
|
||
jt.toggle()
|
||
|
||
def test_locate_no_jack(self):
|
||
"""locate updates internal state even without JACK."""
|
||
jt = JACKTransport(sample_rate=48000)
|
||
jt.locate(48000) # 1 second
|
||
st = jt.get_state()
|
||
# If JACK is not available, internal state should be updated
|
||
if not jt.available:
|
||
assert jt.state.frame == 48000
|
||
|
||
def test_set_tempo_no_jack(self):
|
||
"""set_tempo updates internal state without JACK."""
|
||
jt = JACKTransport()
|
||
jt.set_tempo(140.0)
|
||
if not jt.available:
|
||
assert jt.state.tempo == 140.0
|
||
|
||
def test_callback_registration(self):
|
||
"""Callbacks can be registered and removed."""
|
||
jt = JACKTransport()
|
||
calls = []
|
||
|
||
def cb(st):
|
||
calls.append(st)
|
||
|
||
jt.on_transport_change(cb)
|
||
assert len(jt._callbacks) == 1
|
||
|
||
jt.remove_callback(cb)
|
||
assert len(jt._callbacks) == 0
|
||
|
||
def test_locate_seconds(self):
|
||
jt = JACKTransport(sample_rate=48000)
|
||
jt.locate_seconds(2.5)
|
||
if not jt.available:
|
||
assert jt.state.frame == 120000 # 2.5 * 48000
|
||
|
||
|
||
class TestJACKTransportState:
|
||
def test_defaults(self):
|
||
st = JACKTransportState()
|
||
assert st.state == TransportState.UNKNOWN
|
||
assert st.tempo == 120.0
|
||
assert st.position == 0.0
|
||
assert st.frame == 0
|
||
|
||
def test_custom_values(self):
|
||
st = JACKTransportState(
|
||
state=TransportState.ROLLING,
|
||
tempo=140.0,
|
||
position=10.0,
|
||
frame=480000,
|
||
sample_rate=48000,
|
||
bar=3,
|
||
beat=2,
|
||
)
|
||
assert st.state == TransportState.ROLLING
|
||
assert st.tempo == 140.0
|
||
assert st.bar == 3
|
||
assert st.beat == 2
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Metronome tests
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestClickSound:
|
||
def test_generate_sine(self):
|
||
cs = ClickSound(frequency=1000.0, duration_ms=15.0, volume=0.5, waveform="sine")
|
||
samples = cs.generate(48000)
|
||
assert samples.dtype == np.float32
|
||
assert len(samples) > 0
|
||
assert np.max(np.abs(samples)) <= 0.5
|
||
|
||
def test_generate_square(self):
|
||
cs = ClickSound(waveform="square")
|
||
samples = cs.generate(48000)
|
||
assert samples.dtype == np.float32
|
||
assert len(samples) > 0
|
||
|
||
def test_generate_noise(self):
|
||
cs = ClickSound(waveform="noise")
|
||
samples = cs.generate(48000)
|
||
assert len(samples) > 0
|
||
|
||
def test_generate_click(self):
|
||
cs = ClickSound(waveform="click")
|
||
samples = cs.generate(48000)
|
||
assert len(samples) > 0
|
||
assert abs(samples[0]) > 0 # first sample should be non-zero
|
||
|
||
def test_zero_duration_handled(self):
|
||
cs = ClickSound(duration_ms=0.0)
|
||
samples = cs.generate(48000)
|
||
assert len(samples) > 0 # At least 1 sample
|
||
|
||
def test_low_volume(self):
|
||
cs = ClickSound(volume=0.01)
|
||
samples = cs.generate(48000)
|
||
assert np.max(np.abs(samples)) <= 0.01
|
||
|
||
|
||
class TestMetronome:
|
||
def test_init(self):
|
||
m = Metronome(sample_rate=48000)
|
||
assert m.sample_rate == 48000
|
||
assert m.enabled is True
|
||
assert m.time_sig_num == 4
|
||
assert m.count_in_bars == 2
|
||
|
||
def test_enable_disable(self):
|
||
m = Metronome()
|
||
m.enabled = False
|
||
assert m.enabled is False
|
||
m.enabled = True
|
||
assert m.enabled is True
|
||
|
||
def test_set_volume(self):
|
||
m = Metronome()
|
||
m.set_volume(0.3)
|
||
assert m.strong_click.volume == 0.3
|
||
assert m.weak_click.volume == pytest.approx(0.24, rel=0.1)
|
||
|
||
def test_generate_click_strong(self):
|
||
"""Beat 1 generates strong click."""
|
||
m = Metronome(sample_rate=48000)
|
||
click = m.generate_click(beat=1, bar=1)
|
||
assert len(click) > 0
|
||
assert click.dtype == np.float32
|
||
|
||
def test_generate_click_weak(self):
|
||
"""Beat 2 generates weak click."""
|
||
m = Metronome(sample_rate=48000)
|
||
click = m.generate_click(beat=2, bar=1)
|
||
assert len(click) > 0
|
||
|
||
def test_generate_click_disabled(self):
|
||
"""Disabled metronome returns empty click."""
|
||
m = Metronome()
|
||
m.enabled = False
|
||
click = m.generate_click(beat=1, bar=1)
|
||
assert len(click) == 0
|
||
|
||
def test_generate_click_count_in_bypasses_enabled(self):
|
||
"""Count-in clicks are generated even when metronome is disabled."""
|
||
m = Metronome()
|
||
m.enabled = False
|
||
click = m.generate_click(beat=1, bar=1, is_count_in=True)
|
||
assert len(click) > 0
|
||
|
||
def test_generate_clicks_for_position(self):
|
||
"""Generate clicks for a buffer at a given position."""
|
||
m = Metronome(sample_rate=48000)
|
||
buf = m.generate_clicks_for_position(
|
||
position_seconds=0.0,
|
||
tempo=120.0,
|
||
bar=1,
|
||
beat=1,
|
||
buffer_size=48000, # 1 second of audio
|
||
)
|
||
assert buf.dtype == np.float32
|
||
assert len(buf) == 48000
|
||
# At 120 BPM, 0.5s per beat. In a 1s buffer starting at beat 1,
|
||
# we should have a click at the start (beat 1, already past) and
|
||
# possibly one at beat 2 (offset 0.5s)
|
||
# The click for beat 2 should appear around sample 24000
|
||
assert np.any(np.abs(buf[23000:25000]) > 0.001)
|
||
|
||
def test_generate_clicks_disabled(self):
|
||
"""Disabled metronome returns silence."""
|
||
m = Metronome(sample_rate=48000)
|
||
m.enabled = False
|
||
buf = m.generate_clicks_for_position(0, 120, 1, 1, 1024)
|
||
assert np.all(buf == 0.0)
|
||
|
||
def test_count_in(self):
|
||
"""Count-in generates clicks for a set number of bars."""
|
||
m = Metronome(sample_rate=48000)
|
||
m.count_in_bars = 2
|
||
m.time_sig_num = 4 # 4/4: 8 beats of count-in
|
||
|
||
m.start_count_in(bars=2)
|
||
assert m.is_count_in_active() is True
|
||
|
||
# Generate enough buffers to exhaust the count-in
|
||
# 8 beats * 24000 samples/beat / 12000 buffer = 16 buffers needed
|
||
buf_size = 12000 # 0.25s at 48k
|
||
for _ in range(20): # 20 > 16, safe margin
|
||
buf = m.generate_clicks_for_position(0, 120, 1, 1, buf_size)
|
||
|
||
# Count-in should be complete
|
||
assert m.is_count_in_active() is False
|
||
|
||
def test_count_in_stop(self):
|
||
"""Stopping count-in mid-way works."""
|
||
m = Metronome()
|
||
m.start_count_in()
|
||
assert m.is_count_in_active() is True
|
||
m.stop_count_in()
|
||
assert m.is_count_in_active() is False
|
||
|
||
def test_stereo_clicks(self):
|
||
"""Stereo output has correct shape."""
|
||
m = Metronome(sample_rate=48000)
|
||
buf = m.generate_stereo_clicks(0.0, 120.0, 1, 1, 1024)
|
||
assert buf.shape == (1024, 2)
|
||
assert buf.dtype == np.float32
|
||
|
||
def test_tempo_affects_click_spacing(self):
|
||
"""Faster tempo = clicks closer together."""
|
||
m = Metronome(sample_rate=48000)
|
||
# At 240 BPM, ~0.25s per beat
|
||
buf_fast = m.generate_clicks_for_position(0.0, 240.0, 1, 1, 12000)
|
||
# At 60 BPM, ~1.0s per beat
|
||
buf_slow = m.generate_clicks_for_position(0.0, 60.0, 1, 1, 12000)
|
||
|
||
# Both should be valid
|
||
assert len(buf_fast) == 12000
|
||
assert len(buf_slow) == 12000
|
||
|
||
def test_buffer_boundary_clicks(self):
|
||
"""Clicks that land at buffer boundaries are handled."""
|
||
m = Metronome(sample_rate=48000)
|
||
# Very small buffer
|
||
buf = m.generate_clicks_for_position(0.0, 120.0, 1, 1, 128)
|
||
assert len(buf) == 128
|
||
assert buf.dtype == np.float32
|
||
|
||
def test_very_slow_tempo(self):
|
||
"""Very slow tempo (no beats in buffer) returns silence."""
|
||
m = Metronome(sample_rate=48000)
|
||
buf = m.generate_clicks_for_position(0.1, 10.0, 1, 1, 1024)
|
||
assert len(buf) == 1024
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# BackingTrackPlayer tests
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestBackingTrackPlayer:
|
||
def test_init(self):
|
||
"""Player initializes without errors."""
|
||
cfg = BackingPlayerConfig(jack_transport_enabled=False)
|
||
player = BackingTrackPlayer(cfg)
|
||
assert player.config == cfg
|
||
assert player.is_playing is False
|
||
assert player.is_paused is False
|
||
assert player.active_track is None
|
||
assert player.position == 0.0
|
||
player.shutdown()
|
||
|
||
def test_init_defaults(self):
|
||
"""Player works with default config."""
|
||
player = BackingTrackPlayer()
|
||
assert player.config.sample_rate == 48000
|
||
player.shutdown()
|
||
|
||
def test_load_track(self):
|
||
"""Loading a track updates metadata."""
|
||
wav = _write_temp_wav(duration=0.5, sample_rate=44100, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Test Track")
|
||
assert player.load_track("bt_000")
|
||
|
||
track = player.playlists.get_track("bt_000")
|
||
assert track.loaded is True
|
||
assert track.state == TrackState.IDLE
|
||
assert track.duration > 0
|
||
assert track.sample_rate > 0
|
||
assert track.channels > 0
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_load_nonexistent_file(self):
|
||
"""Loading a nonexistent file returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/nonexistent/file.wav")
|
||
assert player.load_track("bt_000") is False
|
||
t = player.playlists.get_track("bt_000")
|
||
assert t.state == TrackState.ERROR
|
||
player.shutdown()
|
||
|
||
def test_play_without_loaded_track(self):
|
||
"""Playing with no loaded tracks returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/ghost.wav", title="Ghost")
|
||
# Track exists in playlist but audio is not loaded
|
||
assert player.play(count_in=False) is False
|
||
player.shutdown()
|
||
|
||
def test_play_stop(self):
|
||
"""Play and stop cycle works."""
|
||
wav = _write_temp_wav(duration=0.3, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
assert player.load_track("bt_000")
|
||
assert player.play(count_in=False) is True
|
||
assert player.is_playing is True
|
||
assert player.active_track.id == "bt_000"
|
||
|
||
assert player.stop() is True
|
||
assert player.is_playing is False
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_pause_resume(self):
|
||
"""Pause and resume works."""
|
||
wav = _write_temp_wav(duration=0.5, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
assert player.pause() is True
|
||
assert player.is_paused is True
|
||
assert player.is_playing is False
|
||
|
||
assert player.resume() is True
|
||
assert player.is_paused is False
|
||
assert player.is_playing is True
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_toggle_play(self):
|
||
"""Toggle cycles through play/pause states."""
|
||
wav = _write_temp_wav(duration=0.5, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
|
||
# Start
|
||
player.play(count_in=False)
|
||
assert player.is_playing
|
||
|
||
# Pause
|
||
player.pause()
|
||
assert player.is_paused
|
||
|
||
# Resume (toggle calls resume since paused)
|
||
player.resume()
|
||
assert player.is_playing
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_seek(self):
|
||
"""Seeking within a track works."""
|
||
wav = _write_temp_wav(duration=1.0, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
assert player.seek(0.5)
|
||
assert pytest.approx(player.position, rel=0.1) == 0.5
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_seek_beyond_end_clamped(self):
|
||
"""Seeking beyond track duration clamps to duration."""
|
||
wav = _write_temp_wav(duration=0.5, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
player.seek(999.0)
|
||
assert player.position <= 1.0 # should be clamped
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_volume_control(self):
|
||
"""Per-track volume can be set."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/song.wav")
|
||
assert player.set_volume("bt_000", 0.75)
|
||
t = player.playlists.get_track("bt_000")
|
||
assert t.volume == 0.75
|
||
player.shutdown()
|
||
|
||
def test_mute_control(self):
|
||
"""Per-track mute."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/song.wav")
|
||
assert player.set_mute("bt_000", True)
|
||
assert player.playlists.get_track("bt_000").muted is True
|
||
player.shutdown()
|
||
|
||
def test_pan_control(self):
|
||
"""Per-track pan."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/song.wav")
|
||
assert player.set_pan("bt_000", 0.2)
|
||
assert player.playlists.get_track("bt_000").pan == 0.2
|
||
player.shutdown()
|
||
|
||
def test_solo_control(self):
|
||
"""Per-track solo."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/song.wav")
|
||
assert player.set_solo("bt_000", True)
|
||
assert player.playlists.get_track("bt_000").soloed is True
|
||
player.shutdown()
|
||
|
||
def test_play_mode(self):
|
||
"""Setting play mode on a track."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/song.wav")
|
||
assert player.set_play_mode("bt_000", PlayMode.LOOP)
|
||
assert player.playlists.get_track("bt_000").play_mode == PlayMode.LOOP
|
||
player.shutdown()
|
||
|
||
def test_set_tempo(self):
|
||
"""Setting tempo works."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.set_tempo(140.0)
|
||
assert player.transport.state.tempo == 140.0
|
||
player.shutdown()
|
||
|
||
def test_metronome_enable(self):
|
||
"""Metronome can be enabled/disabled."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.set_metronome_enabled(False)
|
||
assert player.metronome.enabled is False
|
||
player.set_metronome_enabled(True)
|
||
assert player.metronome.enabled is True
|
||
player.shutdown()
|
||
|
||
def test_click_volume(self):
|
||
"""Click volume can be adjusted."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.set_click_volume(0.25)
|
||
assert player.config.click_volume == 0.25
|
||
player.shutdown()
|
||
|
||
def test_next_track(self):
|
||
"""Next track navigation works."""
|
||
wav1 = _write_temp_wav(duration=0.1, frequency=440.0)
|
||
wav2 = _write_temp_wav(duration=0.1, frequency=880.0)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav1), title="Song A")
|
||
player.playlists.add_track(str(wav2), title="Song B")
|
||
player.load_track("bt_000")
|
||
player.load_track("bt_001")
|
||
player.play(count_in=False)
|
||
assert player.active_track.id == "bt_000"
|
||
|
||
player.next_track()
|
||
assert player.is_playing
|
||
assert player.active_track.id == "bt_001"
|
||
finally:
|
||
wav1.unlink()
|
||
wav2.unlink()
|
||
player.shutdown()
|
||
|
||
def test_prev_track(self):
|
||
"""Previous track navigation works."""
|
||
wav1 = _write_temp_wav(duration=0.1, frequency=440.0)
|
||
wav2 = _write_temp_wav(duration=0.1, frequency=880.0)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav1), title="Song A")
|
||
player.playlists.add_track(str(wav2), title="Song B")
|
||
player.load_track("bt_000")
|
||
player.load_track("bt_001")
|
||
|
||
# Play track 1
|
||
player.select_track(1)
|
||
assert player.active_track.id == "bt_001"
|
||
|
||
# Go back
|
||
player.prev_track()
|
||
assert player.active_track.id == "bt_000"
|
||
finally:
|
||
wav1.unlink()
|
||
wav2.unlink()
|
||
player.shutdown()
|
||
|
||
def test_select_track(self):
|
||
"""Select track by index."""
|
||
wav1 = _write_temp_wav(duration=0.1, frequency=440.0)
|
||
wav2 = _write_temp_wav(duration=0.1, frequency=880.0)
|
||
wav3 = _write_temp_wav(duration=0.1, frequency=1320.0)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav1), title="A")
|
||
player.playlists.add_track(str(wav2), title="B")
|
||
player.playlists.add_track(str(wav3), title="C")
|
||
player.load_playlist_tracks()
|
||
|
||
player.select_track(2)
|
||
assert player.active_track.title == "C"
|
||
finally:
|
||
for w in [wav1, wav2, wav3]:
|
||
w.unlink()
|
||
player.shutdown()
|
||
|
||
def test_load_playlist_tracks(self):
|
||
"""Loading all tracks in a playlist."""
|
||
wav1 = _write_temp_wav(duration=0.1)
|
||
wav2 = _write_temp_wav(duration=0.1)
|
||
wav3 = _write_temp_wav(duration=0.1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav1), title="One")
|
||
player.playlists.add_track(str(wav2), title="Two")
|
||
player.playlists.add_track(str(wav3), title="Three")
|
||
|
||
count = player.load_playlist_tracks()
|
||
assert count == 3
|
||
|
||
for tid in ["bt_000", "bt_001", "bt_002"]:
|
||
assert player.is_loaded(tid)
|
||
finally:
|
||
for w in [wav1, wav2, wav3]:
|
||
w.unlink()
|
||
player.shutdown()
|
||
|
||
def test_unload_track(self):
|
||
"""Unloading frees audio data."""
|
||
wav = _write_temp_wav(duration=0.1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="One")
|
||
player.load_track("bt_000")
|
||
assert player.is_loaded("bt_000")
|
||
|
||
player.unload_track("bt_000")
|
||
assert not player.is_loaded("bt_000")
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_get_state(self):
|
||
"""get_state returns a meaningful dict."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
state = player.get_state()
|
||
assert "playing" in state
|
||
assert "active_track" in state
|
||
assert "position" in state
|
||
assert "transport" in state
|
||
assert "metronome_enabled" in state
|
||
assert "playlist" in state
|
||
assert "loaded_tracks" in state
|
||
player.shutdown()
|
||
|
||
def test_process_buffer_silence_when_stopped(self):
|
||
"""process_buffer returns silence when not playing."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
buf = player.process_buffer(256)
|
||
assert buf.shape == (256, 2)
|
||
assert buf.dtype == np.float32
|
||
assert np.all(buf == 0.0)
|
||
player.shutdown()
|
||
|
||
def test_process_buffer_audio(self):
|
||
"""process_buffer returns audio when playing."""
|
||
wav = _write_temp_wav(duration=1.0, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(
|
||
jack_transport_enabled=False,
|
||
metronome_enabled=False,
|
||
)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
buf = player.process_buffer(256)
|
||
assert buf.shape == (256, 2)
|
||
# Should contain audio (not silence)
|
||
assert np.any(np.abs(buf) > 0.001)
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_process_buffer_muted(self):
|
||
"""process_buffer returns silence when track is muted."""
|
||
wav = _write_temp_wav(duration=1.0, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(
|
||
jack_transport_enabled=False,
|
||
metronome_enabled=False,
|
||
)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
player.set_mute("bt_000", True)
|
||
player.play(count_in=False)
|
||
|
||
buf = player.process_buffer(256)
|
||
assert np.all(buf == 0.0)
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_loop_mode(self):
|
||
"""LOOP mode wraps position back to cue point."""
|
||
wav = _write_temp_wav(duration=0.1, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(
|
||
jack_transport_enabled=False,
|
||
metronome_enabled=False,
|
||
)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Loop Song", play_mode=PlayMode.LOOP)
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
# Process enough buffers to exhaust the file
|
||
total_frames = 0
|
||
max_frames = 48000 * 2 # 2 seconds worth
|
||
while total_frames < max_frames and player.is_playing:
|
||
buf = player.process_buffer(256)
|
||
total_frames += 256
|
||
|
||
# Track should still be playing (looped)
|
||
assert player.is_playing
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_one_shot_mode(self):
|
||
"""ONE_SHOT mode stops at end of track."""
|
||
wav = _write_temp_wav(duration=0.05, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(
|
||
jack_transport_enabled=False,
|
||
metronome_enabled=False,
|
||
)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="One Shot", play_mode=PlayMode.ONE_SHOT)
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
# Process until done
|
||
for _ in range(20):
|
||
buf = player.process_buffer(256)
|
||
if not player.is_playing:
|
||
break
|
||
|
||
assert not player.is_playing
|
||
t = player.playlists.get_track("bt_000")
|
||
assert t.state == TrackState.FINISHED
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_segue_mode_advances(self):
|
||
"""SEGUE mode advances to next track."""
|
||
wav1 = _write_temp_wav(duration=0.05, sample_rate=48000, channels=1, frequency=440.0)
|
||
wav2 = _write_temp_wav(duration=0.05, sample_rate=48000, channels=1, frequency=880.0)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(
|
||
jack_transport_enabled=False,
|
||
metronome_enabled=False,
|
||
auto_advance=True,
|
||
)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav1), title="A", play_mode=PlayMode.SEGUE)
|
||
player.playlists.add_track(str(wav2), title="B", play_mode=PlayMode.ONE_SHOT)
|
||
player.load_playlist_tracks()
|
||
player.play(count_in=False)
|
||
|
||
# Process until first track finishes and auto-advances
|
||
for _ in range(30):
|
||
buf = player.process_buffer(256)
|
||
if player.active_track and player.active_track.title == "B":
|
||
break
|
||
|
||
# Should have advanced to track B
|
||
assert player.active_track is not None
|
||
assert player.active_track.title == "B"
|
||
finally:
|
||
wav1.unlink()
|
||
wav2.unlink()
|
||
player.shutdown()
|
||
|
||
def test_set_loop_region(self):
|
||
"""Setting a loop region on a track."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/song.wav")
|
||
assert player.set_loop_region("bt_000", 2.0, 8.0)
|
||
t = player.playlists.get_track("bt_000")
|
||
assert t.loop_start == 2.0
|
||
assert t.loop_end == 8.0
|
||
player.shutdown()
|
||
|
||
def test_shutdown_cleanup(self):
|
||
"""Shutdown cleans up without errors."""
|
||
wav = _write_temp_wav(duration=0.1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav))
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
player.shutdown()
|
||
|
||
assert not player.is_playing
|
||
assert len(player._audio_cache) == 0 # noqa: SLF001
|
||
finally:
|
||
wav.unlink()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# DSP Integration tests (MIDI parameter handling)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestDSPIntegration:
|
||
"""Tests for the player's handle_parameter (MIDI → transport/track) dispatch."""
|
||
|
||
def _param(self, ptype, category="transport", channel=-1, label=""):
|
||
"""Helper to create a MixerParameter quickly."""
|
||
from src.midi.types import MixerParameter, ParameterType, ParameterCategory
|
||
return MixerParameter(
|
||
param_type=ParameterType(ptype) if isinstance(ptype, str) else ptype,
|
||
category=ParameterCategory(category) if isinstance(category, str) else category,
|
||
channel=channel,
|
||
label=label or str(ptype),
|
||
)
|
||
|
||
def test_play_stop_parameter(self):
|
||
"""PLAY param > 0.5 starts playback, < 0.5 stops it."""
|
||
wav = _write_temp_wav(duration=0.5)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
|
||
# PLAY=1.0 should start
|
||
p = self._param("play")
|
||
assert player.handle_parameter(p, 1.0) is True
|
||
assert player.is_playing
|
||
|
||
# PLAY=0.0 should stop
|
||
assert player.handle_parameter(p, 0.0) is True
|
||
assert not player.is_playing
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_stop_parameter(self):
|
||
"""STOP parameter stops playback."""
|
||
wav = _write_temp_wav(duration=0.5)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
p = self._param("stop")
|
||
assert player.handle_parameter(p, 1.0) is True
|
||
assert not player.is_playing
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_tempo_parameter(self):
|
||
"""TEMPO parameter sets BPM."""
|
||
wav = _write_temp_wav(duration=0.1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav))
|
||
|
||
p = self._param("tempo")
|
||
assert player.handle_parameter(p, 140.0) is True
|
||
assert player.transport.state.tempo == 140.0
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_loop_parameter(self):
|
||
"""LOOP parameter toggles loop mode on active track."""
|
||
wav = _write_temp_wav(duration=0.5)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
player.play(count_in=False)
|
||
|
||
p = self._param("loop")
|
||
assert player.handle_parameter(p, 1.0) is True
|
||
assert player.playlists.get_track("bt_000").play_mode == PlayMode.LOOP
|
||
|
||
assert player.handle_parameter(p, 0.0) is True
|
||
assert player.playlists.get_track("bt_000").play_mode == PlayMode.ONE_SHOT
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_channel_volume_maps_to_track(self):
|
||
"""Channel 16 volume maps to backing track 0."""
|
||
wav = _write_temp_wav(duration=0.1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="BT1")
|
||
player.playlists.add_track(str(wav), title="BT2")
|
||
|
||
p = self._param("volume", category="channel", channel=16)
|
||
player.handle_parameter(p, 0.75)
|
||
assert player.playlists.get_track("bt_000").volume == 0.75
|
||
|
||
# Channel 17 = track 1
|
||
p2 = self._param("volume", category="channel", channel=17)
|
||
player.handle_parameter(p2, 0.5)
|
||
assert player.playlists.get_track("bt_001").volume == 0.5
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_channel_mute_maps_to_track(self):
|
||
"""Channel mute maps to backing track."""
|
||
wav = _write_temp_wav(duration=0.1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav))
|
||
|
||
p = self._param("mute", category="channel", channel=16)
|
||
player.handle_parameter(p, 1.0)
|
||
assert player.playlists.get_track("bt_000").muted is True
|
||
|
||
player.handle_parameter(p, 0.0)
|
||
assert player.playlists.get_track("bt_000").muted is False
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|
||
|
||
def test_non_transport_parameter_ignored(self):
|
||
"""Non-transport, non-channel params are ignored."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
p = self._param("eq_low_freq", category="channel", channel=0)
|
||
assert player.handle_parameter(p, 100.0) is False
|
||
player.shutdown()
|
||
|
||
def test_tap_tempo_is_noop(self):
|
||
"""TAP_TEMPO parameter returns True but is a noop."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
p = self._param("tap_tempo")
|
||
assert player.handle_parameter(p, 0.0) is True
|
||
player.shutdown()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Edge cases and error handling
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestEdgeCases:
|
||
def test_stop_when_not_playing(self):
|
||
"""Stopping when not playing is a no-op."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.stop() is True
|
||
|
||
def test_pause_when_not_playing(self):
|
||
"""Pausing when not playing returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.pause() is False
|
||
|
||
def test_resume_when_not_paused(self):
|
||
"""Resuming when not paused returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.resume() is False
|
||
|
||
def test_seek_without_active_track(self):
|
||
"""Seeking with no active track returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.seek(1.0) is False
|
||
|
||
def test_next_track_without_playlist(self):
|
||
"""Next track without playlist returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.next_track() is False
|
||
|
||
def test_prev_track_without_playlist(self):
|
||
"""Prev track without playlist returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.prev_track() is False
|
||
|
||
def test_play_with_no_playlist(self):
|
||
"""Playing with no active playlist returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.play() is False
|
||
|
||
def test_unload_nonexistent_track(self):
|
||
"""Unloading a track not in cache returns False."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
assert player.unload_track("nonexistent") is False
|
||
|
||
def test_volume_clamped_to_range(self):
|
||
"""Volume set outside [0,1] is clamped."""
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(jack_transport_enabled=False)
|
||
)
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track("/music/song.wav")
|
||
player.set_volume("bt_000", 5.0)
|
||
assert player.playlists.get_track("bt_000").volume == 1.0
|
||
player.set_volume("bt_000", -3.0)
|
||
assert player.playlists.get_track("bt_000").volume == 0.0
|
||
|
||
def test_multiple_playlists_independent(self):
|
||
"""Multiple playlists are independent."""
|
||
pm = PlaylistManager()
|
||
pm.create("Set A")
|
||
pm.add_track("/music/a.wav")
|
||
pm.create("Set B")
|
||
pm.set_active("Set B")
|
||
pm.add_track("/music/b.wav")
|
||
pm.add_track("/music/c.wav")
|
||
|
||
assert pm.get("Set A").track_count == 1
|
||
assert pm.get("Set B").track_count == 2
|
||
assert pm.active.name == "Set B"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Integration: count-in with audio playback
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestCountInIntegration:
|
||
def test_count_in_before_playback(self):
|
||
"""Count-in generates clicks before audio starts."""
|
||
wav = _write_temp_wav(duration=0.5, sample_rate=48000, channels=1)
|
||
try:
|
||
player = BackingTrackPlayer(
|
||
BackingPlayerConfig(
|
||
jack_transport_enabled=False,
|
||
count_in_bars=1, # 4 beats
|
||
time_signature_num=4,
|
||
metronome_enabled=False, # We want only count-in clicks
|
||
)
|
||
)
|
||
player.metronome.enabled = True # Enable metronome for count-in
|
||
|
||
player.playlists.create("Test")
|
||
player.playlists.add_track(str(wav), title="Song")
|
||
player.load_track("bt_000")
|
||
|
||
# Play WITH count-in (default)
|
||
player.play(count_in=True)
|
||
|
||
# During count-in, is_playing is False
|
||
assert not player.is_playing
|
||
assert player._count_in_remaining > 0 # noqa: SLF001
|
||
|
||
# Process until count-in completes
|
||
# 1 bar × 4 beats × (60/120)s = 2.0s count-in
|
||
# 2.0s / (256 frames / 48000 Hz) ≈ 375 buffers needed
|
||
for _ in range(400):
|
||
buf = player.process_buffer(256)
|
||
if player.is_playing:
|
||
break
|
||
|
||
# After count-in, playback should be active
|
||
assert player.is_playing
|
||
finally:
|
||
wav.unlink()
|
||
player.shutdown()
|