Files
raspberry-pi-mixer/src/backing/player.py
T
shawn b79d7288c5
Lint & Validate / lint (push) Has been cancelled
P5-R3: Web app frontend — touch-optimized mixer UI
- Complete SPA rewrite: 4 tabs (Mixer, Routing, Plugins, Sessions)
- Touch-optimized faders with animated level meters (dark theme)
- Channel strips: fader, mute, solo, pan, EQ modal with 3-band EQ + compressor + gate
- Routing matrix: drag-and-drop source→destination, connection list with mute toggles
- Plugin control: per-channel plugin cards with bypass and parameter sliders
- Session browser: list/load/save/delete scenes via REST API
- Transport controls: play, stop, record, loop, locate (keyboard shortcuts: space=play)
- Responsive: phone portrait (touch-first), landscape, tablet, desktop
- Zero dependencies: vanilla JS + WebSocket only
- Backend: added DELETE /api/v1/scenes/{name} endpoint, wired delete_scene callback

web/app.js (917 lines), web/style.css (1100 lines), web/index.html (160 lines)
2026-05-19 21:05:43 -04:00

828 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (reentrant — play() may be called from next_track() etc.)
self._lock = threading.RLock()
# 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:
# Auto-select first track if none selected
if len(pl.tracks) > 0:
pl.current_index = 0
track = pl.tracks[0]
else:
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
self._active_track_id = track.id
if was_playing:
self.play(track.id, count_in=False)
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
self._active_track_id = track.id
if was_playing:
self.play(track.id, count_in=False)
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
self._active_track_id = track.id
if was_playing:
self.play(track.id, count_in=False)
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)
# Fall back to playlist's current track if nothing actively playing
pl = self.playlists.active
if pl is not None:
return pl.current_track
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")