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,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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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.0–1.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.0–1.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
|
||||
@@ -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")
|
||||
@@ -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.0–1.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
|
||||
@@ -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()
|
||||
@@ -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.0–1.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
|
||||
Reference in New Issue
Block a user