Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled

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)
This commit is contained in:
2026-05-19 20:39:17 -04:00
parent 7b123762b5
commit 9cd8292acc
97 changed files with 26545 additions and 5 deletions
+815
View File
@@ -0,0 +1,815 @@
"""Backing track player — main orchestrator.
The BackingTrackPlayer ties together the audio loader, playlist manager,
JACK transport, and metronome into a cohesive backing track playback engine.
It provides the primary API for:
- Loading tracks into memory
- Starting/stopping/pausing playback
- Per-track volume, pan, mute, solo control
- Loop, one-shot, and segue modes
- JACK transport synchronization
- Click track / metronome output
- Count-in before playback start
Integrates with the mixer's DSPEngine via the ParameterRegistry
callback for MIDI/OSC control of transport and track parameters.
Architecture:
MIDI/OSC → ParameterRegistry → BackingTrackPlayer.handle_parameter()
├── Transport control (play, stop, tempo)
├── Track control (volume, pan, mute, solo)
└── Playlist navigation (next, prev, select)
"""
from __future__ import annotations
import logging
import threading
from typing import Optional
import numpy as np
from ..midi.types import (
MixerParameter,
ParameterType,
ParameterCategory,
)
from .types import (
PlayMode,
TrackState,
Track,
Playlist,
BackingPlayerConfig,
)
from .loader import AudioLoader, AudioData, load_audio
from .playlist import PlaylistManager
from .transport import JACKTransport, TransportState, JACKTransportState
from .metronome import Metronome
logger = logging.getLogger(__name__)
class BackingTrackPlayer:
"""Synchronized backing track playback engine.
Manages loading, playback, and control of backing tracks. Integrates
with JACK transport for synchronization and provides per-track mixer
controls (volume, pan, mute, solo, loop modes).
Thread-safe for concurrent access from MIDI callbacks and UI.
Example usage:
config = BackingPlayerConfig()
player = BackingTrackPlayer(config)
player.load_track("backing_track_1", "/path/to/song.wav")
player.play()
player.set_volume("backing_track_1", 0.8)
player.stop()
"""
# ── Init ─────────────────────────────────────────────────────────────
def __init__(self, config: BackingPlayerConfig | None = None):
self.config = config or BackingPlayerConfig()
# Components
self.loader = AudioLoader()
self.playlists = PlaylistManager()
self.transport = JACKTransport(
sample_rate=self.config.sample_rate,
auto_poll=True,
)
self.metronome = Metronome(
sample_rate=self.config.sample_rate,
click_volume=self.config.click_volume,
click_freq_strong=self.config.click_freq_strong,
click_freq_weak=self.config.click_freq_weak,
click_duration_ms=self.config.click_duration_ms,
)
# Configure metronome
self.metronome.time_sig_num = self.config.time_signature_num
self.metronome.time_sig_den = self.config.time_signature_den
self.metronome.count_in_bars = self.config.count_in_bars
# Loaded audio data cache: track_id → AudioData
self._audio_cache: dict[str, AudioData] = {}
# Transport integration
self._sync_to_transport: bool = self.config.jack_transport_enabled
self._transport_state: TransportState = TransportState.STOPPED
# Playback state
self._playing: bool = False
self._paused: bool = False
self._active_track_id: str | None = None
self._playback_position: float = 0.0 # seconds into active track
self._count_in_remaining: float = 0.0 # seconds of count-in left
# Thread safety
self._lock = threading.Lock()
# DSP integration
self._dsp_engine = self.config.dsp_engine
self._param_callbacks: list = []
# Bind transport callbacks
if self._sync_to_transport:
self.transport.on_transport_change(self._on_transport_change)
logger.info(
"BackingTrackPlayer initialized (sr=%d, buffer=%d, JACK=%s)",
self.config.sample_rate, self.config.buffer_size,
"enabled" if self.transport.available else "unavailable",
)
# ── Public API ───────────────────────────────────────────────────────
# -- Track loading --
def load_track(self, track_id: str, file_path: str | None = None) -> bool:
"""Load a track's audio data into memory.
If file_path is provided, updates the track's file_path.
The track must already exist in the active playlist.
Returns:
True if loaded successfully.
"""
pl = self.playlists.active
if pl is None:
logger.warning("No active playlist")
return False
track = self.playlists.get_track(track_id)
if track is None:
logger.warning("Track not found: %s", track_id)
return False
if file_path:
track.file_path = file_path
if not track.file_path:
logger.warning("No file path for track: %s", track_id)
return False
try:
audio = self.loader.load(track.file_path, self.config.sample_rate)
self._audio_cache[track_id] = audio
# Update track metadata
track.duration = audio.duration
track.sample_rate = audio.sample_rate
track.channels = audio.channels
track.loaded = True
track.state = TrackState.IDLE
logger.info("Loaded track %s: %.1fs, %d ch, %d Hz",
track_id, audio.duration, audio.channels, audio.sample_rate)
return True
except Exception as e:
logger.error("Failed to load track %s: %s", track_id, e)
track.state = TrackState.ERROR
return False
def load_playlist_tracks(self, playlist_name: str | None = None) -> int:
"""Load all tracks in a playlist into memory. Returns count loaded."""
pl = self.playlists.active if playlist_name is None else self.playlists.get(playlist_name)
if pl is None:
return 0
count = 0
for track in pl.tracks:
if not track.loaded and track.file_path:
if self.load_track(track.id):
count += 1
return count
def unload_track(self, track_id: str) -> bool:
"""Free a track's audio data from memory."""
if track_id in self._audio_cache:
del self._audio_cache[track_id]
track = self.playlists.get_track(track_id)
if track:
track.loaded = False
track.state = TrackState.IDLE
return True
return False
def is_loaded(self, track_id: str) -> bool:
"""Check if a track is loaded into memory."""
return track_id in self._audio_cache
# -- Playback control --
def play(self, track_id: str | None = None, count_in: bool = True) -> bool:
"""Start or resume playback.
Args:
track_id: Specific track to play (default: active playlist track).
count_in: If True, pre-roll with count-in clicks before playback.
Returns:
True if playback started.
"""
with self._lock:
pl = self.playlists.active
if pl is None:
logger.warning("No active playlist")
return False
# Resolve track
if track_id:
for i, t in enumerate(pl.tracks):
if t.id == track_id:
pl.current_index = i
break
else:
logger.warning("Track not found: %s", track_id)
return False
track = pl.current_track
if track is None:
logger.warning("No track selected")
return False
# Load if needed
if not track.loaded and track.file_path:
if not self.load_track(track.id):
return False
if track_id not in self._audio_cache:
logger.warning("Track audio not loaded: %s", track.id)
return False
self._active_track_id = track.id
track.state = TrackState.PLAYING
# Reset position to cue point
self._playback_position = track.cue_point
# Count-in
if count_in and self.config.count_in_bars > 0:
self.metronome.start_count_in(self.config.count_in_bars)
beats = self.config.count_in_bars * self.config.time_signature_num
tempo = self.transport.state.tempo
self._count_in_remaining = beats * (60.0 / tempo)
self._playing = False # Will become True after count-in
else:
self._count_in_remaining = 0.0
self._playing = True
self._paused = False
# Sync JACK transport
if self._sync_to_transport and self.transport.available:
self.transport.start()
logger.info("Playing track %s (count-in=%s)", track.id,
"yes" if self._count_in_remaining > 0 else "no")
return True
def stop(self) -> bool:
"""Stop playback."""
with self._lock:
self._playing = False
self._paused = False
self._count_in_remaining = 0.0
self.metronome.stop_count_in()
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track.state = TrackState.STOPPED
track.position = self._playback_position
if self._sync_to_transport and self.transport.available:
self.transport.stop()
logger.info("Playback stopped")
return True
def pause(self) -> bool:
"""Pause playback. Resume with play()."""
with self._lock:
if not self._playing and self._count_in_remaining <= 0:
return False
self._playing = False
self._paused = True
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track.state = TrackState.PAUSED
track.position = self._playback_position
logger.info("Playback paused at %.2fs", self._playback_position)
return True
def resume(self) -> bool:
"""Resume paused playback."""
with self._lock:
if not self._paused:
return False
self._playing = True
self._paused = False
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track.state = TrackState.PLAYING
logger.info("Playback resumed from %.2fs", self._playback_position)
return True
def toggle_play(self) -> bool:
"""Toggle between play and stop/pause."""
if self._playing:
return self.pause() or self.stop()
if self._paused:
return self.resume()
return self.play()
def seek(self, seconds: float) -> bool:
"""Seek to a position in the current track."""
with self._lock:
if not self._active_track_id:
return False
track = self.playlists.get_track(self._active_track_id)
if track is None:
return False
audio = self._audio_cache.get(self._active_track_id)
if audio is None:
return False
seconds = max(0.0, min(seconds, audio.duration))
self._playback_position = seconds
track.position = seconds
if self._sync_to_transport and self.transport.available:
self.transport.locate_seconds(seconds)
logger.debug("Seeked to %.2fs", seconds)
return True
def next_track(self) -> bool:
"""Play the next track in the playlist."""
with self._lock:
was_playing = self._playing or self._paused
self.stop()
pl = self.playlists.active
if pl is None:
return False
track = pl.next_track()
if track is None:
logger.info("End of playlist reached")
return False
if was_playing:
self.play(track.id)
return True
return True
def prev_track(self) -> bool:
"""Play the previous track in the playlist."""
with self._lock:
was_playing = self._playing or self._paused
self.stop()
pl = self.playlists.active
if pl is None:
return False
track = pl.prev_track()
if track is None:
return False
if was_playing:
self.play(track.id)
return True
return True
def select_track(self, index: int) -> bool:
"""Select and play a track by playlist index."""
with self._lock:
was_playing = self._playing or self._paused
self.stop()
pl = self.playlists.active
if pl is None:
return False
track = pl.get_track(index)
if track is None:
return False
pl.current_index = index
if was_playing:
self.play(track.id)
return True
return True
# -- Track controls --
def set_volume(self, track_id: str, volume: float) -> bool:
"""Set per-track volume (0.01.0 linear)."""
return self.playlists.set_volume(track_id, volume)
def set_pan(self, track_id: str, pan: float) -> bool:
"""Set per-track pan (0.0 = L, 0.5 = C, 1.0 = R)."""
return self.playlists.set_pan(track_id, pan)
def set_mute(self, track_id: str, muted: bool) -> bool:
"""Set per-track mute."""
return self.playlists.set_mute(track_id, muted)
def set_solo(self, track_id: str, soloed: bool) -> bool:
"""Set per-track solo."""
return self.playlists.set_solo(track_id, soloed)
def set_play_mode(self, track_id: str, mode: PlayMode) -> bool:
"""Set playback mode (one_shot, loop, segue)."""
return self.playlists.set_play_mode(track_id, mode)
def set_loop_region(self, track_id: str, start: float, end: float = 0.0) -> bool:
"""Set loop region for a track."""
return self.playlists.set_loop_region(track_id, start, end)
# -- Transport control --
def set_tempo(self, bpm: float) -> bool:
"""Set the tempo in BPM (syncs to JACK transport if available)."""
bpm = max(20.0, min(300.0, bpm))
return self.transport.set_tempo(bpm)
# -- Metronome --
def set_metronome_enabled(self, enabled: bool) -> None:
"""Enable/disable the metronome click."""
self.metronome.enabled = enabled
self.config.metronome_enabled = enabled
def set_click_volume(self, volume: float) -> None:
"""Set metronome click volume (0.01.0)."""
self.metronome.set_volume(volume)
self.config.click_volume = volume
# -- Audio buffer generation (called by JACK process callback) --
def process_buffer(self, buffer_size: int | None = None) -> np.ndarray:
"""Generate the next audio buffer for JACK output.
This is the primary audio processing callback. It renders the
active track's audio at the current transport position, applies
per-track controls (volume, pan, mute), layers metronome clicks,
and handles loop/segue logic.
Args:
buffer_size: Frames to generate (default: config.buffer_size).
Returns:
float32 numpy array of shape (buffer_size, 2) — stereo output.
"""
n_frames = buffer_size or self.config.buffer_size
# Get current transport state
transport = self.transport.get_state()
tempo = transport.tempo
# Default: silence
output = np.zeros((n_frames, 2), dtype=np.float32)
with self._lock:
# Handle count-in
if self._count_in_remaining > 0:
count_in_seconds = n_frames / self.config.sample_rate
self._count_in_remaining -= count_in_seconds
# Generate count-in clicks
clicks = self.metronome.generate_stereo_clicks(
self._playback_position, tempo,
bar=1, beat=1,
buffer_size=n_frames,
)
output += clicks
if self._count_in_remaining <= 0:
self._count_in_remaining = 0.0
self._playing = True
self.metronome.stop_count_in()
logger.info("Count-in complete, playback starting")
return output
if not self._playing or self._active_track_id is None:
# Still generate metronome if enabled
if self.metronome.enabled:
clicks = self.metronome.generate_stereo_clicks(
self._playback_position, tempo,
bar=1, beat=1,
buffer_size=n_frames,
)
output += clicks
return output
audio = self._audio_cache.get(self._active_track_id)
track = self.playlists.get_track(self._active_track_id)
if audio is None or track is None:
return output
if track.muted:
return output
# Calculate position within the audio buffer
pos_samples = int(self._playback_position * self.config.sample_rate)
end_samples = pos_samples + n_frames
total_frames = audio.num_frames
if pos_samples >= total_frames:
# Reached end of file
self._handle_track_end(track)
return output
# Extract the slice
available = min(n_frames, total_frames - pos_samples)
chunk = audio.samples[pos_samples:pos_samples + available]
# Build stereo output from chunk
if audio.channels == 1:
# Mono → stereo (center panned)
chunk_stereo = np.zeros((available, 2), dtype=np.float32)
chunk_stereo[:, 0] = chunk[:available]
chunk_stereo[:, 1] = chunk[:available]
else:
# Already stereo
chunk_stereo = np.zeros((available, 2), dtype=np.float32)
chunk_stereo[:, 0] = chunk[:available, 0]
chunk_stereo[:, 1] = chunk[:available, 1]
# Apply pan
if track.pan != 0.5:
left_gain = np.cos(track.pan * np.pi / 2).astype(np.float32)
right_gain = np.sin(track.pan * np.pi / 2).astype(np.float32)
chunk_stereo[:, 0] *= left_gain
chunk_stereo[:, 1] *= right_gain
# Apply volume
chunk_stereo *= track.volume
# Apply fade-in
if track.fade_in > 0 and self._playback_position < track.fade_in:
fade_samples = int(track.fade_in * self.config.sample_rate)
fade_pos = int(self._playback_position * self.config.sample_rate)
for i in range(available):
sample_idx = fade_pos + i
if sample_idx < fade_samples:
gain = sample_idx / fade_samples
chunk_stereo[i] *= gain
# Apply fade-out
if track.fade_out > 0:
time_remaining = track.duration - self._playback_position
if time_remaining < track.fade_out:
fade_samples = int(track.fade_out * self.config.sample_rate)
fade_pos_end = total_frames
for i in range(available):
sample_idx = pos_samples + i
remaining = fade_pos_end - sample_idx
if remaining < fade_samples:
gain = remaining / max(1, fade_samples)
chunk_stereo[i] *= gain
# Place chunk into output buffer
output[:available] += chunk_stereo
# Advance position
self._playback_position += available / self.config.sample_rate
track.position = self._playback_position
# Metronome clicks
if self.metronome.enabled:
clicks = self.metronome.generate_stereo_clicks(
self._playback_position, tempo,
bar=1, beat=1, # TODO: accurate bar/beat from transport
buffer_size=n_frames,
)
output += clicks
# Check if we reached the end
if pos_samples + n_frames >= total_frames:
self._handle_track_end(track)
return output
# -- DSP integration (ParameterRegistry callback) --
def handle_parameter(self, param: MixerParameter, value: float) -> bool:
"""Handle a parameter change from the DSP engine / MIDI.
This is the callback registered with the ParameterRegistry.
Dispatches transport commands and per-track control changes.
Returns:
True if the parameter was handled by this player.
"""
if param.category != ParameterCategory.TRANSPORT:
# Also handle track-level channel parameters
if param.category == ParameterCategory.CHANNEL and param.channel >= 0:
return self._handle_track_param(param, value)
return False
ptype = param.param_type
if ptype == ParameterType.PLAY:
if value > 0.5:
return self.play()
else:
return self.stop()
elif ptype == ParameterType.STOP:
if value > 0.5:
return self.stop()
return True
elif ptype == ParameterType.TEMPO:
return self.set_tempo(value)
elif ptype == ParameterType.TAP_TEMPO:
# Tap tempo — value is ignored; actual tap tempo logic
# would be handled by a higher-level controller
return True
elif ptype == ParameterType.LOOP:
if self._active_track_id:
return self.set_play_mode(
self._active_track_id,
PlayMode.LOOP if value > 0.5 else PlayMode.ONE_SHOT,
)
return False
return False
# -- State query --
@property
def is_playing(self) -> bool:
return self._playing
@property
def is_paused(self) -> bool:
return self._paused
@property
def active_track(self) -> Track | None:
if self._active_track_id:
return self.playlists.get_track(self._active_track_id)
return None
@property
def position(self) -> float:
"""Current playback position in seconds."""
return self._playback_position
def get_state(self) -> dict:
"""Get full player state as a dict for serialization."""
with self._lock:
track_state = None
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track_state = {
"id": track.id,
"title": track.title,
"state": track.state.value,
"position": track.position,
"duration": track.duration,
"volume": track.volume,
"pan": track.pan,
"muted": track.muted,
"soloed": track.soloed,
"play_mode": track.play_mode.value,
}
return {
"playing": self._playing,
"paused": self._paused,
"active_track": track_state,
"position": self._playback_position,
"count_in_remaining": self._count_in_remaining,
"transport": {
"tempo": self.transport.state.tempo,
"state": self.transport.state.state.value,
"jack_available": self.transport.available,
},
"metronome_enabled": self.metronome.enabled,
"playlist": self.playlists.to_dict() if self.playlists.active else {},
"loaded_tracks": list(self._audio_cache.keys()),
}
# ── Internal ─────────────────────────────────────────────────────────
def _handle_track_end(self, track: Track) -> None:
"""Handle when a track reaches its end."""
mode = track.play_mode
if mode == PlayMode.LOOP:
# Loop back
if track.has_loop_region:
self._playback_position = track.loop_start
else:
self._playback_position = track.cue_point
track.state = TrackState.PLAYING
logger.debug("Looping track %s", track.id)
elif mode == PlayMode.SEGUE:
# Auto-advance to next track
track.state = TrackState.FINISHED
logger.info("Track %s finished, auto-advancing", track.id)
pl = self.playlists.active
if pl and self.config.auto_advance:
next_t = pl.next_track()
if next_t:
if not next_t.loaded:
self.load_track(next_t.id)
self._active_track_id = next_t.id
self._playback_position = next_t.cue_point
next_t.state = TrackState.PLAYING
logger.info("Segue to %s", next_t.id)
else:
self._playing = False
track.state = TrackState.FINISHED
logger.info("End of playlist")
else: # ONE_SHOT
track.state = TrackState.FINISHED
self._playing = False
logger.info("Track %s finished (one-shot)", track.id)
def _handle_track_param(self, param: MixerParameter, value: float) -> bool:
"""Handle a channel-level parameter that maps to a track.
Maps mixer channel parameters to backing tracks. Channel 0
maps to the active backing track (simplified; could be extended).
Returns:
True if handled.
"""
# For now, map mixer channel 16+ to backing tracks 0..N
# (channels 015 are regular mixer channels)
track_idx = param.channel - 16
pl = self.playlists.active
if pl is None or track_idx < 0:
return False
track = pl.get_track(track_idx)
if track is None:
return False
ptype = param.param_type
if ptype == ParameterType.VOLUME:
track.volume = max(0.0, min(1.0, value))
return True
elif ptype == ParameterType.PAN:
track.pan = max(0.0, min(1.0, (value + 1.0) / 2.0)) # -1..1 → 0..1
return True
elif ptype == ParameterType.MUTE:
track.muted = value >= 0.5
return True
elif ptype == ParameterType.SOLO:
track.soloed = value >= 0.5
return True
return False
def _on_transport_change(self, state: JACKTransportState) -> None:
"""Callback for JACK transport state changes."""
with self._lock:
if state.state == TransportState.STOPPED and self._playing:
# JACK transport stopped externally — pause our playback
self._playing = False
self._paused = True
logger.info("JACK transport stopped, pausing playback")
elif state.state == TransportState.ROLLING and self._paused:
# JACK transport started — resume if we were paused
# (but only if it wasn't us who started it)
pass
def shutdown(self) -> None:
"""Clean shutdown: stop playback, disconnect JACK, free audio."""
self.stop()
if self._sync_to_transport:
self.transport.stop_polling()
self._audio_cache.clear()
logger.info("BackingTrackPlayer shut down")