Files
raspberry-pi-mixer/tests/test_backing.py
T
shawn 9cd8292acc
Lint & Validate / lint (push) Has been cancelled
Phase 1-4: Audio stack, mixer engine, MIDI, and network API
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning)
P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support)
P2-R3: Plugin manager, categories, blacklist, NAM model support
P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation)
P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store)
P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter)
P5-R1: Touchscreen UI evaluation + main entry point
docs: Audio stack, Carla integration, MIDI support, UI evaluation
tests: Full test suite (292 passing)
2026-05-19 20:39:17 -04:00

1640 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
buf_size = 12000 # 0.25s at 48k = 1 beat at 120 BPM
for _ in range(10): # 10 beats = more than 8
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
for _ in range(50):
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()