b79d7288c5
Lint & Validate / lint (push) Has been cancelled
- 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)
828 lines
29 KiB
Python
828 lines
29 KiB
Python
"""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.0–1.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.0–1.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 0–15 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")
|