"""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")