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

P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning)
P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support)
P2-R3: Plugin manager, categories, blacklist, NAM model support
P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation)
P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store)
P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter)
P5-R1: Touchscreen UI evaluation + main entry point
docs: Audio stack, Carla integration, MIDI support, UI evaluation
tests: Full test suite (292 passing)
This commit is contained in:
2026-05-19 20:39:17 -04:00
parent 7b123762b5
commit 9cd8292acc
97 changed files with 26545 additions and 5 deletions
+52
View File
@@ -0,0 +1,52 @@
"""Backing Track Player — synchronized audio playback engine.
The backing package provides a complete backing track playback system:
- Audio file loading (WAV, FLAC, MP3, AIFF) into numpy arrays
- Playlist / setlist management with ordering and metadata
- Per-track volume, pan, mute, solo, and loop controls
- JACK transport synchronization (tempo, position, start/stop)
- Click track / metronome generator synced to transport tempo
- Count-in before playback start
Integrates with the mixer DSP engine via ParameterRegistry for
MIDI and OSC control of transport and per-track parameters.
"""
from __future__ import annotations
from .types import (
PlayMode,
TrackState,
Track,
Playlist,
BackingPlayerConfig,
)
from .loader import AudioLoader, AudioData, load_audio
from .playlist import PlaylistManager
from .transport import JACKTransport, TransportState, jack_transport_state
from .metronome import Metronome, ClickSound
from .player import BackingTrackPlayer
__all__ = [
# Types
"PlayMode",
"TrackState",
"Track",
"Playlist",
"BackingPlayerConfig",
# Loader
"AudioLoader",
"AudioData",
"load_audio",
# Playlist
"PlaylistManager",
# Transport
"JACKTransport",
"TransportState",
"jack_transport_state",
# Metronome
"Metronome",
"ClickSound",
# Player
"BackingTrackPlayer",
]
+280
View File
@@ -0,0 +1,280 @@
"""Audio file loader — WAV, FLAC, MP3, AIFF → numpy arrays.
Uses soundfile (libsndfile) for WAV/FLAC/AIFF, with ffmpeg fallback
for MP3 and other compressed formats. Loaded audio is stored as float32
numpy arrays normalized to [-1.0, 1.0].
Format support matrix:
WAV — soundfile (native)
FLAC — soundfile (native via libsndfile)
AIFF — soundfile (native)
MP3 — ffmpeg → raw PCM → numpy (soundfile doesn't handle MP3)
"""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
import numpy as np
try:
import soundfile as sf
HAS_SOUNDFILE = True
except ImportError:
HAS_SOUNDFILE = False
logger = logging.getLogger(__name__)
# ── Supported formats ───────────────────────────────────────────────────────
SUPPORTED_EXTENSIONS = {".wav", ".flac", ".aiff", ".aif", ".mp3",
".wave", ".oga", ".ogg", ".m4a"}
# Formats handled natively by soundfile
SNDFILE_FORMATS = {".wav", ".wave", ".flac", ".aiff", ".aif", ".oga", ".ogg"}
# Formats needing ffmpeg decode (or other fallback)
FFMPEG_FORMATS = {".mp3", ".m4a"}
# ── Audio data container ────────────────────────────────────────────────────
@dataclass
class AudioData:
"""Loaded audio data ready for playback.
Audio is stored as float32 numpy array, shape (frames, channels),
normalized to [-1.0, 1.0]. For mono files, shape is (frames,).
"""
samples: np.ndarray # float32, shape (frames,) or (frames, channels)
sample_rate: int = 48000
channels: int = 1
duration: float = 0.0 # seconds
file_path: str = ""
format_name: str = "" # "WAV", "FLAC", etc.
@property
def num_frames(self) -> int:
return len(self.samples)
@property
def is_stereo(self) -> bool:
return self.channels == 2 and self.samples.ndim == 2
def to_mono(self) -> np.ndarray:
"""Return mono mixdown (mean of channels)."""
if self.samples.ndim == 1:
return self.samples
return self.samples.mean(axis=1).astype(np.float32)
# ── Audio loader ────────────────────────────────────────────────────────────
class AudioLoader:
"""Load audio files into AudioData containers.
Handles format detection and dispatch to the appropriate backend.
Thread-safe — each call to load() is independent.
"""
@staticmethod
def load(file_path: str | Path, target_sr: int = 0) -> AudioData:
"""Load an audio file into memory.
Args:
file_path: Path to the audio file.
target_sr: If >0, resample to this sample rate. 0 = keep original.
Returns:
AudioData with samples, metadata.
Raises:
FileNotFoundError: if file doesn't exist.
ValueError: if format is unsupported or file is corrupt.
"""
fp = Path(file_path)
if not fp.exists():
raise FileNotFoundError(f"Audio file not found: {file_path}")
suffix = fp.suffix.lower()
if suffix in SNDFILE_FORMATS and HAS_SOUNDFILE:
return AudioLoader._load_soundfile(fp, target_sr)
elif suffix in FFMPEG_FORMATS:
return AudioLoader._load_ffmpeg(fp, target_sr)
elif suffix in SNDFILE_FORMATS and not HAS_SOUNDFILE:
return AudioLoader._load_ffmpeg(fp, target_sr)
else:
raise ValueError(
f"Unsupported audio format: {suffix}. "
f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
)
@staticmethod
def probe(file_path: str | Path) -> dict:
"""Quick probe of an audio file's metadata without loading samples.
Returns dict with keys: sample_rate, channels, duration, format.
"""
fp = Path(file_path)
if not fp.exists():
raise FileNotFoundError(f"Audio file not found: {file_path}")
suffix = fp.suffix.lower()
if suffix in SNDFILE_FORMATS and HAS_SOUNDFILE:
info = sf.info(str(fp))
return {
"sample_rate": info.samplerate,
"channels": info.channels,
"duration": info.duration,
"format": info.format,
"frames": info.frames,
}
else:
# Use ffprobe for format-agnostic probing
return AudioLoader._ffprobe(fp)
# ── Backends ────────────────────────────────────────────────────────
@staticmethod
def _load_soundfile(fp: Path, target_sr: int) -> AudioData:
"""Load via soundfile (libsndfile)."""
samples, sr = sf.read(str(fp), dtype="float32", always_2d=False)
info = sf.info(str(fp))
if target_sr > 0 and target_sr != sr:
# Simple resampling via linear interpolation for now
# For production, scipy.signal.resample or similar would be used
ratio = target_sr / sr
n_out = int(len(samples) * ratio)
if samples.ndim == 1:
samples = np.interp(
np.linspace(0, len(samples) - 1, n_out),
np.arange(len(samples)), samples
).astype(np.float32)
else:
resampled = np.zeros((n_out, samples.shape[1]), dtype=np.float32)
for ch in range(samples.shape[1]):
resampled[:, ch] = np.interp(
np.linspace(0, len(samples) - 1, n_out),
np.arange(len(samples)), samples[:, ch]
)
samples = resampled
sr = target_sr
channels = 1 if samples.ndim == 1 else samples.shape[1]
duration = len(samples) / sr
logger.info("Loaded %s: %d Hz, %d ch, %.1f s", fp.name, sr, channels, duration)
return AudioData(
samples=samples,
sample_rate=sr,
channels=channels,
duration=duration,
file_path=str(fp),
format_name=info.format,
)
@staticmethod
def _load_ffmpeg(fp: Path, target_sr: int) -> AudioData:
"""Load via ffmpeg → raw PCM → numpy.
Decodes to raw s16le PCM via ffmpeg, then reads into numpy.
Handles MP3, M4A, and fallback for any format ffmpeg supports.
"""
ar = str(target_sr) if target_sr > 0 else "44100"
cmd = [
"ffmpeg", "-v", "error",
"-i", str(fp),
"-f", "s16le",
"-acodec", "pcm_s16le",
"-ar", ar,
"-ac", "2", # always decode to stereo
"-",
]
try:
result = subprocess.run(
cmd, capture_output=True, timeout=120,
)
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace")
raise ValueError(
f"ffmpeg decode failed for {fp.name}: {stderr}"
)
raw = result.stdout
# Convert s16le raw bytes to float32 numpy array
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32)
samples = samples.reshape(-1, 2) # stereo
samples /= 32768.0 # normalize to [-1, 1]
except FileNotFoundError:
raise RuntimeError(
"ffmpeg not found. Install ffmpeg for MP3/AIFF/FLAC support."
)
except subprocess.TimeoutExpired:
raise TimeoutError(f"ffmpeg timed out loading {fp.name}")
sr = int(ar)
duration = len(samples) / sr
channels = 2
logger.info("Loaded %s (via ffmpeg): %d Hz, %d ch, %.1f s",
fp.name, sr, channels, duration)
return AudioData(
samples=samples,
sample_rate=sr,
channels=channels,
duration=duration,
file_path=str(fp),
format_name=fp.suffix.upper().lstrip("."),
)
@staticmethod
def _ffprobe(fp: Path) -> dict:
"""Get audio metadata via ffprobe."""
cmd = [
"ffprobe", "-v", "error",
"-show_entries", "stream=sample_rate,channels,duration,codec_name",
"-of", "default=noprint_wrappers=1",
str(fp),
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10,
)
info = {}
for line in result.stdout.strip().split("\n"):
if "=" in line:
k, v = line.split("=", 1)
info[k] = v
return {
"sample_rate": int(info.get("sample_rate", 0)),
"channels": int(info.get("channels", 0)),
"duration": float(info.get("duration", 0.0)),
"format": info.get("codec_name", "unknown"),
"frames": 0,
}
except Exception:
logger.warning("ffprobe failed for %s", fp.name)
return {
"sample_rate": 0,
"channels": 0,
"duration": 0.0,
"format": "unknown",
"frames": 0,
}
# ── Convenience function ────────────────────────────────────────────────────
def load_audio(file_path: str | Path, target_sr: int = 0) -> AudioData:
"""Load an audio file into memory. Convenience wrapper around AudioLoader."""
return AudioLoader.load(file_path, target_sr)
+345
View File
@@ -0,0 +1,345 @@
"""Click track / metronome generator for the backing track player.
Generates metronome click samples synchronized to the JACK transport
tempo. Supports configurable click sounds (strong/weak beats), count-in,
and time signature awareness. Output is a numpy float32 array suitable
for mixing into the backing track output buffer.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Optional
import numpy as np
logger = logging.getLogger(__name__)
# ── Click sound generation ──────────────────────────────────────────────────
@dataclass
class ClickSound:
"""Parameters for a single click sound."""
frequency: float = 1000.0 # Hz
duration_ms: float = 15.0 # milliseconds
volume: float = 0.5 # 0.0 1.0
waveform: str = "sine" # "sine", "square", "noise", "click"
def generate(self, sample_rate: int) -> np.ndarray:
"""Generate a single click as a numpy array.
Returns:
float32 numpy array of shape (n_samples,) with values in [-1, 1].
"""
n_samples = int(sample_rate * self.duration_ms / 1000.0)
n_samples = max(1, n_samples)
if self.waveform == "sine":
t = np.linspace(0, self.duration_ms / 1000.0, n_samples, endpoint=False,
dtype=np.float32)
samples = np.sin(2 * math.pi * self.frequency * t)
# Apply exponential decay envelope
env = np.exp(-t * (20.0 / (self.duration_ms / 1000.0 + 0.001)))
samples = samples * env.astype(np.float32)
elif self.waveform == "square":
t = np.linspace(0, self.duration_ms / 1000.0, n_samples, endpoint=False,
dtype=np.float32)
samples = np.sign(np.sin(2 * math.pi * self.frequency * t))
env = np.exp(-t * (15.0 / (self.duration_ms / 1000.0 + 0.001)))
samples = samples * env.astype(np.float32)
elif self.waveform == "noise":
rng = np.random.default_rng()
samples = rng.uniform(-1.0, 1.0, n_samples).astype(np.float32)
env = np.exp(-np.linspace(0, 1, n_samples, dtype=np.float32) * 10.0)
samples = samples * env
elif self.waveform == "click":
# Very short impulse — good for electronic music
samples = np.zeros(n_samples, dtype=np.float32)
samples[0] = 1.0
# Apply fast decay
decay = np.exp(-np.arange(n_samples, dtype=np.float32) * 0.5)
samples = samples * decay
else:
samples = np.zeros(n_samples, dtype=np.float32)
return (samples * self.volume).astype(np.float32)
# ── Metronome ───────────────────────────────────────────────────────────────
class Metronome:
"""Synced metronome / click track generator.
Generates click samples for each beat, synchronized to tempo
and transport position. Supports:
- Configurable strong/weak beat sounds
- Count-in bars before transport start
- Arbitrary time signatures
- Pre-roll for seamless buffer scheduling
"""
def __init__(
self,
sample_rate: int = 48000,
click_volume: float = 0.5,
click_freq_strong: float = 1000.0,
click_freq_weak: float = 800.0,
click_duration_ms: float = 15.0,
):
self.sample_rate = sample_rate
# Strong beat (downbeat, beat 1)
self.strong_click = ClickSound(
frequency=click_freq_strong,
duration_ms=click_duration_ms,
volume=click_volume,
waveform="sine",
)
# Weak beat (beats 2, 3, 4, ...)
self.weak_click = ClickSound(
frequency=click_freq_weak,
duration_ms=click_duration_ms * 0.7,
volume=click_volume * 0.8,
waveform="sine",
)
# Count-in settings
self.count_in_bars: int = 2
self.time_sig_num: int = 4 # beats per bar
self.time_sig_den: int = 4 # beat unit
# State
self._enabled: bool = True
self._count_in_active: bool = False
self._count_in_beat: int = 0
self._count_in_bar: int = 0
self._last_beat: int = -1
self._last_bar: int = -1
self._pre_generated_strong: np.ndarray | None = None
self._pre_generated_weak: np.ndarray | None = None
self._pre_gen_sr: int = 0
# ── Public API ───────────────────────────────────────────────────────
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = value
def set_volume(self, volume: float) -> None:
"""Set click volume (0.01.0)."""
v = max(0.0, min(1.0, volume))
self.strong_click.volume = v
self.weak_click.volume = v * 0.8
def start_count_in(self, bars: int | None = None) -> None:
"""Start a count-in before playback.
The count-in beats can be generated via generate_clicks_for_position
until the count-in completes.
Args:
bars: Number of bars to count in. Uses default if None.
"""
beats_per_bar = self.time_sig_num
total_bars = bars if bars is not None else self.count_in_bars
self._count_in_active = True
self._count_in_bar = 0
self._count_in_beat = 0
self._count_in_total_beats = total_bars * beats_per_bar
logger.info("Count-in started: %d bars (%d beats)",
total_bars, self._count_in_total_beats)
def stop_count_in(self) -> None:
"""Cancel count-in."""
self._count_in_active = False
def is_count_in_active(self) -> bool:
"""True if count-in is currently active."""
return self._count_in_active
def generate_click(
self,
beat: int, # 1-based beat number within bar
bar: int = 1, # 1-based bar number
is_count_in: bool = False,
) -> np.ndarray:
"""Generate a click sample for a specific beat.
Args:
beat: Beat number (1 = first beat of bar).
bar: Bar number (1-based).
is_count_in: True if this is a count-in click.
Returns:
float32 numpy array with click samples, or empty array.
"""
if not self._enabled and not is_count_in:
return np.array([], dtype=np.float32)
self._ensure_pre_generated()
# Beat 1 of each bar is the strong (downbeat) click
if beat == 1:
return self._pre_generated_strong.copy() # type: ignore[union-attr]
else:
return self._pre_generated_weak.copy() # type: ignore[union-attr]
def generate_clicks_for_position(
self,
position_seconds: float,
tempo: float,
bar: int,
beat: int,
buffer_size: int,
) -> np.ndarray:
"""Generate click samples for a playback buffer.
Given the current transport position, tempo, and bar/beat,
determine which click(s) fall within the next buffer and
generate them at the correct offsets within the buffer.
Args:
position_seconds: Current transport position in seconds.
tempo: Current tempo in BPM.
bar: Current bar (1-based).
beat: Current beat (1-based, 1.0 = start of beat).
buffer_size: Frames in the output buffer.
Returns:
float32 array of shape (buffer_size,) with clicks mixed in,
or shape (buffer_size, 2) for stereo (clicks centered).
"""
if not self._enabled:
return np.zeros(buffer_size, dtype=np.float32)
beats_per_bar = self.time_sig_num
seconds_per_beat = 60.0 / tempo
samples_per_beat = int(seconds_per_beat * self.sample_rate)
output = np.zeros(buffer_size, dtype=np.float32)
if samples_per_beat <= 0:
return output
# Check if a new beat starts within this buffer
# beat_progress: 0.01.0 within the current beat
beat_progress = beat - int(beat)
samples_until_next_beat = int(
(1.0 - beat_progress) * samples_per_beat
)
# Count-in logic
if self._count_in_active:
if self._count_in_beat >= self._count_in_total_beats:
# Count-in complete
self._count_in_active = False
logger.info("Count-in complete")
else:
# Generate count-in clicks for beats falling in this buffer
current_count_beat = self._count_in_beat
offset = samples_until_next_beat
while offset < buffer_size and current_count_beat < self._count_in_total_beats:
cb = (current_count_beat % beats_per_bar) + 1
click = self.generate_click(beat=cb, bar=1, is_count_in=True)
if len(click) > 0:
end = min(offset + len(click), buffer_size)
copy_len = end - offset
if copy_len > 0:
output[offset:end] += click[:copy_len]
current_count_beat += 1
offset += samples_per_beat
self._count_in_beat = current_count_beat
if current_count_beat >= self._count_in_total_beats:
self._count_in_active = False
self._last_beat = beat
self._last_bar = bar
return output
# Normal metronome operation
# Generate click for the upcoming beat if it starts within this buffer
if samples_until_next_beat < buffer_size:
next_beat = int(beat) + 1
next_bar = bar
if next_beat > beats_per_bar:
next_beat = 1
next_bar = bar + 1
cb = next_beat if next_beat <= beats_per_bar else 1
click = self.generate_click(beat=cb, bar=next_bar)
if len(click) > 0:
start_offset = max(0, samples_until_next_beat)
end = min(start_offset + len(click), buffer_size)
copy_len = end - start_offset
if copy_len > 0:
output[start_offset:end] += click[:copy_len]
# Check if additional beats fall within this buffer
offset = samples_until_next_beat + samples_per_beat
next_beat_accum = int(beat) + 1
next_bar_accum = bar
if next_beat_accum > beats_per_bar:
next_beat_accum = 1
next_bar_accum += 1
while offset < buffer_size:
cb = next_beat_accum if next_beat_accum <= beats_per_bar else 1
click = self.generate_click(beat=cb, bar=next_bar_accum)
if len(click) > 0:
end = min(offset + len(click), buffer_size)
copy_len = end - offset
if copy_len > 0:
output[offset:end] += click[:copy_len]
next_beat_accum += 1
if next_beat_accum > beats_per_bar:
next_beat_accum = 1
next_bar_accum += 1
offset += samples_per_beat
self._last_beat = beat
self._last_bar = bar
return output.astype(np.float32)
def generate_stereo_clicks(
self,
position_seconds: float,
tempo: float,
bar: int,
beat: int,
buffer_size: int,
) -> np.ndarray:
"""Generate stereo click samples (centered).
Returns:
float32 array of shape (buffer_size, 2).
"""
mono = self.generate_clicks_for_position(
position_seconds, tempo, bar, beat, buffer_size,
)
stereo = np.zeros((buffer_size, 2), dtype=np.float32)
if len(mono) > 0:
stereo[:, 0] = mono
stereo[:, 1] = mono
return stereo
def _ensure_pre_generated(self) -> None:
"""Pre-generate click samples if the sample rate changed."""
if self._pre_gen_sr != self.sample_rate or self._pre_generated_strong is None:
self._pre_generated_strong = self.strong_click.generate(self.sample_rate)
self._pre_generated_weak = self.weak_click.generate(self.sample_rate)
self._pre_gen_sr = self.sample_rate
+815
View File
@@ -0,0 +1,815 @@
"""Backing track player — main orchestrator.
The BackingTrackPlayer ties together the audio loader, playlist manager,
JACK transport, and metronome into a cohesive backing track playback engine.
It provides the primary API for:
- Loading tracks into memory
- Starting/stopping/pausing playback
- Per-track volume, pan, mute, solo control
- Loop, one-shot, and segue modes
- JACK transport synchronization
- Click track / metronome output
- Count-in before playback start
Integrates with the mixer's DSPEngine via the ParameterRegistry
callback for MIDI/OSC control of transport and track parameters.
Architecture:
MIDI/OSC → ParameterRegistry → BackingTrackPlayer.handle_parameter()
├── Transport control (play, stop, tempo)
├── Track control (volume, pan, mute, solo)
└── Playlist navigation (next, prev, select)
"""
from __future__ import annotations
import logging
import threading
from typing import Optional
import numpy as np
from ..midi.types import (
MixerParameter,
ParameterType,
ParameterCategory,
)
from .types import (
PlayMode,
TrackState,
Track,
Playlist,
BackingPlayerConfig,
)
from .loader import AudioLoader, AudioData, load_audio
from .playlist import PlaylistManager
from .transport import JACKTransport, TransportState, JACKTransportState
from .metronome import Metronome
logger = logging.getLogger(__name__)
class BackingTrackPlayer:
"""Synchronized backing track playback engine.
Manages loading, playback, and control of backing tracks. Integrates
with JACK transport for synchronization and provides per-track mixer
controls (volume, pan, mute, solo, loop modes).
Thread-safe for concurrent access from MIDI callbacks and UI.
Example usage:
config = BackingPlayerConfig()
player = BackingTrackPlayer(config)
player.load_track("backing_track_1", "/path/to/song.wav")
player.play()
player.set_volume("backing_track_1", 0.8)
player.stop()
"""
# ── Init ─────────────────────────────────────────────────────────────
def __init__(self, config: BackingPlayerConfig | None = None):
self.config = config or BackingPlayerConfig()
# Components
self.loader = AudioLoader()
self.playlists = PlaylistManager()
self.transport = JACKTransport(
sample_rate=self.config.sample_rate,
auto_poll=True,
)
self.metronome = Metronome(
sample_rate=self.config.sample_rate,
click_volume=self.config.click_volume,
click_freq_strong=self.config.click_freq_strong,
click_freq_weak=self.config.click_freq_weak,
click_duration_ms=self.config.click_duration_ms,
)
# Configure metronome
self.metronome.time_sig_num = self.config.time_signature_num
self.metronome.time_sig_den = self.config.time_signature_den
self.metronome.count_in_bars = self.config.count_in_bars
# Loaded audio data cache: track_id → AudioData
self._audio_cache: dict[str, AudioData] = {}
# Transport integration
self._sync_to_transport: bool = self.config.jack_transport_enabled
self._transport_state: TransportState = TransportState.STOPPED
# Playback state
self._playing: bool = False
self._paused: bool = False
self._active_track_id: str | None = None
self._playback_position: float = 0.0 # seconds into active track
self._count_in_remaining: float = 0.0 # seconds of count-in left
# Thread safety
self._lock = threading.Lock()
# DSP integration
self._dsp_engine = self.config.dsp_engine
self._param_callbacks: list = []
# Bind transport callbacks
if self._sync_to_transport:
self.transport.on_transport_change(self._on_transport_change)
logger.info(
"BackingTrackPlayer initialized (sr=%d, buffer=%d, JACK=%s)",
self.config.sample_rate, self.config.buffer_size,
"enabled" if self.transport.available else "unavailable",
)
# ── Public API ───────────────────────────────────────────────────────
# -- Track loading --
def load_track(self, track_id: str, file_path: str | None = None) -> bool:
"""Load a track's audio data into memory.
If file_path is provided, updates the track's file_path.
The track must already exist in the active playlist.
Returns:
True if loaded successfully.
"""
pl = self.playlists.active
if pl is None:
logger.warning("No active playlist")
return False
track = self.playlists.get_track(track_id)
if track is None:
logger.warning("Track not found: %s", track_id)
return False
if file_path:
track.file_path = file_path
if not track.file_path:
logger.warning("No file path for track: %s", track_id)
return False
try:
audio = self.loader.load(track.file_path, self.config.sample_rate)
self._audio_cache[track_id] = audio
# Update track metadata
track.duration = audio.duration
track.sample_rate = audio.sample_rate
track.channels = audio.channels
track.loaded = True
track.state = TrackState.IDLE
logger.info("Loaded track %s: %.1fs, %d ch, %d Hz",
track_id, audio.duration, audio.channels, audio.sample_rate)
return True
except Exception as e:
logger.error("Failed to load track %s: %s", track_id, e)
track.state = TrackState.ERROR
return False
def load_playlist_tracks(self, playlist_name: str | None = None) -> int:
"""Load all tracks in a playlist into memory. Returns count loaded."""
pl = self.playlists.active if playlist_name is None else self.playlists.get(playlist_name)
if pl is None:
return 0
count = 0
for track in pl.tracks:
if not track.loaded and track.file_path:
if self.load_track(track.id):
count += 1
return count
def unload_track(self, track_id: str) -> bool:
"""Free a track's audio data from memory."""
if track_id in self._audio_cache:
del self._audio_cache[track_id]
track = self.playlists.get_track(track_id)
if track:
track.loaded = False
track.state = TrackState.IDLE
return True
return False
def is_loaded(self, track_id: str) -> bool:
"""Check if a track is loaded into memory."""
return track_id in self._audio_cache
# -- Playback control --
def play(self, track_id: str | None = None, count_in: bool = True) -> bool:
"""Start or resume playback.
Args:
track_id: Specific track to play (default: active playlist track).
count_in: If True, pre-roll with count-in clicks before playback.
Returns:
True if playback started.
"""
with self._lock:
pl = self.playlists.active
if pl is None:
logger.warning("No active playlist")
return False
# Resolve track
if track_id:
for i, t in enumerate(pl.tracks):
if t.id == track_id:
pl.current_index = i
break
else:
logger.warning("Track not found: %s", track_id)
return False
track = pl.current_track
if track is None:
logger.warning("No track selected")
return False
# Load if needed
if not track.loaded and track.file_path:
if not self.load_track(track.id):
return False
if track_id not in self._audio_cache:
logger.warning("Track audio not loaded: %s", track.id)
return False
self._active_track_id = track.id
track.state = TrackState.PLAYING
# Reset position to cue point
self._playback_position = track.cue_point
# Count-in
if count_in and self.config.count_in_bars > 0:
self.metronome.start_count_in(self.config.count_in_bars)
beats = self.config.count_in_bars * self.config.time_signature_num
tempo = self.transport.state.tempo
self._count_in_remaining = beats * (60.0 / tempo)
self._playing = False # Will become True after count-in
else:
self._count_in_remaining = 0.0
self._playing = True
self._paused = False
# Sync JACK transport
if self._sync_to_transport and self.transport.available:
self.transport.start()
logger.info("Playing track %s (count-in=%s)", track.id,
"yes" if self._count_in_remaining > 0 else "no")
return True
def stop(self) -> bool:
"""Stop playback."""
with self._lock:
self._playing = False
self._paused = False
self._count_in_remaining = 0.0
self.metronome.stop_count_in()
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track.state = TrackState.STOPPED
track.position = self._playback_position
if self._sync_to_transport and self.transport.available:
self.transport.stop()
logger.info("Playback stopped")
return True
def pause(self) -> bool:
"""Pause playback. Resume with play()."""
with self._lock:
if not self._playing and self._count_in_remaining <= 0:
return False
self._playing = False
self._paused = True
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track.state = TrackState.PAUSED
track.position = self._playback_position
logger.info("Playback paused at %.2fs", self._playback_position)
return True
def resume(self) -> bool:
"""Resume paused playback."""
with self._lock:
if not self._paused:
return False
self._playing = True
self._paused = False
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track.state = TrackState.PLAYING
logger.info("Playback resumed from %.2fs", self._playback_position)
return True
def toggle_play(self) -> bool:
"""Toggle between play and stop/pause."""
if self._playing:
return self.pause() or self.stop()
if self._paused:
return self.resume()
return self.play()
def seek(self, seconds: float) -> bool:
"""Seek to a position in the current track."""
with self._lock:
if not self._active_track_id:
return False
track = self.playlists.get_track(self._active_track_id)
if track is None:
return False
audio = self._audio_cache.get(self._active_track_id)
if audio is None:
return False
seconds = max(0.0, min(seconds, audio.duration))
self._playback_position = seconds
track.position = seconds
if self._sync_to_transport and self.transport.available:
self.transport.locate_seconds(seconds)
logger.debug("Seeked to %.2fs", seconds)
return True
def next_track(self) -> bool:
"""Play the next track in the playlist."""
with self._lock:
was_playing = self._playing or self._paused
self.stop()
pl = self.playlists.active
if pl is None:
return False
track = pl.next_track()
if track is None:
logger.info("End of playlist reached")
return False
if was_playing:
self.play(track.id)
return True
return True
def prev_track(self) -> bool:
"""Play the previous track in the playlist."""
with self._lock:
was_playing = self._playing or self._paused
self.stop()
pl = self.playlists.active
if pl is None:
return False
track = pl.prev_track()
if track is None:
return False
if was_playing:
self.play(track.id)
return True
return True
def select_track(self, index: int) -> bool:
"""Select and play a track by playlist index."""
with self._lock:
was_playing = self._playing or self._paused
self.stop()
pl = self.playlists.active
if pl is None:
return False
track = pl.get_track(index)
if track is None:
return False
pl.current_index = index
if was_playing:
self.play(track.id)
return True
return True
# -- Track controls --
def set_volume(self, track_id: str, volume: float) -> bool:
"""Set per-track volume (0.01.0 linear)."""
return self.playlists.set_volume(track_id, volume)
def set_pan(self, track_id: str, pan: float) -> bool:
"""Set per-track pan (0.0 = L, 0.5 = C, 1.0 = R)."""
return self.playlists.set_pan(track_id, pan)
def set_mute(self, track_id: str, muted: bool) -> bool:
"""Set per-track mute."""
return self.playlists.set_mute(track_id, muted)
def set_solo(self, track_id: str, soloed: bool) -> bool:
"""Set per-track solo."""
return self.playlists.set_solo(track_id, soloed)
def set_play_mode(self, track_id: str, mode: PlayMode) -> bool:
"""Set playback mode (one_shot, loop, segue)."""
return self.playlists.set_play_mode(track_id, mode)
def set_loop_region(self, track_id: str, start: float, end: float = 0.0) -> bool:
"""Set loop region for a track."""
return self.playlists.set_loop_region(track_id, start, end)
# -- Transport control --
def set_tempo(self, bpm: float) -> bool:
"""Set the tempo in BPM (syncs to JACK transport if available)."""
bpm = max(20.0, min(300.0, bpm))
return self.transport.set_tempo(bpm)
# -- Metronome --
def set_metronome_enabled(self, enabled: bool) -> None:
"""Enable/disable the metronome click."""
self.metronome.enabled = enabled
self.config.metronome_enabled = enabled
def set_click_volume(self, volume: float) -> None:
"""Set metronome click volume (0.01.0)."""
self.metronome.set_volume(volume)
self.config.click_volume = volume
# -- Audio buffer generation (called by JACK process callback) --
def process_buffer(self, buffer_size: int | None = None) -> np.ndarray:
"""Generate the next audio buffer for JACK output.
This is the primary audio processing callback. It renders the
active track's audio at the current transport position, applies
per-track controls (volume, pan, mute), layers metronome clicks,
and handles loop/segue logic.
Args:
buffer_size: Frames to generate (default: config.buffer_size).
Returns:
float32 numpy array of shape (buffer_size, 2) — stereo output.
"""
n_frames = buffer_size or self.config.buffer_size
# Get current transport state
transport = self.transport.get_state()
tempo = transport.tempo
# Default: silence
output = np.zeros((n_frames, 2), dtype=np.float32)
with self._lock:
# Handle count-in
if self._count_in_remaining > 0:
count_in_seconds = n_frames / self.config.sample_rate
self._count_in_remaining -= count_in_seconds
# Generate count-in clicks
clicks = self.metronome.generate_stereo_clicks(
self._playback_position, tempo,
bar=1, beat=1,
buffer_size=n_frames,
)
output += clicks
if self._count_in_remaining <= 0:
self._count_in_remaining = 0.0
self._playing = True
self.metronome.stop_count_in()
logger.info("Count-in complete, playback starting")
return output
if not self._playing or self._active_track_id is None:
# Still generate metronome if enabled
if self.metronome.enabled:
clicks = self.metronome.generate_stereo_clicks(
self._playback_position, tempo,
bar=1, beat=1,
buffer_size=n_frames,
)
output += clicks
return output
audio = self._audio_cache.get(self._active_track_id)
track = self.playlists.get_track(self._active_track_id)
if audio is None or track is None:
return output
if track.muted:
return output
# Calculate position within the audio buffer
pos_samples = int(self._playback_position * self.config.sample_rate)
end_samples = pos_samples + n_frames
total_frames = audio.num_frames
if pos_samples >= total_frames:
# Reached end of file
self._handle_track_end(track)
return output
# Extract the slice
available = min(n_frames, total_frames - pos_samples)
chunk = audio.samples[pos_samples:pos_samples + available]
# Build stereo output from chunk
if audio.channels == 1:
# Mono → stereo (center panned)
chunk_stereo = np.zeros((available, 2), dtype=np.float32)
chunk_stereo[:, 0] = chunk[:available]
chunk_stereo[:, 1] = chunk[:available]
else:
# Already stereo
chunk_stereo = np.zeros((available, 2), dtype=np.float32)
chunk_stereo[:, 0] = chunk[:available, 0]
chunk_stereo[:, 1] = chunk[:available, 1]
# Apply pan
if track.pan != 0.5:
left_gain = np.cos(track.pan * np.pi / 2).astype(np.float32)
right_gain = np.sin(track.pan * np.pi / 2).astype(np.float32)
chunk_stereo[:, 0] *= left_gain
chunk_stereo[:, 1] *= right_gain
# Apply volume
chunk_stereo *= track.volume
# Apply fade-in
if track.fade_in > 0 and self._playback_position < track.fade_in:
fade_samples = int(track.fade_in * self.config.sample_rate)
fade_pos = int(self._playback_position * self.config.sample_rate)
for i in range(available):
sample_idx = fade_pos + i
if sample_idx < fade_samples:
gain = sample_idx / fade_samples
chunk_stereo[i] *= gain
# Apply fade-out
if track.fade_out > 0:
time_remaining = track.duration - self._playback_position
if time_remaining < track.fade_out:
fade_samples = int(track.fade_out * self.config.sample_rate)
fade_pos_end = total_frames
for i in range(available):
sample_idx = pos_samples + i
remaining = fade_pos_end - sample_idx
if remaining < fade_samples:
gain = remaining / max(1, fade_samples)
chunk_stereo[i] *= gain
# Place chunk into output buffer
output[:available] += chunk_stereo
# Advance position
self._playback_position += available / self.config.sample_rate
track.position = self._playback_position
# Metronome clicks
if self.metronome.enabled:
clicks = self.metronome.generate_stereo_clicks(
self._playback_position, tempo,
bar=1, beat=1, # TODO: accurate bar/beat from transport
buffer_size=n_frames,
)
output += clicks
# Check if we reached the end
if pos_samples + n_frames >= total_frames:
self._handle_track_end(track)
return output
# -- DSP integration (ParameterRegistry callback) --
def handle_parameter(self, param: MixerParameter, value: float) -> bool:
"""Handle a parameter change from the DSP engine / MIDI.
This is the callback registered with the ParameterRegistry.
Dispatches transport commands and per-track control changes.
Returns:
True if the parameter was handled by this player.
"""
if param.category != ParameterCategory.TRANSPORT:
# Also handle track-level channel parameters
if param.category == ParameterCategory.CHANNEL and param.channel >= 0:
return self._handle_track_param(param, value)
return False
ptype = param.param_type
if ptype == ParameterType.PLAY:
if value > 0.5:
return self.play()
else:
return self.stop()
elif ptype == ParameterType.STOP:
if value > 0.5:
return self.stop()
return True
elif ptype == ParameterType.TEMPO:
return self.set_tempo(value)
elif ptype == ParameterType.TAP_TEMPO:
# Tap tempo — value is ignored; actual tap tempo logic
# would be handled by a higher-level controller
return True
elif ptype == ParameterType.LOOP:
if self._active_track_id:
return self.set_play_mode(
self._active_track_id,
PlayMode.LOOP if value > 0.5 else PlayMode.ONE_SHOT,
)
return False
return False
# -- State query --
@property
def is_playing(self) -> bool:
return self._playing
@property
def is_paused(self) -> bool:
return self._paused
@property
def active_track(self) -> Track | None:
if self._active_track_id:
return self.playlists.get_track(self._active_track_id)
return None
@property
def position(self) -> float:
"""Current playback position in seconds."""
return self._playback_position
def get_state(self) -> dict:
"""Get full player state as a dict for serialization."""
with self._lock:
track_state = None
if self._active_track_id:
track = self.playlists.get_track(self._active_track_id)
if track:
track_state = {
"id": track.id,
"title": track.title,
"state": track.state.value,
"position": track.position,
"duration": track.duration,
"volume": track.volume,
"pan": track.pan,
"muted": track.muted,
"soloed": track.soloed,
"play_mode": track.play_mode.value,
}
return {
"playing": self._playing,
"paused": self._paused,
"active_track": track_state,
"position": self._playback_position,
"count_in_remaining": self._count_in_remaining,
"transport": {
"tempo": self.transport.state.tempo,
"state": self.transport.state.state.value,
"jack_available": self.transport.available,
},
"metronome_enabled": self.metronome.enabled,
"playlist": self.playlists.to_dict() if self.playlists.active else {},
"loaded_tracks": list(self._audio_cache.keys()),
}
# ── Internal ─────────────────────────────────────────────────────────
def _handle_track_end(self, track: Track) -> None:
"""Handle when a track reaches its end."""
mode = track.play_mode
if mode == PlayMode.LOOP:
# Loop back
if track.has_loop_region:
self._playback_position = track.loop_start
else:
self._playback_position = track.cue_point
track.state = TrackState.PLAYING
logger.debug("Looping track %s", track.id)
elif mode == PlayMode.SEGUE:
# Auto-advance to next track
track.state = TrackState.FINISHED
logger.info("Track %s finished, auto-advancing", track.id)
pl = self.playlists.active
if pl and self.config.auto_advance:
next_t = pl.next_track()
if next_t:
if not next_t.loaded:
self.load_track(next_t.id)
self._active_track_id = next_t.id
self._playback_position = next_t.cue_point
next_t.state = TrackState.PLAYING
logger.info("Segue to %s", next_t.id)
else:
self._playing = False
track.state = TrackState.FINISHED
logger.info("End of playlist")
else: # ONE_SHOT
track.state = TrackState.FINISHED
self._playing = False
logger.info("Track %s finished (one-shot)", track.id)
def _handle_track_param(self, param: MixerParameter, value: float) -> bool:
"""Handle a channel-level parameter that maps to a track.
Maps mixer channel parameters to backing tracks. Channel 0
maps to the active backing track (simplified; could be extended).
Returns:
True if handled.
"""
# For now, map mixer channel 16+ to backing tracks 0..N
# (channels 015 are regular mixer channels)
track_idx = param.channel - 16
pl = self.playlists.active
if pl is None or track_idx < 0:
return False
track = pl.get_track(track_idx)
if track is None:
return False
ptype = param.param_type
if ptype == ParameterType.VOLUME:
track.volume = max(0.0, min(1.0, value))
return True
elif ptype == ParameterType.PAN:
track.pan = max(0.0, min(1.0, (value + 1.0) / 2.0)) # -1..1 → 0..1
return True
elif ptype == ParameterType.MUTE:
track.muted = value >= 0.5
return True
elif ptype == ParameterType.SOLO:
track.soloed = value >= 0.5
return True
return False
def _on_transport_change(self, state: JACKTransportState) -> None:
"""Callback for JACK transport state changes."""
with self._lock:
if state.state == TransportState.STOPPED and self._playing:
# JACK transport stopped externally — pause our playback
self._playing = False
self._paused = True
logger.info("JACK transport stopped, pausing playback")
elif state.state == TransportState.ROLLING and self._paused:
# JACK transport started — resume if we were paused
# (but only if it wasn't us who started it)
pass
def shutdown(self) -> None:
"""Clean shutdown: stop playback, disconnect JACK, free audio."""
self.stop()
if self._sync_to_transport:
self.transport.stop_polling()
self._audio_cache.clear()
logger.info("BackingTrackPlayer shut down")
+330
View File
@@ -0,0 +1,330 @@
"""Playlist / setlist manager for backing tracks.
Manages an ordered list of backing tracks with navigation (next/prev/jump),
add/remove/reorder, and per-track control state. Ties into the backing
track player for coordinated playback.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Optional
from .types import PlayMode, Track, TrackState, Playlist
logger = logging.getLogger(__name__)
class PlaylistManager:
"""Manages a setlist of backing tracks.
Provides CRUD operations, ordering, and playback navigation.
Thread-safe for concurrent access from UI and transport callbacks.
"""
def __init__(self) -> None:
self._playlists: dict[str, Playlist] = {}
self._active_playlist_id: str | None = None
# ── Playlist CRUD ────────────────────────────────────────────────────
def create(self, name: str) -> Playlist:
"""Create a new empty playlist."""
pl = Playlist(name=name)
self._playlists[name] = pl
if self._active_playlist_id is None:
self._active_playlist_id = name
logger.info("Created playlist: %s", name)
return pl
def get(self, name: str) -> Playlist | None:
"""Get a playlist by name."""
return self._playlists.get(name)
def delete(self, name: str) -> bool:
"""Delete a playlist. Cannot delete the active playlist."""
if name not in self._playlists:
return False
if name == self._active_playlist_id:
logger.warning("Cannot delete active playlist: %s", name)
return False
del self._playlists[name]
return True
def list_names(self) -> list[str]:
"""Get all playlist names."""
return list(self._playlists.keys())
def set_active(self, name: str) -> bool:
"""Set the active playlist by name."""
if name not in self._playlists:
logger.warning("Playlist not found: %s", name)
return False
self._active_playlist_id = name
return True
@property
def active(self) -> Playlist | None:
"""The currently active playlist."""
if self._active_playlist_id:
return self._playlists.get(self._active_playlist_id)
return None
# ── Track management (on active playlist) ────────────────────────────
def add_track(
self,
file_path: str,
title: str = "",
play_mode: PlayMode = PlayMode.ONE_SHOT,
playlist_name: str | None = None,
) -> str:
"""Add a track to the playlist. Returns the track ID.
Args:
file_path: Path to the audio file.
title: Display name (default: file name).
play_mode: Playback mode.
playlist_name: Target playlist (default: active).
Returns:
The new track's ID string.
"""
pl = self._resolve_playlist(playlist_name)
if pl is None:
raise ValueError("No active playlist")
track_id = f"bt_{len(pl.tracks):03d}"
title = title or Path(file_path).stem
track = Track(
id=track_id,
title=title,
file_path=file_path,
play_mode=play_mode,
)
pl.tracks.append(track)
logger.info("Added track %s: %s", track_id, track.title)
return track_id
def remove_track(self, track_id: str, playlist_name: str | None = None) -> bool:
"""Remove a track by ID."""
pl = self._resolve_playlist(playlist_name)
if pl is None:
return False
for i, t in enumerate(pl.tracks):
if t.id == track_id:
pl.tracks.pop(i)
if pl.current_index >= len(pl.tracks):
pl.current_index = len(pl.tracks) - 1
logger.info("Removed track %s", track_id)
return True
return False
def move_track(
self,
track_id: str,
new_index: int,
playlist_name: str | None = None,
) -> bool:
"""Move a track to a new position in the playlist."""
pl = self._resolve_playlist(playlist_name)
if pl is None:
return False
for i, t in enumerate(pl.tracks):
if t.id == track_id:
track = pl.tracks.pop(i)
new_index = max(0, min(new_index, len(pl.tracks)))
pl.tracks.insert(new_index, track)
return True
return False
def clear(self, playlist_name: str | None = None) -> int:
"""Remove all tracks. Returns number removed."""
pl = self._resolve_playlist(playlist_name)
if pl is None:
return 0
count = len(pl.tracks)
pl.tracks.clear()
pl.current_index = -1
return count
def get_track(self, track_id: str, playlist_name: str | None = None) -> Track | None:
"""Get a track by ID."""
pl = self._resolve_playlist(playlist_name)
if pl is None:
return None
for t in pl.tracks:
if t.id == track_id:
return t
return None
# ── Track controls ───────────────────────────────────────────────────
def set_volume(self, track_id: str, volume: float) -> bool:
"""Set per-track volume (0.01.0)."""
t = self.get_track(track_id)
if t:
t.volume = max(0.0, min(1.0, volume))
return True
return False
def set_pan(self, track_id: str, pan: float) -> bool:
"""Set per-track pan (0.0 = left, 0.5 = center, 1.0 = right)."""
t = self.get_track(track_id)
if t:
t.pan = max(0.0, min(1.0, pan))
return True
return False
def set_mute(self, track_id: str, muted: bool) -> bool:
"""Set per-track mute."""
t = self.get_track(track_id)
if t:
t.muted = muted
return True
return False
def set_solo(self, track_id: str, soloed: bool) -> bool:
"""Set per-track solo."""
t = self.get_track(track_id)
if t:
t.soloed = soloed
return True
return False
def set_play_mode(self, track_id: str, mode: PlayMode) -> bool:
"""Set playback mode."""
t = self.get_track(track_id)
if t:
t.play_mode = mode
return True
return False
def set_cue_point(self, track_id: str, seconds: float) -> bool:
"""Set start cue point."""
t = self.get_track(track_id)
if t:
t.cue_point = max(0.0, seconds)
return True
return False
def set_loop_region(self, track_id: str, start: float, end: float = 0.0) -> bool:
"""Set loop region in seconds."""
t = self.get_track(track_id)
if t:
t.loop_start = max(0.0, start)
t.loop_end = max(0.0, end)
return True
return False
# ── Playback navigation ──────────────────────────────────────────────
def select_track(self, index: int, playlist_name: str | None = None) -> Track | None:
"""Select a track by index (0-based)."""
pl = self._resolve_playlist(playlist_name)
if pl is None or not (0 <= index < len(pl.tracks)):
return None
pl.current_index = index
return pl.tracks[index]
def next_track(self, playlist_name: str | None = None) -> Track | None:
"""Advance to next track."""
pl = self._resolve_playlist(playlist_name)
if pl is None:
return None
return pl.next_track()
def prev_track(self, playlist_name: str | None = None) -> Track | None:
"""Go to previous track."""
pl = self._resolve_playlist(playlist_name)
if pl is None:
return None
return pl.prev_track()
# ── Serialization ────────────────────────────────────────────────────
def to_dict(self, playlist_name: str | None = None) -> dict:
"""Serialize a playlist to a dict."""
pl = self._resolve_playlist(playlist_name)
if pl is None:
return {}
return {
"name": pl.name,
"current_index": pl.current_index,
"tracks": [
{
"id": t.id,
"title": t.title,
"artist": t.artist,
"file_path": t.file_path,
"duration": t.duration,
"sample_rate": t.sample_rate,
"channels": t.channels,
"play_mode": t.play_mode.value,
"volume": t.volume,
"pan": t.pan,
"muted": t.muted,
"soloed": t.soloed,
"cue_point": t.cue_point,
"loop_start": t.loop_start,
"loop_end": t.loop_end,
"fade_in": t.fade_in,
"fade_out": t.fade_out,
}
for t in pl.tracks
],
}
def to_json(self, playlist_name: str | None = None, indent: int = 2) -> str:
"""Serialize to JSON string."""
return json.dumps(self.to_dict(playlist_name), indent=indent)
def from_dict(self, data: dict) -> Playlist:
"""Restore a playlist from a dict."""
pl = Playlist(
name=data.get("name", "Untitled"),
current_index=data.get("current_index", -1),
)
for td in data.get("tracks", []):
t = Track(
id=td.get("id", ""),
title=td.get("title", ""),
artist=td.get("artist", ""),
file_path=td.get("file_path", ""),
duration=td.get("duration", 0.0),
sample_rate=td.get("sample_rate", 0),
channels=td.get("channels", 0),
play_mode=PlayMode(td.get("play_mode", "one_shot")),
volume=td.get("volume", 1.0),
pan=td.get("pan", 0.5),
muted=td.get("muted", False),
soloed=td.get("soloed", False),
cue_point=td.get("cue_point", 0.0),
loop_start=td.get("loop_start", 0.0),
loop_end=td.get("loop_end", 0.0),
fade_in=td.get("fade_in", 0.0),
fade_out=td.get("fade_out", 0.0),
)
pl.tracks.append(t)
self._playlists[pl.name] = pl
if self._active_playlist_id is None:
self._active_playlist_id = pl.name
return pl
def from_json(self, json_str: str) -> Playlist:
"""Restore a playlist from JSON string."""
return self.from_dict(json.loads(json_str))
# ── Helpers ──────────────────────────────────────────────────────────
def _resolve_playlist(self, name: str | None) -> Playlist | None:
"""Get playlist by name, falling back to active."""
if name:
return self._playlists.get(name)
return self.active
+312
View File
@@ -0,0 +1,312 @@
"""JACK Transport synchronization for the backing track player.
Interacts with the JACK transport system via jack_transport CLI and
jack_metro for metronome sync. Exposes transport state (tempo, position,
rolling state) and provides tempo change notifications.
On systems without a running JACK server, all operations degrade
gracefully — transport state returns safe defaults and CLI calls
are silently skipped.
"""
from __future__ import annotations
import logging
import subprocess
import threading
import time
from dataclasses import dataclass
from enum import StrEnum
from typing import Optional, Callable
logger = logging.getLogger(__name__)
# ── Transport state ─────────────────────────────────────────────────────────
class TransportState(StrEnum):
"""JACK transport state."""
STOPPED = "stopped"
ROLLING = "rolling" # playing
STARTING = "starting" # in count-in / pre-roll
UNKNOWN = "unknown"
@dataclass
class JACKTransportState:
"""Snapshot of JACK transport state."""
state: TransportState = TransportState.UNKNOWN
tempo: float = 120.0 # BPM
position: float = 0.0 # seconds (frame / sample_rate)
frame: int = 0 # transport frame
sample_rate: int = 48000
bar: int = 0 # current bar (1-based)
beat: int = 0 # current beat within bar (1-based)
tick: int = 0 # current tick within beat (0-based)
beats_per_bar: float = 4.0
beat_type: float = 4.0
updated_at: float = 0.0 # monotonic timestamp of this snapshot
# ── JACK Transport client ──────────────────────────────────────────────────
class JACKTransport:
"""JACK transport synchronization client.
Communicates with the JACK server via CLI tools. Designed to run
on a Raspberry Pi with jackd running. Degrades gracefully when
JACK is not available.
Thread-safe: uses a lock for state and supports callbacks for
transport changes.
"""
def __init__(
self,
sample_rate: int = 48000,
poll_interval: float = 0.1,
auto_poll: bool = False,
):
self._sample_rate = sample_rate
self._poll_interval = poll_interval
self._state = JACKTransportState(sample_rate=sample_rate)
self._lock = threading.Lock()
self._polling = False
self._poll_thread: threading.Thread | None = None
self._callbacks: list[Callable[[JACKTransportState], None]] = []
# Check JACK availability
self._jack_available = self._check_jack()
# ── Public API ───────────────────────────────────────────────────────
@property
def available(self) -> bool:
"""True if JACK server is running and reachable."""
return self._jack_available
@property
def state(self) -> JACKTransportState:
"""Current transport state snapshot."""
with self._lock:
return self._state
def get_state(self) -> JACKTransportState:
"""Poll and return current transport state."""
if not self._jack_available:
return self._state
st = self._poll_transport()
with self._lock:
old_state = self._state.state
old_tempo = self._state.tempo
self._state = st
# Notify callbacks on state change
if st.state != old_state or abs(st.tempo - old_tempo) > 0.01:
self._notify_callbacks(st)
return st
# ── Transport control ────────────────────────────────────────────────
def start(self) -> bool:
"""Start JACK transport rolling."""
return self._jack_cmd(["jack_transport", "start"])
def stop(self) -> bool:
"""Stop JACK transport."""
return self._jack_cmd(["jack_transport", "stop"])
def toggle(self) -> bool:
"""Toggle play/stop."""
st = self.get_state()
if st.state == TransportState.ROLLING:
return self.stop()
return self.start()
def locate(self, frame: int) -> bool:
"""Seek transport to a specific frame."""
if not self._jack_available:
with self._lock:
self._state.frame = frame
self._state.position = frame / self._sample_rate
return True
return self._jack_cmd(["jack_transport", "locate", str(frame)])
def locate_seconds(self, seconds: float) -> bool:
"""Seek transport to a time in seconds."""
return self.locate(int(seconds * self._sample_rate))
def set_tempo(self, bpm: float) -> bool:
"""Set the JACK transport tempo (BPM).
Uses jack_transport tempo if available, otherwise sets locally.
"""
if not self._jack_available:
with self._lock:
self._state.tempo = bpm
return True
# jack_transport doesn't have a direct tempo set; use jack_property
ok = self._jack_cmd(
["jack_property", "-s", "-p", str(bpm), "tempo", "double"]
)
if ok:
with self._lock:
self._state.tempo = bpm
return ok
# ── Callbacks ────────────────────────────────────────────────────────
def on_transport_change(self, callback: Callable[[JACKTransportState], None]) -> None:
"""Register a callback for transport state changes."""
self._callbacks.append(callback)
def remove_callback(self, callback: Callable) -> None:
"""Remove a previously registered callback."""
try:
self._callbacks.remove(callback)
except ValueError:
pass
# ── Polling (background) ─────────────────────────────────────────────
def start_polling(self) -> None:
"""Start background transport polling thread."""
if self._polling or not self._jack_available:
return
self._polling = True
self._poll_thread = threading.Thread(
target=self._poll_loop, daemon=True, name="jack-transport-poll"
)
self._poll_thread.start()
logger.info("JACK transport polling started")
def stop_polling(self) -> None:
"""Stop background polling thread."""
self._polling = False
if self._poll_thread:
self._poll_thread.join(timeout=2.0)
self._poll_thread = None
logger.info("JACK transport polling stopped")
# ── Internal ─────────────────────────────────────────────────────────
def _check_jack(self) -> bool:
"""Check if JACK server is running."""
try:
result = subprocess.run(
["jack_transport"],
capture_output=True, text=True, timeout=3,
)
# Even if it returns non-zero, if we get output it's likely
# just printing usage because we didn't pass a subcommand
return True
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
logger.debug("JACK transport not available")
return False
def _poll_transport(self) -> JACKTransportState:
"""Query JACK transport state via CLI."""
st = JACKTransportState(
state=TransportState.UNKNOWN,
sample_rate=self._sample_rate,
updated_at=time.monotonic(),
)
if not self._jack_available:
# Return last known state with updated timestamp
with self._lock:
s = self._state
s.updated_at = time.monotonic()
return s
try:
# Query tempo
tempo_result = subprocess.run(
["jack_property", "-l"],
capture_output=True, text=True, timeout=3,
)
tempo = 120.0
if tempo_result.returncode == 0:
for line in tempo_result.stdout.strip().split("\n"):
if "tempo" in line.lower():
try:
# Parse "tempo double 120.0" format
parts = line.split()
for i, p in enumerate(parts):
if p == "tempo" and i + 2 < len(parts):
tempo = float(parts[i + 2])
break
except (ValueError, IndexError):
pass
st.tempo = tempo
# Check if transport is rolling
rolling_result = subprocess.run(
["jack_transport"],
capture_output=True, text=True, timeout=3,
)
output = rolling_result.stdout.lower()
if "rolling" in output:
st.state = TransportState.ROLLING
elif "stopped" in output:
st.state = TransportState.STOPPED
# Try to get frame position (jack_transport doesn't always
# expose this easily; jack-showtime or custom tool)
try:
frame_result = subprocess.run(
["jack_transport", "dump"],
capture_output=True, text=True, timeout=3,
)
# Parse frame from output if available
for line in frame_result.stdout.strip().split("\n"):
if "frame" in line.lower():
try:
st.frame = int(line.split()[-1])
st.position = st.frame / self._sample_rate
except (ValueError, IndexError):
pass
except Exception:
pass
except subprocess.TimeoutExpired:
logger.warning("JACK transport query timed out")
except Exception as e:
logger.debug("JACK transport query error: %s", e)
return st
def _jack_cmd(self, cmd: list[str]) -> bool:
"""Run a JACK CLI command. Returns True on success."""
if not self._jack_available:
return False
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
logger.debug("JACK command failed: %s%s", cmd, e)
return False
def _notify_callbacks(self, state: JACKTransportState) -> None:
"""Notify all registered callbacks of a transport change."""
for cb in self._callbacks:
try:
cb(state)
except Exception:
logger.exception("Transport callback error")
def _poll_loop(self) -> None:
"""Background poll loop."""
while self._polling:
self.get_state()
time.sleep(self._poll_interval)
# ── Convenience function ────────────────────────────────────────────────────
def jack_transport_state() -> JACKTransportState:
"""Quick one-shot query of current JACK transport state."""
transport = JACKTransport()
return transport.get_state()
+165
View File
@@ -0,0 +1,165 @@
"""Backing track player types — enums and dataclasses."""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from pathlib import Path
# ── Playback modes ──────────────────────────────────────────────────────────
class PlayMode(enum.StrEnum):
"""Playback mode for a backing track."""
ONE_SHOT = "one_shot" # play once, stop at end
LOOP = "loop" # loop continuously
SEGUE = "segue" # play once, then auto-advance to next track
class TrackState(enum.StrEnum):
"""Current state of a track in the player."""
IDLE = "idle" # loaded, not playing
PLAYING = "playing" # actively playing
PAUSED = "paused" # paused mid-playback
STOPPED = "stopped" # manually stopped
FINISHED = "finished" # reached end (one-shot/segue)
ERROR = "error" # load or playback error
# ── Backing track ───────────────────────────────────────────────────────────
@dataclass
class Track:
"""A single backing track entry in a playlist.
Holds the file reference, playback mode, and per-track mixer
controls that can be automated via MIDI or OSC.
"""
# Identity
id: str # unique track id (e.g. "bt_001")
title: str = "" # display name
artist: str = "" # optional artist
file_path: str = "" # path to audio file
# Audio metadata (populated after load)
duration: float = 0.0 # seconds
sample_rate: int = 0 # Hz
channels: int = 0 # 1=mono, 2=stereo
loaded: bool = False # True when audio data is in memory
# Playback mode
play_mode: PlayMode = PlayMode.ONE_SHOT
# Per-track mixer controls (0.01.0 unless noted)
volume: float = 1.0 # 0.0 1.0 linear
pan: float = 0.5 # 0.0 (L) 1.0 (R), 0.5 = center
muted: bool = False
soloed: bool = False
# Transport control
cue_point: float = 0.0 # start offset in seconds
loop_start: float = 0.0 # loop region start (seconds)
loop_end: float = 0.0 # loop region end (0 = end of file)
fade_in: float = 0.0 # fade-in duration (seconds)
fade_out: float = 0.0 # fade-out duration (seconds)
# State (runtime)
state: TrackState = TrackState.IDLE
position: float = 0.0 # current playback position (seconds)
@property
def file_name(self) -> str:
"""Just the file name, not the full path."""
return Path(self.file_path).name if self.file_path else ""
@property
def has_loop_region(self) -> bool:
"""True if a custom loop region is defined."""
return self.loop_end > 0 and self.loop_end > self.loop_start
@property
def effective_loop_end(self) -> float:
"""The effective end of loop (file duration if not set)."""
if self.loop_end > 0:
return self.loop_end
return self.duration
# ── Playlist / Setlist ──────────────────────────────────────────────────────
@dataclass
class Playlist:
"""An ordered collection of backing tracks (a setlist)."""
name: str = "Untitled Playlist"
tracks: list[Track] = field(default_factory=list)
current_index: int = -1 # index of active track (-1 = none)
@property
def current_track(self) -> Track | None:
"""The currently active track, or None."""
if 0 <= self.current_index < len(self.tracks):
return self.tracks[self.current_index]
return None
@property
def track_count(self) -> int:
return len(self.tracks)
@property
def total_duration(self) -> float:
"""Sum of all track durations."""
return sum(t.duration for t in self.tracks)
def get_track(self, index: int) -> Track | None:
"""Get track at index, or None if out of bounds."""
if 0 <= index < len(self.tracks):
return self.tracks[index]
return None
def next_track(self) -> Track | None:
"""Advance to next track and return it, or None at end."""
if self.current_index + 1 < len(self.tracks):
self.current_index += 1
return self.tracks[self.current_index]
return None
def prev_track(self) -> Track | None:
"""Go to previous track and return it, or None at start."""
if self.current_index > 0:
self.current_index -= 1
return self.tracks[self.current_index]
return None
# ── Player configuration ────────────────────────────────────────────────────
@dataclass
class BackingPlayerConfig:
"""Configuration for the backing track player."""
# Audio
sample_rate: int = 48000 # output sample rate
buffer_size: int = 256 # frames per buffer
channels: int = 2 # stereo output
# JACK
jack_client_name: str = "pi_mixer_backing"
jack_port_prefix: str = "pi_mixer" # prefix for JACK port names
jack_transport_enabled: bool = True
# Metronome
metronome_enabled: bool = True
click_volume: float = 0.5 # 0.0 1.0
click_freq_strong: float = 1000.0 # Hz for downbeat
click_freq_weak: float = 800.0 # Hz for off-beats
click_duration_ms: float = 15.0 # click tone duration
# Count-in
count_in_bars: int = 2 # bars of count-in before playback
time_signature_num: int = 4 # e.g. 4/4
time_signature_den: int = 4
# Playlist
auto_advance: bool = True # auto-advance on track finish (segue)
# DSP integration
dsp_engine: object | None = None # reference to DSPEngine for parameter dispatch