Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
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:
@@ -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.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)
|
||||
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")
|
||||
Reference in New Issue
Block a user