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
|
||||
@@ -0,0 +1,53 @@
|
||||
"""MIDI subsystem for Raspberry Pi RT Audio Mixer.
|
||||
|
||||
Provides USB MIDI device discovery, mapping engine, learn mode,
|
||||
clock sync, and JACK MIDI bridge.
|
||||
"""
|
||||
|
||||
from .types import (
|
||||
MIDIMessage,
|
||||
MIDIMessageType,
|
||||
MIDIMapping,
|
||||
MixerParameter,
|
||||
ParameterCategory,
|
||||
ParameterType,
|
||||
)
|
||||
from .midi_engine import MIDIEngine
|
||||
from .midi_learn import MIDILearn, LearnState
|
||||
from .midi_clock import MIDIClock
|
||||
from .mapping_store import (
|
||||
save_mappings,
|
||||
load_mappings,
|
||||
list_sessions,
|
||||
delete_session,
|
||||
)
|
||||
from .jack_midi_bridge import JACKMIDIBridge
|
||||
from .device_discovery import discover_all, MIDIDevice
|
||||
from .controllers import (
|
||||
KNOWN_CONTROLLERS,
|
||||
find_controller,
|
||||
find_controller_by_alsa,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MIDIMessage",
|
||||
"MIDIMessageType",
|
||||
"MIDIMapping",
|
||||
"MixerParameter",
|
||||
"ParameterCategory",
|
||||
"ParameterType",
|
||||
"MIDIEngine",
|
||||
"MIDILearn",
|
||||
"LearnState",
|
||||
"MIDIClock",
|
||||
"save_mappings",
|
||||
"load_mappings",
|
||||
"list_sessions",
|
||||
"delete_session",
|
||||
"JACKMIDIBridge",
|
||||
"discover_all",
|
||||
"MIDIDevice",
|
||||
"KNOWN_CONTROLLERS",
|
||||
"find_controller",
|
||||
"find_controller_by_alsa",
|
||||
]
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Supported MIDI controller database.
|
||||
|
||||
Pre-defined profiles for common USB MIDI controllers. Each profile
|
||||
maps physical controls to MIDI CC/NRPN numbers. Profiles are used
|
||||
to auto-populate initial mappings and for device identification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControllerControl:
|
||||
"""Describes one physical control on a MIDI controller."""
|
||||
name: str
|
||||
cc_number: int # MIDI CC number (or -1 for NRPN)
|
||||
channel: int = 0 # MIDI channel (0–15, -1 = omni)
|
||||
control_type: str = "fader" # fader, knob, button, encoder, transport
|
||||
is_nrpn: bool = False
|
||||
nrpn_number: int = 0
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControllerProfile:
|
||||
"""Profile describing a known MIDI controller's control layout."""
|
||||
manufacturer: str
|
||||
model: str
|
||||
usb_vendor_id: int # USB VID (hex)
|
||||
usb_product_id: int # USB PID (hex)
|
||||
alsa_client_pattern: str = "" # Substring to match in ALSA client name
|
||||
num_faders: int = 0
|
||||
num_knobs: int = 0
|
||||
num_buttons: int = 0
|
||||
has_transport: bool = False
|
||||
has_jog_wheel: bool = False
|
||||
has_scribble_strips: bool = False
|
||||
controls: list[ControllerControl] = field(default_factory=list)
|
||||
midi_channel_count: int = 1
|
||||
notes: str = ""
|
||||
|
||||
def find_control(self, cc: int, channel: int = 0) -> ControllerControl | None:
|
||||
for ctrl in self.controls:
|
||||
if ctrl.cc_number == cc and (ctrl.channel == channel or ctrl.channel < 0):
|
||||
return ctrl
|
||||
return None
|
||||
|
||||
|
||||
# ── Known controllers ───────────────────────────────────────────────────────
|
||||
|
||||
KNOWN_CONTROLLERS: list[ControllerProfile] = [
|
||||
ControllerProfile(
|
||||
manufacturer="Behringer",
|
||||
model="X-Touch Compact",
|
||||
usb_vendor_id=0x1397,
|
||||
usb_product_id=0x00B4,
|
||||
alsa_client_pattern="X-TOUCH COMPACT",
|
||||
num_faders=9,
|
||||
num_knobs=16,
|
||||
num_buttons=39,
|
||||
has_transport=True,
|
||||
has_jog_wheel=False,
|
||||
midi_channel_count=1,
|
||||
notes="MCU-compatible mode preferred. Use MIDI channel 1.",
|
||||
controls=[
|
||||
# 9 motorised faders
|
||||
ControllerControl("Fader 1", 70, 0, "fader"),
|
||||
ControllerControl("Fader 2", 71, 0, "fader"),
|
||||
ControllerControl("Fader 3", 72, 0, "fader"),
|
||||
ControllerControl("Fader 4", 73, 0, "fader"),
|
||||
ControllerControl("Fader 5", 74, 0, "fader"),
|
||||
ControllerControl("Fader 6", 75, 0, "fader"),
|
||||
ControllerControl("Fader 7", 76, 0, "fader"),
|
||||
ControllerControl("Fader 8", 77, 0, "fader"),
|
||||
ControllerControl("Master", 78, 0, "fader"),
|
||||
# 16 rotary encoders (top row)
|
||||
ControllerControl("Encoder 1", 10, 0, "encoder"),
|
||||
ControllerControl("Encoder 2", 11, 0, "encoder"),
|
||||
ControllerControl("Encoder 3", 12, 0, "encoder"),
|
||||
ControllerControl("Encoder 4", 13, 0, "encoder"),
|
||||
ControllerControl("Encoder 5", 14, 0, "encoder"),
|
||||
ControllerControl("Encoder 6", 15, 0, "encoder"),
|
||||
ControllerControl("Encoder 7", 16, 0, "encoder"),
|
||||
ControllerControl("Encoder 8", 17, 0, "encoder"),
|
||||
# Bottom encoders
|
||||
ControllerControl("Encoder 9", 18, 0, "encoder"),
|
||||
ControllerControl("Encoder 10", 19, 0, "encoder"),
|
||||
ControllerControl("Encoder 11", 20, 0, "encoder"),
|
||||
ControllerControl("Encoder 12", 21, 0, "encoder"),
|
||||
ControllerControl("Encoder 13", 22, 0, "encoder"),
|
||||
ControllerControl("Encoder 14", 23, 0, "encoder"),
|
||||
ControllerControl("Encoder 15", 24, 0, "encoder"),
|
||||
ControllerControl("Encoder 16", 25, 0, "encoder"),
|
||||
# Buttons per channel
|
||||
ControllerControl("Rec 1", 0, 0, "button"),
|
||||
ControllerControl("Rec 2", 1, 0, "button"),
|
||||
ControllerControl("Rec 3", 2, 0, "button"),
|
||||
ControllerControl("Rec 4", 3, 0, "button"),
|
||||
ControllerControl("Rec 5", 4, 0, "button"),
|
||||
ControllerControl("Rec 6", 5, 0, "button"),
|
||||
ControllerControl("Rec 7", 6, 0, "button"),
|
||||
ControllerControl("Rec 8", 7, 0, "button"),
|
||||
ControllerControl("Solo 1", 8, 0, "button"),
|
||||
ControllerControl("Solo 2", 9, 0, "button"),
|
||||
ControllerControl("Solo 3", 10, 0, "button"),
|
||||
ControllerControl("Solo 4", 11, 0, "button"),
|
||||
# Transport
|
||||
ControllerControl("Play", 114, 0, "transport"),
|
||||
ControllerControl("Stop", 115, 0, "transport"),
|
||||
ControllerControl("Rec", 116, 0, "transport"),
|
||||
ControllerControl("Rewind", 117, 0, "transport"),
|
||||
ControllerControl("FFwd", 118, 0, "transport"),
|
||||
ControllerControl("Loop", 119, 0, "transport"),
|
||||
],
|
||||
),
|
||||
|
||||
ControllerProfile(
|
||||
manufacturer="Behringer",
|
||||
model="X-Touch (MCU mode)",
|
||||
usb_vendor_id=0x1397,
|
||||
usb_product_id=0x00B5,
|
||||
alsa_client_pattern="X-TOUCH",
|
||||
num_faders=9,
|
||||
num_knobs=8,
|
||||
num_buttons=92,
|
||||
has_transport=True,
|
||||
has_jog_wheel=True,
|
||||
has_scribble_strips=True,
|
||||
midi_channel_count=1,
|
||||
notes="Standard Mackie Control Universal protocol. 9 motorised faders, V-Pots, jog wheel.",
|
||||
controls=[
|
||||
ControllerControl("Fader 1", 0, 0, "fader"),
|
||||
ControllerControl("Fader 2", 1, 0, "fader"),
|
||||
ControllerControl("Fader 3", 2, 0, "fader"),
|
||||
ControllerControl("Fader 4", 3, 0, "fader"),
|
||||
ControllerControl("Fader 5", 4, 0, "fader"),
|
||||
ControllerControl("Fader 6", 5, 0, "fader"),
|
||||
ControllerControl("Fader 7", 6, 0, "fader"),
|
||||
ControllerControl("Fader 8", 7, 0, "fader"),
|
||||
ControllerControl("Master", 8, 0, "fader"),
|
||||
# V-Pots (relative encoders)
|
||||
ControllerControl("V-Pot 1", 16, 0, "encoder"),
|
||||
ControllerControl("V-Pot 2", 17, 0, "encoder"),
|
||||
ControllerControl("V-Pot 3", 18, 0, "encoder"),
|
||||
ControllerControl("V-Pot 4", 19, 0, "encoder"),
|
||||
ControllerControl("V-Pot 5", 20, 0, "encoder"),
|
||||
ControllerControl("V-Pot 6", 21, 0, "encoder"),
|
||||
ControllerControl("V-Pot 7", 22, 0, "encoder"),
|
||||
ControllerControl("V-Pot 8", 23, 0, "encoder"),
|
||||
# Transport
|
||||
ControllerControl("Play", 94, 0, "transport"),
|
||||
ControllerControl("Stop", 93, 0, "transport"),
|
||||
ControllerControl("Rec", 95, 0, "transport"),
|
||||
ControllerControl("Rewind", 91, 0, "transport"),
|
||||
ControllerControl("FFwd", 92, 0, "transport"),
|
||||
],
|
||||
),
|
||||
|
||||
ControllerProfile(
|
||||
manufacturer="FaderFox",
|
||||
model="UC4",
|
||||
usb_vendor_id=0x16D0,
|
||||
usb_product_id=0x0D27,
|
||||
alsa_client_pattern="Faderfox UC4",
|
||||
num_faders=8,
|
||||
num_knobs=0,
|
||||
num_buttons=32,
|
||||
has_transport=False,
|
||||
midi_channel_count=1,
|
||||
notes="Class-compliant USB MIDI. No drivers needed on Linux.",
|
||||
controls=[
|
||||
ControllerControl("Fader 1", 16, 0, "fader"),
|
||||
ControllerControl("Fader 2", 17, 0, "fader"),
|
||||
ControllerControl("Fader 3", 18, 0, "fader"),
|
||||
ControllerControl("Fader 4", 19, 0, "fader"),
|
||||
ControllerControl("Fader 5", 20, 0, "fader"),
|
||||
ControllerControl("Fader 6", 21, 0, "fader"),
|
||||
ControllerControl("Fader 7", 22, 0, "fader"),
|
||||
ControllerControl("Fader 8", 23, 0, "fader"),
|
||||
],
|
||||
),
|
||||
|
||||
ControllerProfile(
|
||||
manufacturer="Akai",
|
||||
model="MIDImix",
|
||||
usb_vendor_id=0x09E8,
|
||||
usb_product_id=0x0031,
|
||||
alsa_client_pattern="MIDImix",
|
||||
num_faders=9,
|
||||
num_knobs=24,
|
||||
num_buttons=16,
|
||||
has_transport=False,
|
||||
midi_channel_count=1,
|
||||
notes="8 channel strips + master. 3 knobs per channel. Bank buttons send CC.",
|
||||
controls=[
|
||||
# 8 channel faders
|
||||
ControllerControl("Fader 1", 19, 0, "fader"),
|
||||
ControllerControl("Fader 2", 23, 0, "fader"),
|
||||
ControllerControl("Fader 3", 27, 0, "fader"),
|
||||
ControllerControl("Fader 4", 31, 0, "fader"),
|
||||
ControllerControl("Fader 5", 49, 0, "fader"),
|
||||
ControllerControl("Fader 6", 53, 0, "fader"),
|
||||
ControllerControl("Fader 7", 57, 0, "fader"),
|
||||
ControllerControl("Fader 8", 61, 0, "fader"),
|
||||
ControllerControl("Master", 62, 0, "fader"),
|
||||
# Knobs row 1 per channel
|
||||
ControllerControl("Knob 1A", 16, 0, "knob"),
|
||||
ControllerControl("Knob 2A", 20, 0, "knob"),
|
||||
ControllerControl("Knob 3A", 24, 0, "knob"),
|
||||
ControllerControl("Knob 4A", 28, 0, "knob"),
|
||||
ControllerControl("Knob 5A", 46, 0, "knob"),
|
||||
ControllerControl("Knob 6A", 50, 0, "knob"),
|
||||
ControllerControl("Knob 7A", 54, 0, "knob"),
|
||||
ControllerControl("Knob 8A", 58, 0, "knob"),
|
||||
# Knobs row 2
|
||||
ControllerControl("Knob 1B", 17, 0, "knob"),
|
||||
ControllerControl("Knob 2B", 21, 0, "knob"),
|
||||
ControllerControl("Knob 3B", 25, 0, "knob"),
|
||||
ControllerControl("Knob 4B", 29, 0, "knob"),
|
||||
ControllerControl("Knob 5B", 47, 0, "knob"),
|
||||
ControllerControl("Knob 6B", 51, 0, "knob"),
|
||||
ControllerControl("Knob 7B", 55, 0, "knob"),
|
||||
ControllerControl("Knob 8B", 59, 0, "knob"),
|
||||
# Knobs row 3
|
||||
ControllerControl("Knob 1C", 18, 0, "knob"),
|
||||
ControllerControl("Knob 2C", 22, 0, "knob"),
|
||||
ControllerControl("Knob 3C", 26, 0, "knob"),
|
||||
ControllerControl("Knob 4C", 30, 0, "knob"),
|
||||
ControllerControl("Knob 5C", 48, 0, "knob"),
|
||||
ControllerControl("Knob 6C", 52, 0, "knob"),
|
||||
ControllerControl("Knob 7C", 56, 0, "knob"),
|
||||
ControllerControl("Knob 8C", 60, 0, "knob"),
|
||||
# Mute/Solo buttons (momentary)
|
||||
ControllerControl("Mute 1", 1, 0, "button"),
|
||||
ControllerControl("Mute 2", 4, 0, "button"),
|
||||
ControllerControl("Mute 3", 7, 0, "button"),
|
||||
ControllerControl("Mute 4", 10, 0, "button"),
|
||||
ControllerControl("Mute 5", 13, 0, "button"),
|
||||
ControllerControl("Mute 6", 16, 0, "button"),
|
||||
ControllerControl("Mute 7", 19, 0, "button"),
|
||||
ControllerControl("Mute 8", 22, 0, "button"),
|
||||
ControllerControl("Solo 1", 2, 0, "button"),
|
||||
ControllerControl("Solo 2", 3, 0, "button"),
|
||||
ControllerControl("Solo 3", 8, 0, "button"),
|
||||
ControllerControl("Solo 4", 9, 0, "button"),
|
||||
ControllerControl("Solo 5", 14, 0, "button"),
|
||||
ControllerControl("Solo 6", 15, 0, "button"),
|
||||
ControllerControl("Solo 7", 20, 0, "button"),
|
||||
ControllerControl("Solo 8", 21, 0, "button"),
|
||||
],
|
||||
),
|
||||
|
||||
ControllerProfile(
|
||||
manufacturer="Arturia",
|
||||
model="BeatStep",
|
||||
usb_vendor_id=0x1C75,
|
||||
usb_product_id=0x0208,
|
||||
alsa_client_pattern="Arturia BeatStep",
|
||||
num_faders=0,
|
||||
num_knobs=17,
|
||||
num_buttons=16,
|
||||
has_transport=True,
|
||||
midi_channel_count=1,
|
||||
notes="17 endless encoders + 16 pads. Good for transport and parameter tweaking.",
|
||||
controls=[
|
||||
ControllerControl("Knob 1", 74, 0, "encoder"),
|
||||
ControllerControl("Knob 2", 71, 0, "encoder"),
|
||||
ControllerControl("Knob 3", 76, 0, "encoder"),
|
||||
ControllerControl("Knob 4", 77, 0, "encoder"),
|
||||
ControllerControl("Knob 5", 93, 0, "encoder"),
|
||||
ControllerControl("Knob 6", 73, 0, "encoder"),
|
||||
ControllerControl("Knob 7", 75, 0, "encoder"),
|
||||
ControllerControl("Knob 8", 79, 0, "encoder"),
|
||||
ControllerControl("Knob 9", 72, 0, "encoder"),
|
||||
ControllerControl("Knob 10", 80, 0, "encoder"),
|
||||
ControllerControl("Knob 11", 81, 0, "encoder"),
|
||||
ControllerControl("Knob 12", 82, 0, "encoder"),
|
||||
ControllerControl("Knob 13", 83, 0, "encoder"),
|
||||
ControllerControl("Knob 14", 84, 0, "encoder"),
|
||||
ControllerControl("Knob 15", 85, 0, "encoder"),
|
||||
ControllerControl("Knob 16", 86, 0, "encoder"),
|
||||
ControllerControl("Big Knob", 112, 0, "encoder"),
|
||||
# Transport
|
||||
ControllerControl("Play", 116, 0, "transport"),
|
||||
ControllerControl("Stop", 117, 0, "transport"),
|
||||
ControllerControl("Rec", 118, 0, "transport"),
|
||||
],
|
||||
),
|
||||
|
||||
ControllerProfile(
|
||||
manufacturer="Novation",
|
||||
model="Launch Control XL",
|
||||
usb_vendor_id=0x1235,
|
||||
usb_product_id=0x0061,
|
||||
alsa_client_pattern="Launch Control XL",
|
||||
num_faders=8,
|
||||
num_knobs=24,
|
||||
num_buttons=16,
|
||||
has_transport=False,
|
||||
midi_channel_count=1,
|
||||
notes="8 faders, 24 knobs (3 rows x 8), 16 buttons. Highly customisable.",
|
||||
controls=[
|
||||
ControllerControl("Fader 1", 77, 0, "fader"),
|
||||
ControllerControl("Fader 2", 78, 0, "fader"),
|
||||
ControllerControl("Fader 3", 79, 0, "fader"),
|
||||
ControllerControl("Fader 4", 80, 0, "fader"),
|
||||
ControllerControl("Fader 5", 81, 0, "fader"),
|
||||
ControllerControl("Fader 6", 82, 0, "fader"),
|
||||
ControllerControl("Fader 7", 83, 0, "fader"),
|
||||
ControllerControl("Fader 8", 84, 0, "fader"),
|
||||
# Knobs row 1
|
||||
ControllerControl("Knob 1A", 13, 0, "knob"),
|
||||
ControllerControl("Knob 2A", 14, 0, "knob"),
|
||||
ControllerControl("Knob 3A", 15, 0, "knob"),
|
||||
ControllerControl("Knob 4A", 16, 0, "knob"),
|
||||
ControllerControl("Knob 5A", 17, 0, "knob"),
|
||||
ControllerControl("Knob 6A", 18, 0, "knob"),
|
||||
ControllerControl("Knob 7A", 19, 0, "knob"),
|
||||
ControllerControl("Knob 8A", 20, 0, "knob"),
|
||||
# Knobs row 2
|
||||
ControllerControl("Knob 1B", 29, 0, "knob"),
|
||||
ControllerControl("Knob 2B", 30, 0, "knob"),
|
||||
ControllerControl("Knob 3B", 31, 0, "knob"),
|
||||
ControllerControl("Knob 4B", 32, 0, "knob"),
|
||||
ControllerControl("Knob 5B", 33, 0, "knob"),
|
||||
ControllerControl("Knob 6B", 34, 0, "knob"),
|
||||
ControllerControl("Knob 7B", 35, 0, "knob"),
|
||||
ControllerControl("Knob 8B", 36, 0, "knob"),
|
||||
],
|
||||
),
|
||||
|
||||
ControllerProfile(
|
||||
manufacturer="Korg",
|
||||
model="nanoKONTROL2",
|
||||
usb_vendor_id=0x0944,
|
||||
usb_product_id=0x0117,
|
||||
alsa_client_pattern="nanoKONTROL2",
|
||||
num_faders=8,
|
||||
num_knobs=8,
|
||||
num_buttons=32,
|
||||
has_transport=True,
|
||||
midi_channel_count=1,
|
||||
notes="Compact USB controller. 8 faders, 8 knobs, 24 buttons, transport. Class-compliant.",
|
||||
controls=[
|
||||
ControllerControl("Fader 1", 0, 0, "fader"),
|
||||
ControllerControl("Fader 2", 1, 0, "fader"),
|
||||
ControllerControl("Fader 3", 2, 0, "fader"),
|
||||
ControllerControl("Fader 4", 3, 0, "fader"),
|
||||
ControllerControl("Fader 5", 4, 0, "fader"),
|
||||
ControllerControl("Fader 6", 5, 0, "fader"),
|
||||
ControllerControl("Fader 7", 6, 0, "fader"),
|
||||
ControllerControl("Fader 8", 7, 0, "fader"),
|
||||
ControllerControl("Knob 1", 16, 0, "knob"),
|
||||
ControllerControl("Knob 2", 17, 0, "knob"),
|
||||
ControllerControl("Knob 3", 18, 0, "knob"),
|
||||
ControllerControl("Knob 4", 19, 0, "knob"),
|
||||
ControllerControl("Knob 5", 20, 0, "knob"),
|
||||
ControllerControl("Knob 6", 21, 0, "knob"),
|
||||
ControllerControl("Knob 7", 22, 0, "knob"),
|
||||
ControllerControl("Knob 8", 23, 0, "knob"),
|
||||
ControllerControl("Solo 1", 32, 0, "button"),
|
||||
ControllerControl("Solo 2", 33, 0, "button"),
|
||||
ControllerControl("Solo 3", 34, 0, "button"),
|
||||
ControllerControl("Solo 4", 35, 0, "button"),
|
||||
ControllerControl("Solo 5", 36, 0, "button"),
|
||||
ControllerControl("Solo 6", 37, 0, "button"),
|
||||
ControllerControl("Solo 7", 38, 0, "button"),
|
||||
ControllerControl("Solo 8", 39, 0, "button"),
|
||||
ControllerControl("Mute 1", 48, 0, "button"),
|
||||
ControllerControl("Mute 2", 49, 0, "button"),
|
||||
ControllerControl("Mute 3", 50, 0, "button"),
|
||||
ControllerControl("Mute 4", 51, 0, "button"),
|
||||
ControllerControl("Mute 5", 52, 0, "button"),
|
||||
ControllerControl("Mute 6", 53, 0, "button"),
|
||||
ControllerControl("Mute 7", 54, 0, "button"),
|
||||
ControllerControl("Mute 8", 55, 0, "button"),
|
||||
ControllerControl("Rec 1", 64, 0, "button"),
|
||||
ControllerControl("Rec 2", 65, 0, "button"),
|
||||
ControllerControl("Rec 3", 66, 0, "button"),
|
||||
ControllerControl("Rec 4", 67, 0, "button"),
|
||||
ControllerControl("Rec 5", 68, 0, "button"),
|
||||
ControllerControl("Rec 6", 69, 0, "button"),
|
||||
ControllerControl("Rec 7", 70, 0, "button"),
|
||||
ControllerControl("Rec 8", 71, 0, "button"),
|
||||
# Transport
|
||||
ControllerControl("Play", 41, 0, "transport"),
|
||||
ControllerControl("Stop", 42, 0, "transport"),
|
||||
ControllerControl("Rec", 43, 0, "transport"),
|
||||
ControllerControl("Rewind", 44, 0, "transport"),
|
||||
ControllerControl("FFwd", 45, 0, "transport"),
|
||||
ControllerControl("Loop", 46, 0, "transport"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def find_controller(usb_vid: int, usb_pid: int) -> ControllerProfile | None:
|
||||
"""Look up a known controller by USB vendor/product IDs."""
|
||||
for c in KNOWN_CONTROLLERS:
|
||||
if c.usb_vendor_id == usb_vid and c.usb_product_id == usb_pid:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def find_controller_by_alsa(client_name: str) -> ControllerProfile | None:
|
||||
"""Look up a known controller by ALSA client name substring match."""
|
||||
name_upper = client_name.upper()
|
||||
for c in KNOWN_CONTROLLERS:
|
||||
if c.alsa_client_pattern and c.alsa_client_pattern.upper() in name_upper:
|
||||
return c
|
||||
return None
|
||||
@@ -0,0 +1,313 @@
|
||||
"""USB MIDI device discovery, enumeration, and hotplug notification.
|
||||
|
||||
Uses udev for hotplug detection and pyudev for programmatic device
|
||||
enumeration. Falls back to ALSA sequencer client listing if pyudev
|
||||
is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MIDIDevice:
|
||||
"""Represents a discovered USB MIDI device.
|
||||
|
||||
Populated from udev or ALSA sequencer enumeration.
|
||||
"""
|
||||
device_node: str # e.g. /dev/snd/midiC1D0 or /dev/midi1
|
||||
manufacturer: str = ""
|
||||
product: str = ""
|
||||
serial: str = ""
|
||||
usb_vendor_id: int = 0
|
||||
usb_product_id: int = 0
|
||||
alsa_client_id: int = -1 # ALSA sequencer client ID
|
||||
alsa_client_name: str = ""
|
||||
num_ports: int = 0
|
||||
is_online: bool = True
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
parts = []
|
||||
if self.manufacturer:
|
||||
parts.append(self.manufacturer)
|
||||
if self.product:
|
||||
parts.append(self.product)
|
||||
if parts:
|
||||
return " ".join(parts)
|
||||
if self.alsa_client_name:
|
||||
return self.alsa_client_name
|
||||
return os.path.basename(self.device_node)
|
||||
|
||||
|
||||
# ── udev-based discovery ────────────────────────────────────────────────────
|
||||
|
||||
def _parse_udev_device(dev) -> MIDIDevice | None:
|
||||
"""Extract MIDIDevice from a pyudev Device object."""
|
||||
try:
|
||||
node = dev.device_node
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# USB parent info
|
||||
vid, pid = 0, 0
|
||||
manufacturer, product, serial = "", "", ""
|
||||
try:
|
||||
usb_dev = dev.find_parent("usb", "usb_device")
|
||||
if usb_dev:
|
||||
vid = int(usb_dev.get("ID_VENDOR_ID", "0"), 16)
|
||||
pid = int(usb_dev.get("ID_MODEL_ID", "0"), 16)
|
||||
manufacturer = usb_dev.get("ID_VENDOR_FROM_DATABASE", "") or usb_dev.get("ID_VENDOR", "")
|
||||
product = usb_dev.get("ID_MODEL_FROM_DATABASE", "") or usb_dev.get("ID_MODEL", "")
|
||||
serial = usb_dev.get("ID_SERIAL_SHORT", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return MIDIDevice(
|
||||
device_node=node,
|
||||
manufacturer=manufacturer,
|
||||
product=product,
|
||||
serial=serial,
|
||||
usb_vendor_id=vid,
|
||||
usb_product_id=pid,
|
||||
)
|
||||
|
||||
|
||||
def discover_udev() -> list[MIDIDevice]:
|
||||
"""Enumerate USB MIDI devices using pyudev.
|
||||
|
||||
Returns all raw MIDI device nodes (subsystem=sound with midi capability).
|
||||
"""
|
||||
try:
|
||||
import pyudev
|
||||
except ImportError:
|
||||
logger.debug("pyudev not available, falling back to ALSA enumeration")
|
||||
return []
|
||||
|
||||
devices: list[MIDIDevice] = []
|
||||
context = pyudev.Context()
|
||||
|
||||
for dev in context.list_devices(subsystem="sound"):
|
||||
devtype = dev.get("DEVTYPE", "")
|
||||
# ALSA rawmidi devices
|
||||
if devtype == "rawmidi" or "midi" in dev.sys_name.lower():
|
||||
d = _parse_udev_device(dev)
|
||||
if d:
|
||||
devices.append(d)
|
||||
|
||||
# Also check /dev/midi* devices
|
||||
for dev in context.list_devices(subsystem="sound"):
|
||||
if "midi" in dev.get("DEVNAME", "").lower():
|
||||
d = _parse_udev_device(dev)
|
||||
if d and d not in devices:
|
||||
devices.append(d)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
# ── ALSA sequencer-based discovery ──────────────────────────────────────────
|
||||
|
||||
def discover_alsa_seq() -> list[MIDIDevice]:
|
||||
"""Enumerate MIDI devices via ALSA sequencer clients.
|
||||
|
||||
Parses /proc/asound/seq/clients (no external deps).
|
||||
"""
|
||||
devices: list[MIDIDevice] = []
|
||||
clients_path = "/proc/asound/seq/clients"
|
||||
|
||||
if not os.path.exists(clients_path):
|
||||
return devices
|
||||
|
||||
try:
|
||||
with open(clients_path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("Client"):
|
||||
continue
|
||||
# Format: "Client 128 : "USB MIDI Device" [type=kernel]"
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
client_part = parts[0].strip()
|
||||
if not client_part.startswith("Client "):
|
||||
continue
|
||||
try:
|
||||
client_id = int(client_part.split()[1])
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
|
||||
name_part = parts[1].strip().strip('"')
|
||||
# Extract port count from the bracket part
|
||||
num_ports = 1
|
||||
if "[" in name_part:
|
||||
name_part, _ = name_part.rsplit("[", 1)
|
||||
name_part = name_part.strip()
|
||||
|
||||
devices.append(MIDIDevice(
|
||||
device_node=f"alsa:client:{client_id}",
|
||||
alsa_client_id=client_id,
|
||||
alsa_client_name=name_part,
|
||||
num_ports=num_ports,
|
||||
))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read ALSA seq clients: %s", exc)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def discover_all() -> list[MIDIDevice]:
|
||||
"""Enumerate all available MIDI input devices.
|
||||
|
||||
Tries udev first (richer metadata), falls back to ALSA sequencer.
|
||||
"""
|
||||
devices = discover_udev()
|
||||
if not devices:
|
||||
devices = discover_alsa_seq()
|
||||
|
||||
# Enrich ALSA-only devices with seq client IDs
|
||||
for dev in devices:
|
||||
if dev.alsa_client_id < 0:
|
||||
_enrich_alsa_id(dev)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def _enrich_alsa_id(dev: MIDIDevice) -> None:
|
||||
"""Try to find ALSA sequencer client ID for a udev-discovered device."""
|
||||
seq_devs = discover_alsa_seq()
|
||||
for sd in seq_devs:
|
||||
if sd.alsa_client_name and (
|
||||
dev.product in sd.alsa_client_name
|
||||
or dev.manufacturer in sd.alsa_client_name
|
||||
):
|
||||
dev.alsa_client_id = sd.alsa_client_id
|
||||
dev.alsa_client_name = sd.alsa_client_name
|
||||
break
|
||||
|
||||
|
||||
# ── Hotplug monitoring ──────────────────────────────────────────────────────
|
||||
|
||||
DeviceCallback = Callable[[MIDIDevice], None] # called on add
|
||||
DeviceRemoveCallback = Callable[[str], None] # called with device_node on remove
|
||||
|
||||
|
||||
class MIDIHotplugMonitor:
|
||||
"""Monitors udev for USB MIDI device add/remove events.
|
||||
|
||||
Uses pyudev.Monitor when available; polls /proc/asound/seq/clients
|
||||
as a fallback.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._monitor = None
|
||||
self._observer = None
|
||||
self._on_add: list[DeviceCallback] = []
|
||||
self._on_remove: list[DeviceRemoveCallback] = []
|
||||
self._running = False
|
||||
self._known_devices: dict[str, MIDIDevice] = {}
|
||||
|
||||
def on_add(self, callback: DeviceCallback) -> None:
|
||||
self._on_add.append(callback)
|
||||
|
||||
def on_remove(self, callback: DeviceRemoveCallback) -> None:
|
||||
self._on_remove.append(callback)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start monitoring for hotplug events (non-blocking)."""
|
||||
self._running = True
|
||||
try:
|
||||
import pyudev
|
||||
self._monitor = pyudev.Monitor.from_netlink(pyudev.Context())
|
||||
self._monitor.filter_by(subsystem="sound")
|
||||
import pyudev_monitor # noqa: F811
|
||||
from pyudev import MonitorObserver
|
||||
self._observer = MonitorObserver(self._monitor, self._handle_udev_event)
|
||||
self._observer.start()
|
||||
logger.info("MIDI hotplug monitor started (pyudev)")
|
||||
except ImportError:
|
||||
logger.info("pyudev not available; hotplug requires polling")
|
||||
self._monitor = None
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._observer:
|
||||
self._observer.stop()
|
||||
self._observer = None
|
||||
logger.info("MIDI hotplug monitor stopped")
|
||||
|
||||
def _handle_udev_event(self, device) -> None:
|
||||
action = device.get("ACTION", "")
|
||||
if action == "add":
|
||||
d = _parse_udev_device(device)
|
||||
if d and d.device_node not in self._known_devices:
|
||||
self._known_devices[d.device_node] = d
|
||||
for cb in self._on_add:
|
||||
try:
|
||||
cb(d)
|
||||
except Exception as exc:
|
||||
logger.error("Hotplug add callback failed: %s", exc)
|
||||
elif action == "remove":
|
||||
node = device.get("DEVNAME", "")
|
||||
if node:
|
||||
self._known_devices.pop(node, None)
|
||||
for cb in self._on_remove:
|
||||
try:
|
||||
cb(node)
|
||||
except Exception as exc:
|
||||
logger.error("Hotplug remove callback failed: %s", exc)
|
||||
|
||||
|
||||
def poll_once(self) -> list[MIDIDevice]:
|
||||
"""Poll-based check for new/removed devices. Returns newly added devices.
|
||||
|
||||
Call periodically (~1-2 Hz) as fallback when udev monitoring isn't available.
|
||||
"""
|
||||
current = discover_all()
|
||||
current_nodes = {d.device_node for d in current}
|
||||
known_nodes = set(self._known_devices.keys())
|
||||
|
||||
added = [d for d in current if d.device_node not in known_nodes]
|
||||
removed = known_nodes - current_nodes
|
||||
|
||||
self._known_devices = {d.device_node: d for d in current}
|
||||
|
||||
for d in added:
|
||||
for cb in self._on_add:
|
||||
try:
|
||||
cb(d)
|
||||
except Exception as exc:
|
||||
logger.error("Poll add callback failed: %s", exc)
|
||||
|
||||
for node in removed:
|
||||
for cb in self._on_remove:
|
||||
try:
|
||||
cb(node)
|
||||
except Exception as exc:
|
||||
logger.error("Poll remove callback failed: %s", exc)
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ── Convenience ─────────────────────────────────────────────────────────────
|
||||
|
||||
def print_devices() -> None:
|
||||
"""Print a human-readable device list to stdout."""
|
||||
devices = discover_all()
|
||||
if not devices:
|
||||
print("No MIDI devices found.")
|
||||
return
|
||||
|
||||
print(f"{'Node':<30} {'Name':<40} {'VID:PID':<12} {'ALSA':<8}")
|
||||
print("-" * 90)
|
||||
for d in devices:
|
||||
vidpid = f"{d.usb_vendor_id:04x}:{d.usb_product_id:04x}" if d.usb_vendor_id else "-"
|
||||
alsa = str(d.alsa_client_id) if d.alsa_client_id > 0 else "-"
|
||||
name = d.display_name[:38]
|
||||
print(f"{d.device_node:<30} {name:<40} {vidpid:<12} {alsa:<8}")
|
||||
@@ -0,0 +1,235 @@
|
||||
"""JACK MIDI bridge — create JACK MIDI ports for Carla consumption.
|
||||
|
||||
Bridges mapped MIDI events from the engine into JACK MIDI output ports.
|
||||
Carla (or any JACK MIDI-aware application) can connect to these ports
|
||||
and route MIDI to soft-synths, samplers, and effects.
|
||||
|
||||
Two modes:
|
||||
1. Direct JACK client (requires jack-client Python module)
|
||||
2. ALSA sequencer bridge (uses a2jmidid for ALSA → JACK bridging)
|
||||
|
||||
The direct JACK client mode is preferred as it avoids the extra a2jmidid
|
||||
daemon, but requires the `jack` Python package (pip install JACK-Client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum number of JACK MIDI ports to create
|
||||
DEFAULT_NUM_PORTS = 4
|
||||
|
||||
|
||||
class JACKMIDIBridge:
|
||||
"""Creates JACK MIDI output ports and writes MIDI events to them.
|
||||
|
||||
Usage:
|
||||
bridge = JACKMIDIBridge(client_name="rpi-mixer")
|
||||
bridge.start()
|
||||
# ... after engine processes a mapping ...
|
||||
bridge.send_midi(port_index=0, event=[0x90, 60, 100])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_name: str = "rpi-mixer",
|
||||
num_ports: int = DEFAULT_NUM_PORTS,
|
||||
port_name_prefix: str = "midi_out",
|
||||
):
|
||||
self.client_name = client_name
|
||||
self.num_ports = num_ports
|
||||
self.port_name_prefix = port_name_prefix
|
||||
self._client = None
|
||||
self._ports: list[Any] = []
|
||||
self._alsa_client = None
|
||||
self._alsa_ports: list[int] = []
|
||||
self._running = False
|
||||
self._activated = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Activate JACK client and register MIDI output ports.
|
||||
|
||||
Returns True on success, False if JACK is unavailable.
|
||||
"""
|
||||
try:
|
||||
import jack
|
||||
self._client = jack.Client(self.client_name)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"jack-client Python module not available. "
|
||||
"Install with: pip install JACK-Client. "
|
||||
"Falling back to ALSA sequencer output."
|
||||
)
|
||||
return self._start_alsa_fallback()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create JACK client: %s", exc)
|
||||
return self._start_alsa_fallback()
|
||||
|
||||
# Register MIDI output ports
|
||||
with self._lock:
|
||||
for i in range(self.num_ports):
|
||||
port_name = f"{self.port_name_prefix}_{i}"
|
||||
try:
|
||||
port = self._client.midi_outports.register(port_name)
|
||||
self._ports.append(port)
|
||||
logger.info("Registered JACK MIDI port: %s:%s", self.client_name, port_name)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to register port %s: %s", port_name, exc)
|
||||
|
||||
if not self._ports:
|
||||
logger.error("No JACK MIDI ports registered")
|
||||
return self._start_alsa_fallback()
|
||||
|
||||
# Activate the client
|
||||
try:
|
||||
self._client.activate()
|
||||
self._activated = True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to activate JACK client: %s", exc)
|
||||
return self._start_alsa_fallback()
|
||||
|
||||
self._running = True
|
||||
logger.info("JACK MIDI bridge active: %d ports on '%s'", len(self._ports), self.client_name)
|
||||
return True
|
||||
|
||||
def _start_alsa_fallback(self) -> bool:
|
||||
"""Fallback: use ALSA sequencer output (bridged via a2jmidid).
|
||||
|
||||
This creates ALSA sequencer ports that a2jmidid automatically
|
||||
bridges to JACK MIDI. No direct JACK dependency needed.
|
||||
"""
|
||||
try:
|
||||
import alsaseq
|
||||
except ImportError:
|
||||
logger.warning("alsaseq not available either — MIDI output disabled")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._alsa_client = alsaseq.SequencerClient(
|
||||
self.client_name,
|
||||
client_type=alsaseq.SEQ_CLIENT_DUPLEX
|
||||
)
|
||||
with self._lock:
|
||||
for i in range(self.num_ports):
|
||||
port_name = f"{self.port_name_prefix}_{i}"
|
||||
port_id = self._alsa_client.create_port(
|
||||
port_name,
|
||||
caps=alsaseq.SEQ_PORT_CAP_WRITE | alsaseq.SEQ_PORT_CAP_SUBS_WRITE,
|
||||
type=alsaseq.SEQ_PORT_TYPE_MIDI_GENERIC,
|
||||
)
|
||||
self._alsa_ports.append(port_id)
|
||||
logger.info("Created ALSA seq port: %s:%s (id=%d)", self.client_name, port_name, port_id)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create ALSA sequencer client: %s", exc)
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
logger.info("ALSA sequencer MIDI bridge active (connect via a2jmidid)")
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Deactivate the JACK client and clean up ports."""
|
||||
self._running = False
|
||||
if self._client and self._activated:
|
||||
try:
|
||||
self._client.deactivate()
|
||||
except Exception as exc:
|
||||
logger.warning("Error deactivating JACK client: %s", exc)
|
||||
self._client = None
|
||||
self._ports.clear()
|
||||
self._activated = False
|
||||
logger.info("JACK MIDI bridge stopped")
|
||||
|
||||
# ── MIDI output ──────────────────────────────────────────────────────
|
||||
|
||||
def send_midi(self, port_index: int, event: bytes | list[int], timestamp: float = 0.0) -> bool:
|
||||
"""Send a raw MIDI event to a JACK MIDI port.
|
||||
|
||||
Args:
|
||||
port_index: Which output port (0..num_ports-1).
|
||||
event: Raw MIDI bytes (e.g., [0x90, 0x3C, 0x64] for note on).
|
||||
timestamp: JACK frame time offset (0.0 = as soon as possible).
|
||||
|
||||
Returns True if the event was queued successfully.
|
||||
"""
|
||||
if not self._running:
|
||||
return False
|
||||
|
||||
if isinstance(event, list):
|
||||
event = bytes(event)
|
||||
|
||||
with self._lock:
|
||||
if port_index < 0 or port_index >= len(self._ports):
|
||||
logger.debug("Invalid port index %d (have %d ports)", port_index, len(self._ports))
|
||||
return False
|
||||
try:
|
||||
port = self._ports[port_index]
|
||||
port.write_midi_event(timestamp, event)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("MIDI write to port %d failed: %s", port_index, exc)
|
||||
return False
|
||||
|
||||
def send_midi_burst(self, port_index: int, events: list[bytes | list[int]]) -> int:
|
||||
"""Send multiple MIDI events atomically (same JACK cycle).
|
||||
|
||||
Returns number of events successfully queued.
|
||||
"""
|
||||
sent = 0
|
||||
for event in events:
|
||||
if self.send_midi(port_index, event):
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
# ── Port info ────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def port_count(self) -> int:
|
||||
return len(self._ports)
|
||||
|
||||
def port_name(self, index: int) -> str:
|
||||
if 0 <= index < len(self._ports):
|
||||
return f"{self.client_name}:{self.port_name_prefix}_{index}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self._running and self._activated
|
||||
|
||||
|
||||
# ── Utility: generate common MIDI messages ──────────────────────────────────
|
||||
|
||||
def midi_note_on(channel: int, note: int, velocity: int = 100) -> bytes:
|
||||
"""Build a Note On message (3 bytes)."""
|
||||
return bytes([0x90 | (channel & 0x0F), note & 0x7F, velocity & 0x7F])
|
||||
|
||||
|
||||
def midi_note_off(channel: int, note: int) -> bytes:
|
||||
"""Build a Note Off message (3 bytes)."""
|
||||
return bytes([0x80 | (channel & 0x0F), note & 0x7F, 0])
|
||||
|
||||
|
||||
def midi_cc(channel: int, controller: int, value: int) -> bytes:
|
||||
"""Build a Control Change message (3 bytes)."""
|
||||
return bytes([0xB0 | (channel & 0x0F), controller & 0x7F, value & 0x7F])
|
||||
|
||||
|
||||
def midi_pitch_bend(channel: int, value: int) -> bytes:
|
||||
"""Build a Pitch Bend message (3 bytes, 14-bit value)."""
|
||||
value = max(0, min(16383, value))
|
||||
lsb = value & 0x7F
|
||||
msb = (value >> 7) & 0x7F
|
||||
return bytes([0xE0 | (channel & 0x0F), lsb, msb])
|
||||
|
||||
|
||||
def midi_program_change(channel: int, program: int) -> bytes:
|
||||
"""Build a Program Change message (2 bytes)."""
|
||||
return bytes([0xC0 | (channel & 0x0F), program & 0x7F])
|
||||
@@ -0,0 +1,163 @@
|
||||
"""MIDI mapping persistence — save/load mappings as JSON.
|
||||
|
||||
Mappings are stored per-session (a "session" is one mapping configuration
|
||||
file). The file format is designed for human readability and version control
|
||||
friendliness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .types import MIDIMapping, MIDIMessageType, ParameterType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAPPINGS_DIR = Path.home() / ".config" / "rpi-mixer" / "mappings"
|
||||
DEFAULT_SESSION_NAME = "default"
|
||||
|
||||
|
||||
# ── Serialisation ───────────────────────────────────────────────────────────
|
||||
|
||||
def mapping_to_dict(m: MIDIMapping) -> dict[str, Any]:
|
||||
"""Serialise one mapping to a plain dict."""
|
||||
return {
|
||||
"midi": {
|
||||
"type": m.msg_type.name,
|
||||
"channel": m.channel,
|
||||
"controller": m.controller,
|
||||
"is_nrpn": m.is_nrpn,
|
||||
"nrpn_number": m.nrpn_number,
|
||||
"source_device": m.source_device or None,
|
||||
},
|
||||
"target": {
|
||||
"parameter": m.param_type.value,
|
||||
"channel": m.param_channel,
|
||||
},
|
||||
"value": {
|
||||
"midi_min": m.midi_min,
|
||||
"midi_max": m.midi_max,
|
||||
"param_min": m.param_min,
|
||||
"param_max": m.param_max,
|
||||
"invert": m.invert,
|
||||
"curve": m.curve,
|
||||
},
|
||||
"meta": {
|
||||
"label": m.label or None,
|
||||
"enabled": m.enabled,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def mapping_from_dict(d: dict[str, Any]) -> MIDIMapping:
|
||||
"""Deserialise one mapping from a dict."""
|
||||
midi = d.get("midi", {})
|
||||
target = d.get("target", {})
|
||||
value = d.get("value", {})
|
||||
meta = d.get("meta", {})
|
||||
|
||||
msg_type = MIDIMessageType[midi.get("type", "CONTROL_CHANGE")]
|
||||
|
||||
return MIDIMapping(
|
||||
msg_type=msg_type,
|
||||
channel=midi.get("channel", 0),
|
||||
controller=midi.get("controller", 0),
|
||||
is_nrpn=midi.get("is_nrpn", False),
|
||||
nrpn_number=midi.get("nrpn_number", 0),
|
||||
source_device=midi.get("source_device") or "",
|
||||
param_type=ParameterType(target.get("parameter", "volume")),
|
||||
param_channel=target.get("channel", -1),
|
||||
midi_min=value.get("midi_min", 0),
|
||||
midi_max=value.get("midi_max", 127),
|
||||
param_min=value.get("param_min", 0.0),
|
||||
param_max=value.get("param_max", 1.0),
|
||||
invert=value.get("invert", False),
|
||||
curve=value.get("curve", "linear"),
|
||||
label=meta.get("label") or "",
|
||||
enabled=meta.get("enabled", True),
|
||||
)
|
||||
|
||||
|
||||
# ── Session file I/O ────────────────────────────────────────────────────────
|
||||
|
||||
def _session_path(session_name: str = DEFAULT_SESSION_NAME) -> Path:
|
||||
"""Resolve the JSON file path for a session."""
|
||||
return DEFAULT_MAPPINGS_DIR / f"{session_name}.json"
|
||||
|
||||
|
||||
def save_mappings(
|
||||
mappings: list[MIDIMapping],
|
||||
session_name: str = DEFAULT_SESSION_NAME,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""Persist mapping list to JSON.
|
||||
|
||||
Returns the path written.
|
||||
"""
|
||||
path = _session_path(session_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
doc: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"session": session_name,
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
"metadata": metadata or {},
|
||||
"mappings": [mapping_to_dict(m) for m in mappings],
|
||||
}
|
||||
|
||||
# Atomic write: write to temp then rename
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(doc, fh, indent=2, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
|
||||
logger.info("Saved %d mappings to %s", len(mappings), path)
|
||||
return path
|
||||
|
||||
|
||||
def load_mappings(session_name: str = DEFAULT_SESSION_NAME) -> list[MIDIMapping]:
|
||||
"""Load mapping list from JSON.
|
||||
|
||||
Returns empty list if the file doesn't exist.
|
||||
"""
|
||||
path = _session_path(session_name)
|
||||
if not path.exists():
|
||||
logger.info("No mapping file at %s", path)
|
||||
return []
|
||||
|
||||
with open(path) as fh:
|
||||
doc = json.load(fh)
|
||||
|
||||
version = doc.get("version", 0)
|
||||
if version != 1:
|
||||
logger.warning("Unknown mapping format version %d in %s", version, path)
|
||||
|
||||
raw = doc.get("mappings", [])
|
||||
mappings = [mapping_from_dict(m) for m in raw]
|
||||
logger.info("Loaded %d mappings from %s", len(mappings), path)
|
||||
return mappings
|
||||
|
||||
|
||||
def list_sessions() -> list[str]:
|
||||
"""Return names of all saved mapping sessions."""
|
||||
if not DEFAULT_MAPPINGS_DIR.exists():
|
||||
return []
|
||||
return sorted(
|
||||
p.stem for p in DEFAULT_MAPPINGS_DIR.glob("*.json")
|
||||
if p.stem != "default" or True # Include default
|
||||
)
|
||||
|
||||
|
||||
def delete_session(session_name: str) -> bool:
|
||||
"""Delete a mapping session file. Returns True if successful."""
|
||||
path = _session_path(session_name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.info("Deleted mapping session: %s", session_name)
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,217 @@
|
||||
"""MIDI Clock synchronisation.
|
||||
|
||||
Receives MIDI clock (timing clock) messages and derives tempo (BPM)
|
||||
from the timing pulse stream. MIDI clock sends 24 pulses per quarter
|
||||
note (PPQN). Also handles START, STOP, CONTINUE for transport sync.
|
||||
|
||||
Supports:
|
||||
- BPM detection from clock pulse timing
|
||||
- Tempo averaging (moving window) to smooth jitter
|
||||
- Song Position Pointer (SPP) for locate
|
||||
- Transport state tracking (stopped, playing)
|
||||
- MIDI clock output for master clock generation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PPQN = 24 # MIDI clock pulses per quarter note
|
||||
DEFAULT_WINDOW = 96 # 4 quarter notes at 24 PPQN
|
||||
|
||||
|
||||
@dataclass
|
||||
class MIDIClockState:
|
||||
"""Current state of MIDI clock reception."""
|
||||
running: bool = False # Transport is playing
|
||||
bpm: float = 120.0 # Current estimated tempo
|
||||
bpm_raw: float = 120.0 # Raw instantaneous BPM (no smoothing)
|
||||
bpm_stable: bool = False # Tempo estimate has converged
|
||||
song_position: float = 0.0 # Current song position in beats
|
||||
last_pulse_time: float = 0.0 # monotonic timestamp of last clock pulse
|
||||
pulse_count: int = 0 # Total clock pulses received
|
||||
ppqn: int = PPQN
|
||||
|
||||
# Moving window for tempo averaging
|
||||
_pulse_intervals: collections.deque = field(default_factory=lambda: collections.deque(maxlen=DEFAULT_WINDOW))
|
||||
|
||||
def reset(self) -> None:
|
||||
self.running = False
|
||||
self.bpm = 120.0
|
||||
self.bpm_raw = 120.0
|
||||
self.bpm_stable = False
|
||||
self.song_position = 0.0
|
||||
self.last_pulse_time = 0.0
|
||||
self.pulse_count = 0
|
||||
self._pulse_intervals.clear()
|
||||
|
||||
|
||||
class MIDIClock:
|
||||
"""MIDI clock receiver and tempo estimator.
|
||||
|
||||
Feed clock messages via process_message(). BPM is derived from
|
||||
the inter-pulse timing using a moving average window.
|
||||
|
||||
Usage:
|
||||
clock = MIDIClock()
|
||||
clock.on_tempo_change = lambda bpm: print(f"Tempo: {bpm:.1f}")
|
||||
clock.on_transport = lambda event: print(f"Transport: {event}")
|
||||
# Feed messages:
|
||||
clock.process_message(msg)
|
||||
"""
|
||||
|
||||
def __init__(self, window_size: int = DEFAULT_WINDOW):
|
||||
self.state = MIDIClockState()
|
||||
self.state._pulse_intervals = collections.deque(maxlen=window_size)
|
||||
self._on_tempo_change: list = []
|
||||
self._on_transport: list = []
|
||||
self._on_beat: list = [] # Fires every quarter note (every 24 pulses)
|
||||
|
||||
# ── Callbacks ────────────────────────────────────────────────────────
|
||||
|
||||
def on_tempo_change(self, callback):
|
||||
"""callback(bpm: float, raw_bpm: float, stable: bool)."""
|
||||
self._on_tempo_change.append(callback)
|
||||
|
||||
def on_transport(self, callback):
|
||||
"""callback(event: str) where event is 'start', 'stop', 'continue'."""
|
||||
self._on_transport.append(callback)
|
||||
|
||||
def on_beat(self, callback):
|
||||
"""callback(beat: int) — fires every quarter note (24 pulses)."""
|
||||
self._on_beat.append(callback)
|
||||
|
||||
# ── Message processing ───────────────────────────────────────────────
|
||||
|
||||
def process_message(self, status_byte: int) -> None:
|
||||
"""Process a MIDI system realtime message.
|
||||
|
||||
Args:
|
||||
status_byte: The raw MIDI status byte (0xF8–0xFC).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
|
||||
if status_byte == 0xF8: # Timing Clock
|
||||
self._handle_clock(now)
|
||||
elif status_byte == 0xFA: # Start
|
||||
self._handle_start(now)
|
||||
elif status_byte == 0xFB: # Continue
|
||||
self._handle_continue(now)
|
||||
elif status_byte == 0xFC: # Stop
|
||||
self._handle_stop()
|
||||
elif status_byte == 0xF2: # Song Position Pointer (handled separately)
|
||||
pass # SPP requires two data bytes, handled upstream
|
||||
|
||||
def process_song_position(self, beats: int) -> None:
|
||||
"""Handle Song Position Pointer (SPP).
|
||||
|
||||
Args:
|
||||
beats: Song position in MIDI beats (0–16383, where 1 beat = 6 clock pulses).
|
||||
"""
|
||||
self.state.song_position = beats
|
||||
logger.debug("Song position: %.1f beats", beats)
|
||||
|
||||
# ── Internal handlers ────────────────────────────────────────────────
|
||||
|
||||
def _handle_clock(self, now: float) -> None:
|
||||
s = self.state
|
||||
s.pulse_count += 1
|
||||
|
||||
if s.last_pulse_time > 0:
|
||||
interval = now - s.last_pulse_time
|
||||
if interval > 0 and interval < 2.0: # Sanity: ignore >2s gaps
|
||||
s._pulse_intervals.append(interval)
|
||||
|
||||
if len(s._pulse_intervals) >= 4: # Need at least a few pulses
|
||||
avg_interval = sum(s._pulse_intervals) / len(s._pulse_intervals)
|
||||
if avg_interval > 0:
|
||||
# 24 pulses per quarter note, BPM = 60 / (seconds per quarter note)
|
||||
# seconds per quarter note = avg_interval * 24
|
||||
s.bpm_raw = 60.0 / (avg_interval * s.ppqn)
|
||||
s.bpm = s.bpm_raw
|
||||
s.bpm_stable = len(s._pulse_intervals) >= ((s._pulse_intervals.maxlen or DEFAULT_WINDOW) // 2)
|
||||
|
||||
s.last_pulse_time = now
|
||||
|
||||
# Beat callback every 24 pulses
|
||||
if s.pulse_count % s.ppqn == 0:
|
||||
beat = s.pulse_count // s.ppqn
|
||||
for cb in self._on_beat:
|
||||
try:
|
||||
cb(beat)
|
||||
except Exception as exc:
|
||||
logger.error("Beat callback failed: %s", exc)
|
||||
|
||||
# Fire tempo change on beat boundaries (not every pulse)
|
||||
if s.bpm_stable:
|
||||
for cb in self._on_tempo_change:
|
||||
try:
|
||||
cb(s.bpm, s.bpm_raw, s.bpm_stable)
|
||||
except Exception as exc:
|
||||
logger.error("Tempo callback failed: %s", exc)
|
||||
|
||||
def _handle_start(self, now: float) -> None:
|
||||
self.state.running = True
|
||||
self.state.song_position = 0.0
|
||||
self.state.pulse_count = 0
|
||||
self.state.last_pulse_time = now
|
||||
self.state._pulse_intervals.clear()
|
||||
logger.info("MIDI START — transport running")
|
||||
for cb in self._on_transport:
|
||||
try:
|
||||
cb("start")
|
||||
except Exception as exc:
|
||||
logger.error("Transport callback failed: %s", exc)
|
||||
|
||||
def _handle_continue(self, now: float) -> None:
|
||||
self.state.running = True
|
||||
self.state.last_pulse_time = now
|
||||
logger.info("MIDI CONTINUE — transport running")
|
||||
for cb in self._on_transport:
|
||||
try:
|
||||
cb("continue")
|
||||
except Exception as exc:
|
||||
logger.error("Transport callback failed: %s", exc)
|
||||
|
||||
def _handle_stop(self) -> None:
|
||||
self.state.running = False
|
||||
logger.info("MIDI STOP — transport stopped")
|
||||
for cb in self._on_transport:
|
||||
try:
|
||||
cb("stop")
|
||||
except Exception as exc:
|
||||
logger.error("Transport callback failed: %s", exc)
|
||||
|
||||
# ── Master clock output (for generating MIDI clock) ──────────────────
|
||||
|
||||
def generate_clock_pulse(self) -> float | None:
|
||||
"""Generate next clock pulse interval for master output.
|
||||
|
||||
Returns:
|
||||
Seconds until next pulse, or None if transport not running.
|
||||
"""
|
||||
if not self.state.running:
|
||||
return None
|
||||
if self.state.bpm <= 0:
|
||||
return None
|
||||
# Seconds per quarter note = 60 / BPM
|
||||
# Interval per pulse = (60 / BPM) / PPQN
|
||||
return (60.0 / self.state.bpm) / self.state.ppqn
|
||||
|
||||
# ── Query ────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def tempo(self) -> float:
|
||||
return self.state.bpm
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self.state.running
|
||||
|
||||
def reset(self) -> None:
|
||||
self.state.reset()
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Core MIDI processing engine.
|
||||
|
||||
Reads MIDI events from input ports (ALSA sequencer or rtmidi),
|
||||
applies mapping rules, dispatches parameter changes downstream,
|
||||
and optionally forwards mapped MIDI to output ports.
|
||||
|
||||
Architecture:
|
||||
USB MIDI device → ALSA seq / rtmidi input → MappingEngine
|
||||
→ ParameterRegistry callbacks (DSP / UI)
|
||||
→ [optional] JACK MIDI / ALSA seq output ports
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Callable
|
||||
|
||||
from .types import (
|
||||
MIDIMessage,
|
||||
MIDIMessageType,
|
||||
MIDIMapping,
|
||||
ParameterType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Callback shape: (mapping: MIDIMapping, scaled_value: float, raw_msg: MIDIMessage) -> None
|
||||
MappingCallback = Callable[[MIDIMapping, float, MIDIMessage], None]
|
||||
|
||||
|
||||
class MIDIEngine:
|
||||
"""Core MIDI routing and mapping engine.
|
||||
|
||||
Maintains a list of active mappings, reads MIDI events from one or
|
||||
more backends, matches events against mappings, and dispatches
|
||||
scaled parameter values to registered callbacks.
|
||||
|
||||
Thread-safe for concurrent event input and mapping updates.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "rpi-mixer-midi"):
|
||||
self.name = name
|
||||
self._mappings: list[MIDIMapping] = []
|
||||
self._mappings_lock = threading.Lock()
|
||||
self._callbacks: list[MappingCallback] = []
|
||||
self._running = False
|
||||
|
||||
# NRPN state machine per (source_device, channel)
|
||||
# NRPN uses CC 99 (MSB), CC 98 (LSB), CC 6 (data MSB), CC 38 (data LSB), CC 96 (increment), CC 97 (decrement)
|
||||
self._nrpn_state: dict[tuple[str, int], dict[str, int | None]] = defaultdict(
|
||||
lambda: {"msb": None, "lsb": None, "data_msb": None}
|
||||
)
|
||||
|
||||
# Stats
|
||||
self.event_count: int = 0
|
||||
self.mapped_count: int = 0
|
||||
self.uptime_start: float = 0.0
|
||||
|
||||
# ── Mapping CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def set_mappings(self, mappings: list[MIDIMapping]) -> None:
|
||||
"""Replace the entire mapping table atomically."""
|
||||
with self._mappings_lock:
|
||||
self._mappings = list(mappings)
|
||||
logger.info("Loaded %d mappings", len(mappings))
|
||||
|
||||
def add_mapping(self, mapping: MIDIMapping) -> None:
|
||||
with self._mappings_lock:
|
||||
self._mappings.append(mapping)
|
||||
logger.info("Added mapping: %s", mapping.label or f"CC{mapping.controller}→{mapping.param_type.value}")
|
||||
|
||||
def remove_mapping(self, mapping: MIDIMapping) -> bool:
|
||||
with self._mappings_lock:
|
||||
for i, m in enumerate(self._mappings):
|
||||
if (
|
||||
m.msg_type == mapping.msg_type
|
||||
and m.channel == mapping.channel
|
||||
and m.controller == mapping.controller
|
||||
and m.param_type == mapping.param_type
|
||||
and m.param_channel == mapping.param_channel
|
||||
):
|
||||
self._mappings.pop(i)
|
||||
logger.info("Removed mapping: %s", mapping.label or f"CC{mapping.controller}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_mappings(self) -> list[MIDIMapping]:
|
||||
with self._mappings_lock:
|
||||
return list(self._mappings)
|
||||
|
||||
def find_mappings_for(self, param_type: ParameterType, channel: int = -1) -> list[MIDIMapping]:
|
||||
"""Return all mappings targeting a specific parameter."""
|
||||
with self._mappings_lock:
|
||||
return [m for m in self._mappings if m.param_type == param_type and m.param_channel == channel]
|
||||
|
||||
# ── Callback management ──────────────────────────────────────────────
|
||||
|
||||
def on_mapped(self, callback: MappingCallback) -> None:
|
||||
"""Register a callback for mapped parameter changes."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback: MappingCallback) -> None:
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
# ── Event processing ─────────────────────────────────────────────────
|
||||
|
||||
def process_event(self, msg: MIDIMessage) -> list[tuple[MIDIMapping, float]]:
|
||||
"""Process one MIDI event through the mapping table.
|
||||
|
||||
Handles NRPN state machine transparently: when a complete NRPN
|
||||
value arrives (via CC 6/38/96/97 after parameter select via
|
||||
CC 98/99), constructs a synthetic MIDIMessage with is_nrpn=True
|
||||
and the full 14-bit value.
|
||||
|
||||
Returns list of (mapping, scaled_value) for all matched mappings.
|
||||
"""
|
||||
self.event_count += 1
|
||||
results: list[tuple[MIDIMapping, float]] = []
|
||||
|
||||
# Handle NRPN state machine
|
||||
resolved = self._handle_nrpn_state(msg)
|
||||
if resolved is None:
|
||||
return results # Intermediate NRPN message, not a value
|
||||
msg = resolved
|
||||
|
||||
with self._mappings_lock:
|
||||
mappings = list(self._mappings)
|
||||
|
||||
for mapping in mappings:
|
||||
if not mapping.enabled:
|
||||
continue
|
||||
if mapping.matches(msg) and msg.value is not None:
|
||||
scaled = mapping.scale_value(msg.value)
|
||||
results.append((mapping, scaled))
|
||||
self.mapped_count += 1
|
||||
|
||||
# Fire callbacks
|
||||
for mapping, scaled in results:
|
||||
for cb in self._callbacks:
|
||||
try:
|
||||
cb(mapping, scaled, msg)
|
||||
except Exception as exc:
|
||||
logger.error("Mapping callback failed: %s", exc)
|
||||
|
||||
return results
|
||||
|
||||
def _handle_nrpn_state(self, msg: MIDIMessage) -> MIDIMessage | None:
|
||||
"""Track NRPN parameter selection and return synthetic value messages.
|
||||
|
||||
NRPN protocol:
|
||||
1. CC 99 (NRPN MSB) — parameter number high 7 bits
|
||||
2. CC 98 (NRPN LSB) — parameter number low 7 bits
|
||||
3. CC 6 (Data Entry MSB) — value high 7 bits
|
||||
4. CC 38 (Data Entry LSB) — value low 7 bits (optional)
|
||||
Or CC 96 (Increment) / CC 97 (Decrement) for relative changes
|
||||
|
||||
Returns None for intermediate messages (99, 98, 38 without data MSB).
|
||||
Returns a synthetic MIDIMessage with is_nrpn=True when a complete
|
||||
value arrives.
|
||||
"""
|
||||
if msg.msg_type != MIDIMessageType.CONTROL_CHANGE or msg.controller is None:
|
||||
return msg # Not a CC message
|
||||
|
||||
key = (msg.source_device, msg.channel)
|
||||
state = self._nrpn_state[key]
|
||||
|
||||
if msg.controller == 99: # NRPN MSB
|
||||
state["msb"] = msg.value
|
||||
return None
|
||||
elif msg.controller == 98: # NRPN LSB
|
||||
state["lsb"] = msg.value
|
||||
return None
|
||||
elif msg.controller == 6: # Data Entry MSB
|
||||
# A value just arrived — build synthetic NRPN message
|
||||
nrpn_msb = state.get("msb")
|
||||
nrpn_lsb = state.get("lsb")
|
||||
if nrpn_msb is None or nrpn_lsb is None:
|
||||
# NRPN parameter not fully selected yet
|
||||
state["data_msb"] = msg.value
|
||||
return None
|
||||
|
||||
# Combine MSB + LSB for 14-bit value (if CC 38 follows, it refines LSB)
|
||||
value = (msg.value << 7) if msg.value is not None else 0
|
||||
state["data_msb"] = msg.value
|
||||
return None # Wait for CC 38 or treat as 7-bit
|
||||
elif msg.controller == 38: # Data Entry LSB
|
||||
# Completes the 14-bit value
|
||||
nrpn_msb = state.get("msb")
|
||||
nrpn_lsb = state.get("lsb")
|
||||
data_msb = state.get("data_msb")
|
||||
if nrpn_msb is None or nrpn_lsb is None:
|
||||
return None
|
||||
|
||||
value = ((data_msb or 0) << 7) | (msg.value or 0)
|
||||
return MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=msg.channel,
|
||||
is_nrpn=True,
|
||||
nrpn_msb=nrpn_msb,
|
||||
nrpn_lsb=nrpn_lsb,
|
||||
value=value,
|
||||
timestamp=msg.timestamp,
|
||||
source_device=msg.source_device,
|
||||
)
|
||||
elif msg.controller == 96: # Data Increment
|
||||
nrpn_msb = state.get("msb")
|
||||
nrpn_lsb = state.get("lsb")
|
||||
if nrpn_msb is None or nrpn_lsb is None:
|
||||
return None
|
||||
return MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=msg.channel,
|
||||
is_nrpn=True,
|
||||
nrpn_msb=nrpn_msb,
|
||||
nrpn_lsb=nrpn_lsb,
|
||||
value=1, # Increment by 1
|
||||
timestamp=msg.timestamp,
|
||||
source_device=msg.source_device,
|
||||
)
|
||||
elif msg.controller == 97: # Data Decrement
|
||||
nrpn_msb = state.get("msb")
|
||||
nrpn_lsb = state.get("lsb")
|
||||
if nrpn_msb is None or nrpn_lsb is None:
|
||||
return None
|
||||
return MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=msg.channel,
|
||||
is_nrpn=True,
|
||||
nrpn_msb=nrpn_msb,
|
||||
nrpn_lsb=nrpn_lsb,
|
||||
value=-1, # Decrement by 1
|
||||
timestamp=msg.timestamp,
|
||||
source_device=msg.source_device,
|
||||
)
|
||||
else:
|
||||
# Regular CC — reset NRPN state for this channel
|
||||
state["msb"] = None
|
||||
state["lsb"] = None
|
||||
state["data_msb"] = None
|
||||
return msg # Regular CC, pass through
|
||||
|
||||
return msg # Shouldn't reach here, but just in case
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self.uptime_start = time.monotonic()
|
||||
logger.info("MIDI engine started")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
logger.info("MIDI engine stopped (events: %d, mapped: %d)", self.event_count, self.mapped_count)
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
uptime = time.monotonic() - self.uptime_start if self.uptime_start else 0
|
||||
return {
|
||||
"events_total": self.event_count,
|
||||
"events_mapped": self.mapped_count,
|
||||
"mappings_active": len(self._mappings),
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"events_per_second": round(self.event_count / uptime, 1) if uptime > 0 else 0,
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"""MIDI Learn Mode — state machine for interactive mapping assignment.
|
||||
|
||||
Learn mode listens for incoming MIDI messages and associates the
|
||||
last-received message pattern with a user-selected mixer parameter.
|
||||
|
||||
State machine:
|
||||
IDLE → LISTENING (user selects parameter to learn) → LISTENING (waits for MIDI)
|
||||
→ CAPTURED (message received, awaiting confirmation)
|
||||
→ IDLE (confirmed/discarded)
|
||||
|
||||
Supports:
|
||||
- Single learn: assign one CC to one parameter
|
||||
- Batch learn: rapid-fire assignment (activate, wiggle knob, next param, repeat)
|
||||
- NRPN learn: detects NRPN parameter numbers automatically
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .types import (
|
||||
MIDIMessage,
|
||||
MIDIMessageType,
|
||||
MIDIMapping,
|
||||
ParameterType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LearnState(enum.Enum):
|
||||
IDLE = "idle"
|
||||
LISTENING = "listening" # Waiting for MIDI input
|
||||
CAPTURED = "captured" # MIDI received, awaiting confirmation
|
||||
BATCH = "batch" # Batch mode: auto-confirm and stay listening
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnSession:
|
||||
"""Tracks the state of one learn operation."""
|
||||
|
||||
state: LearnState = LearnState.IDLE
|
||||
|
||||
# What parameter are we learning for?
|
||||
param_type: ParameterType | None = None
|
||||
param_channel: int = -1
|
||||
param_label: str = ""
|
||||
|
||||
# Captured MIDI source
|
||||
captured_msg: MIDIMessage | None = None
|
||||
captured_cc: int = -1
|
||||
captured_channel: int = 0
|
||||
captured_is_nrpn: bool = False
|
||||
captured_nrpn: int = 0
|
||||
captured_device: str = ""
|
||||
|
||||
# Batch mode
|
||||
batch_index: int = 0 # Current position in the batch parameter list
|
||||
batch_params: list[tuple[ParameterType, int, str]] = field(default_factory=list)
|
||||
batch_mappings: list[MIDIMapping] = field(default_factory=list)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset to idle, discarding any captured state."""
|
||||
self.state = LearnState.IDLE
|
||||
self.captured_msg = None
|
||||
self.captured_cc = -1
|
||||
self.captured_channel = 0
|
||||
self.captured_is_nrpn = False
|
||||
self.captured_nrpn = 0
|
||||
self.captured_device = ""
|
||||
self.batch_index = 0
|
||||
self.batch_params.clear()
|
||||
self.batch_mappings.clear()
|
||||
|
||||
|
||||
class MIDILearn:
|
||||
"""Learn mode manager.
|
||||
|
||||
Usage:
|
||||
learn = MIDILearn()
|
||||
learn.on_mapped = engine.on_mapped # Forward learned mappings
|
||||
|
||||
# Single learn
|
||||
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||
# ... wait for MIDI event (call learn.feed_message(msg))
|
||||
mapping = learn.confirm() # → MIDIMapping if captured
|
||||
|
||||
# Batch learn
|
||||
learn.start_batch([
|
||||
(ParameterType.VOLUME, 0, "CH1 Vol"),
|
||||
(ParameterType.PAN, 0, "CH1 Pan"),
|
||||
(ParameterType.MUTE, 0, "CH1 Mute"),
|
||||
])
|
||||
# feed each message... each capture auto-advances
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.session = LearnSession()
|
||||
self._on_learned: list = [] # callbacks receiving (mapping,)
|
||||
|
||||
def on_learned(self, callback):
|
||||
"""Register callback: callback(MIDIMapping) on confirmation."""
|
||||
self._on_learned.append(callback)
|
||||
|
||||
# ── Learn lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
def start_learn(self, param_type: ParameterType, channel: int = -1, label: str = "") -> None:
|
||||
"""Begin single learn mode for one parameter."""
|
||||
self.session.reset()
|
||||
self.session.state = LearnState.LISTENING
|
||||
self.session.param_type = param_type
|
||||
self.session.param_channel = channel
|
||||
self.session.param_label = label
|
||||
logger.info("Learn mode: listening for %s CH%d", param_type.value, channel)
|
||||
|
||||
def start_batch(self, params: list[tuple[ParameterType, int, str]]) -> None:
|
||||
"""Begin batch learn mode for a sequence of parameters.
|
||||
|
||||
Each incoming MIDI message is auto-mapped to the current parameter
|
||||
and the session advances to the next. Confirmation is automatic.
|
||||
"""
|
||||
self.session.reset()
|
||||
self.session.state = LearnState.BATCH
|
||||
self.session.batch_params = list(params)
|
||||
self.session.batch_index = 0
|
||||
self._set_batch_target()
|
||||
logger.info("Learn mode: batch of %d params", len(params))
|
||||
|
||||
def _set_batch_target(self) -> None:
|
||||
"""Update the current batch target."""
|
||||
if self.session.batch_index < len(self.session.batch_params):
|
||||
pt, ch, label = self.session.batch_params[self.session.batch_index]
|
||||
self.session.param_type = pt
|
||||
self.session.param_channel = ch
|
||||
self.session.param_label = label
|
||||
|
||||
def feed_message(self, msg: MIDIMessage) -> MIDIMapping | None:
|
||||
"""Accept an incoming MIDI message for learn capture.
|
||||
|
||||
Returns a MIDIMapping if the message triggered a mapping confirmation
|
||||
(single confirm or batch auto-assign). Returns None if still listening
|
||||
or if the message was ignored.
|
||||
"""
|
||||
if self.session.state not in (LearnState.LISTENING, LearnState.BATCH, LearnState.CAPTURED):
|
||||
return None
|
||||
|
||||
# Only care about CC and NRPN messages (and notes for transport triggers)
|
||||
if msg.msg_type not in (MIDIMessageType.CONTROL_CHANGE, MIDIMessageType.NOTE_ON, MIDIMessageType.NOTE_OFF):
|
||||
return None
|
||||
|
||||
if self.session.state in (LearnState.LISTENING, LearnState.BATCH):
|
||||
self._capture(msg)
|
||||
|
||||
if self.session.state == LearnState.CAPTURED:
|
||||
# In single mode, we now have the capture; caller must confirm()
|
||||
return None
|
||||
|
||||
if self.session.state == LearnState.BATCH:
|
||||
# Auto-confirm and advance
|
||||
return self._batch_confirm()
|
||||
|
||||
return None
|
||||
|
||||
def _capture(self, msg: MIDIMessage) -> None:
|
||||
"""Store the incoming MIDI message as the captured source."""
|
||||
# Determine CC or NRPN
|
||||
if msg.is_nrpn:
|
||||
cc = -1
|
||||
is_nrpn = True
|
||||
nrpn = msg.nrpn_number or 0
|
||||
else:
|
||||
cc = msg.controller if msg.controller is not None else -1
|
||||
is_nrpn = False
|
||||
nrpn = 0
|
||||
|
||||
self.session.captured_msg = msg
|
||||
self.session.captured_cc = cc
|
||||
self.session.captured_channel = msg.channel
|
||||
self.session.captured_is_nrpn = is_nrpn
|
||||
self.session.captured_nrpn = nrpn
|
||||
self.session.captured_device = msg.source_device
|
||||
|
||||
if self.session.state == LearnState.LISTENING:
|
||||
self.session.state = LearnState.CAPTURED
|
||||
|
||||
logger.info(
|
||||
"Learn captured: ch=%d %s=%d device=%s",
|
||||
msg.channel,
|
||||
"NRPN" if is_nrpn else "CC",
|
||||
nrpn if is_nrpn else cc,
|
||||
msg.source_device,
|
||||
)
|
||||
|
||||
def confirm(self) -> MIDIMapping | None:
|
||||
"""Confirm the captured mapping in single-learn mode.
|
||||
|
||||
Returns the created MIDIMapping, or None if nothing captured.
|
||||
"""
|
||||
if self.session.state != LearnState.CAPTURED:
|
||||
return None
|
||||
|
||||
mapping = self._build_mapping()
|
||||
self.session.reset()
|
||||
logger.info("Learn confirmed: %s", mapping.label or f"CC{mapping.controller}")
|
||||
self._notify(mapping)
|
||||
return mapping
|
||||
|
||||
def _batch_confirm(self) -> MIDIMapping | None:
|
||||
"""Auto-confirm in batch mode and advance to next parameter."""
|
||||
mapping = self._build_mapping()
|
||||
self.session.batch_mappings.append(mapping)
|
||||
logger.info("Batch learn [%d/%d]: %s",
|
||||
self.session.batch_index + 1,
|
||||
len(self.session.batch_params),
|
||||
mapping.label or f"CC{mapping.controller}")
|
||||
self._notify(mapping)
|
||||
|
||||
self.session.batch_index += 1
|
||||
if self.session.batch_index >= len(self.session.batch_params):
|
||||
# Batch complete
|
||||
self.session.state = LearnState.IDLE
|
||||
logger.info("Batch learn complete: %d mappings", len(self.session.batch_mappings))
|
||||
return mapping # Return last one
|
||||
|
||||
self._set_batch_target()
|
||||
return mapping
|
||||
|
||||
def discard(self) -> None:
|
||||
"""Discard the current capture and return to idle."""
|
||||
self.session.reset()
|
||||
logger.info("Learn discarded")
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Alias for discard."""
|
||||
self.discard()
|
||||
|
||||
# ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_mapping(self) -> MIDIMapping:
|
||||
"""Construct a MIDIMapping from captured state."""
|
||||
s = self.session
|
||||
msg_type = s.captured_msg.msg_type if s.captured_msg else MIDIMessageType.CONTROL_CHANGE
|
||||
return MIDIMapping(
|
||||
msg_type=msg_type,
|
||||
channel=s.captured_channel,
|
||||
controller=s.captured_cc,
|
||||
is_nrpn=s.captured_is_nrpn,
|
||||
nrpn_number=s.captured_nrpn,
|
||||
source_device=s.captured_device,
|
||||
param_type=s.param_type or ParameterType.VOLUME,
|
||||
param_channel=s.param_channel,
|
||||
label=s.param_label or self._auto_label(),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def _auto_label(self) -> str:
|
||||
"""Generate a human-readable label for the mapping."""
|
||||
s = self.session
|
||||
pt = s.param_type.value if s.param_type else "param"
|
||||
ch = f"CH{s.param_channel}" if s.param_channel >= 0 else "Master"
|
||||
src = f"CC{s.captured_cc}" if not s.captured_is_nrpn else f"NRPN{s.captured_nrpn}"
|
||||
return f"{ch} {pt} ← {src}"
|
||||
|
||||
def _notify(self, mapping: MIDIMapping) -> None:
|
||||
for cb in self._on_learned:
|
||||
try:
|
||||
cb(mapping)
|
||||
except Exception as exc:
|
||||
logger.error("Learn callback failed: %s", exc)
|
||||
|
||||
# ── Query state ──────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def is_idle(self) -> bool:
|
||||
return self.session.state == LearnState.IDLE
|
||||
|
||||
@property
|
||||
def is_listening(self) -> bool:
|
||||
return self.session.state == LearnState.LISTENING
|
||||
|
||||
@property
|
||||
def is_captured(self) -> bool:
|
||||
return self.session.state == LearnState.CAPTURED
|
||||
|
||||
@property
|
||||
def is_batch(self) -> bool:
|
||||
return self.session.state == LearnState.BATCH
|
||||
|
||||
@property
|
||||
def status_text(self) -> str:
|
||||
s = self.session
|
||||
pt = s.param_type
|
||||
pt_val = pt.value if pt else "?"
|
||||
pl = s.param_label or pt_val
|
||||
if s.state == LearnState.IDLE:
|
||||
return "Idle"
|
||||
if s.state == LearnState.LISTENING:
|
||||
return f"Listening for {pl}..."
|
||||
if s.state == LearnState.CAPTURED:
|
||||
src = f"CC{s.captured_cc}" if not s.captured_is_nrpn else f"NRPN{s.captured_nrpn}"
|
||||
return f"Captured {src} -> {pl} (confirm/discard)"
|
||||
if s.state == LearnState.BATCH:
|
||||
idx = s.batch_index + 1
|
||||
total = len(self.session.batch_params)
|
||||
return f"Batch [{idx}/{total}] {pl}..."
|
||||
return "Unknown"
|
||||
@@ -0,0 +1,256 @@
|
||||
"""MIDI types, enums, and message classes for the Raspberry Pi RT Audio Mixer.
|
||||
|
||||
Defines the canonical MIDI message representation, mixer parameter
|
||||
taxonomy, and mapping data structures used throughout the MIDI subsystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── MIDI protocol constants ────────────────────────────────────────────────
|
||||
|
||||
class MIDIMessageType(enum.IntEnum):
|
||||
"""MIDI channel voice message status nibbles (upper 4 bits)."""
|
||||
NOTE_OFF = 0x8
|
||||
NOTE_ON = 0x9
|
||||
POLY_PRESSURE = 0xA
|
||||
CONTROL_CHANGE = 0xB # CC
|
||||
PROGRAM_CHANGE = 0xC
|
||||
CHANNEL_PRESSURE = 0xD
|
||||
PITCH_BEND = 0xE
|
||||
|
||||
@classmethod
|
||||
def from_status(cls, status_byte: int) -> MIDIMessageType:
|
||||
return cls(status_byte >> 4)
|
||||
|
||||
@staticmethod
|
||||
def channel(status_byte: int) -> int:
|
||||
return status_byte & 0x0F
|
||||
|
||||
|
||||
class MIDISystemMessage(enum.IntEnum):
|
||||
"""System message types (status byte when channel nibble is ignored)."""
|
||||
SYSEX_START = 0xF0
|
||||
MTC_QUARTER = 0xF1 # MIDI Time Code quarter frame
|
||||
SONG_POSITION = 0xF2
|
||||
SONG_SELECT = 0xF3
|
||||
TUNE_REQUEST = 0xF6
|
||||
SYSEX_END = 0xF7
|
||||
TIMING_CLOCK = 0xF8
|
||||
START = 0xFA
|
||||
CONTINUE = 0xFB
|
||||
STOP = 0xFC
|
||||
ACTIVE_SENSE = 0xFE
|
||||
SYSTEM_RESET = 0xFF
|
||||
|
||||
|
||||
# ── Mixer parameter taxonomy ────────────────────────────────────────────────
|
||||
|
||||
class ParameterCategory(enum.StrEnum):
|
||||
"""Top-level categories of mixer parameters."""
|
||||
CHANNEL = "channel"
|
||||
MASTER = "master"
|
||||
FX = "fx"
|
||||
ROUTING = "routing"
|
||||
TRANSPORT = "transport"
|
||||
UTILITY = "utility"
|
||||
|
||||
|
||||
class ParameterType(enum.StrEnum):
|
||||
"""Concrete mixer parameter types.
|
||||
|
||||
Each type implies a value range and interpolation model.
|
||||
"""
|
||||
# Channel strip
|
||||
VOLUME = "volume" # dB or linear 0.0–1.0
|
||||
PAN = "pan" # -1.0 (L) to 1.0 (R)
|
||||
MUTE = "mute" # boolean
|
||||
SOLO = "solo" # boolean
|
||||
GAIN = "gain" # dB pre-fader
|
||||
PHASE_INVERT = "phase_invert" # boolean
|
||||
|
||||
# EQ (3-band parametric)
|
||||
EQ_LOW_FREQ = "eq_low_freq"
|
||||
EQ_LOW_GAIN = "eq_low_gain"
|
||||
EQ_LOW_Q = "eq_low_q"
|
||||
EQ_MID_FREQ = "eq_mid_freq"
|
||||
EQ_MID_GAIN = "eq_mid_gain"
|
||||
EQ_MID_Q = "eq_mid_q"
|
||||
EQ_HIGH_FREQ = "eq_high_freq"
|
||||
EQ_HIGH_GAIN = "eq_high_gain"
|
||||
EQ_HIGH_Q = "eq_high_q"
|
||||
EQ_ENABLE = "eq_enable"
|
||||
|
||||
# Dynamics
|
||||
COMP_THRESHOLD = "comp_threshold"
|
||||
COMP_RATIO = "comp_ratio"
|
||||
COMP_ATTACK = "comp_attack"
|
||||
COMP_RELEASE = "comp_release"
|
||||
COMP_GAIN = "comp_gain"
|
||||
GATE_THRESHOLD = "gate_threshold"
|
||||
GATE_RANGE = "gate_range"
|
||||
|
||||
# FX sends
|
||||
FX_SEND_A = "fx_send_a"
|
||||
FX_SEND_B = "fx_send_b"
|
||||
FX_SEND_C = "fx_send_c"
|
||||
FX_SEND_D = "fx_send_d"
|
||||
FX_RETURN_A = "fx_return_a"
|
||||
FX_RETURN_B = "fx_return_b"
|
||||
FX_RETURN_C = "fx_return_c"
|
||||
FX_RETURN_D = "fx_return_d"
|
||||
|
||||
# Master
|
||||
MASTER_VOLUME = "master_volume"
|
||||
MASTER_MUTE = "master_mute"
|
||||
MASTER_DIM = "master_dim"
|
||||
MONITOR_VOLUME = "monitor_volume"
|
||||
PHONES_VOLUME = "phones_volume"
|
||||
|
||||
# Transport
|
||||
PLAY = "play"
|
||||
STOP = "stop"
|
||||
RECORD = "record"
|
||||
LOOP = "loop"
|
||||
TEMPO = "tempo"
|
||||
TAP_TEMPO = "tap_tempo"
|
||||
|
||||
# Utility
|
||||
SNAPSHOT_LOAD = "snapshot_load"
|
||||
SNAPSHOT_SAVE = "snapshot_save"
|
||||
SCENE_NEXT = "scene_next"
|
||||
SCENE_PREV = "scene_prev"
|
||||
|
||||
|
||||
# ── Data classes ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MIDIMessage:
|
||||
"""Canonical MIDI message.
|
||||
|
||||
Normalises CC, NRPN, note, pitch bend, and system realtime messages
|
||||
into a single representation for the mapping engine.
|
||||
"""
|
||||
msg_type: MIDIMessageType
|
||||
channel: int # 0–15
|
||||
controller: int | None = None # CC number (0–127) or note number
|
||||
value: int | None = None # 0–127 for CC, 0–16383 for pitch bend
|
||||
is_nrpn: bool = False
|
||||
nrpn_msb: int | None = None # NRPN parameter number MSB (CC 99)
|
||||
nrpn_lsb: int | None = None # NRPN parameter number LSB (CC 98)
|
||||
timestamp: float = 0.0 # seconds (monotonic)
|
||||
source_device: str = "" # ALSA client name or USB device node
|
||||
|
||||
@property
|
||||
def is_cc(self) -> bool:
|
||||
return self.msg_type == MIDIMessageType.CONTROL_CHANGE and not self.is_nrpn
|
||||
|
||||
@property
|
||||
def nrpn_number(self) -> int | None:
|
||||
"""Full 14-bit NRPN number (MSB << 7 | LSB)."""
|
||||
if self.is_nrpn and self.nrpn_msb is not None and self.nrpn_lsb is not None:
|
||||
return (self.nrpn_msb << 7) | self.nrpn_lsb
|
||||
return None
|
||||
|
||||
@property
|
||||
def value_normalised(self) -> float:
|
||||
"""Value scaled to 0.0–1.0 for CC, -1.0–1.0 for pitch bend."""
|
||||
if self.value is None:
|
||||
return 0.0
|
||||
if self.msg_type == MIDIMessageType.PITCH_BEND:
|
||||
return (self.value - 8192) / 8192.0 # -1.0 to ~1.0
|
||||
return self.value / 127.0
|
||||
|
||||
def __repr__(self) -> str:
|
||||
base = f"ch={self.channel}"
|
||||
if self.is_nrpn:
|
||||
base += f" NRPN={self.nrpn_number}"
|
||||
elif self.controller is not None:
|
||||
base += f" CC={self.controller}"
|
||||
if self.value is not None:
|
||||
base += f" val={self.value}"
|
||||
if self.source_device:
|
||||
base += f" src={self.source_device}"
|
||||
return f"<MIDI {self.msg_type.name} {base}>"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MixerParameter:
|
||||
"""A bindable mixer parameter."""
|
||||
param_type: ParameterType
|
||||
category: ParameterCategory
|
||||
channel: int = -1 # -1 = master / global
|
||||
label: str = ""
|
||||
min_val: float = 0.0
|
||||
max_val: float = 1.0
|
||||
default_val: float = 0.5
|
||||
step: float = 0.0 # 0 = continuous; >0 = stepped
|
||||
|
||||
@property
|
||||
def full_label(self) -> str:
|
||||
if self.channel >= 0:
|
||||
return f"CH{self.channel} {self.label or self.param_type.value}"
|
||||
return self.label or self.param_type.value
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MIDIMapping:
|
||||
"""A single mapping from a MIDI message pattern to a mixer parameter."""
|
||||
# MIDI source pattern (match criteria)
|
||||
msg_type: MIDIMessageType = MIDIMessageType.CONTROL_CHANGE
|
||||
channel: int = 0
|
||||
controller: int = 0 # CC number or note number
|
||||
is_nrpn: bool = False
|
||||
nrpn_number: int = 0 # 14-bit NRPN parameter number
|
||||
source_device: str = "" # empty = any device
|
||||
|
||||
# Mixer target
|
||||
param_type: ParameterType = ParameterType.VOLUME
|
||||
param_channel: int = -1
|
||||
|
||||
# Value mapping
|
||||
midi_min: int = 0
|
||||
midi_max: int = 127
|
||||
param_min: float = 0.0
|
||||
param_max: float = 1.0
|
||||
invert: bool = False
|
||||
curve: str = "linear" # linear, logarithmic, exponential
|
||||
|
||||
# Metadata
|
||||
label: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
def scale_value(self, midi_value: int) -> float:
|
||||
"""Convert raw MIDI value to the parameter's output range."""
|
||||
raw = (midi_value - self.midi_min) / max(1, self.midi_max - self.midi_min)
|
||||
raw = max(0.0, min(1.0, raw))
|
||||
if self.invert:
|
||||
raw = 1.0 - raw
|
||||
|
||||
if self.curve == "logarithmic":
|
||||
import math
|
||||
raw = math.log10(1 + 9 * raw)
|
||||
elif self.curve == "exponential":
|
||||
raw = raw ** 2
|
||||
|
||||
return self.param_min + raw * (self.param_max - self.param_min)
|
||||
|
||||
def matches(self, msg: MIDIMessage) -> bool:
|
||||
"""Return True if msg matches this mapping's source criteria."""
|
||||
if msg.msg_type != self.msg_type:
|
||||
return False
|
||||
if self.channel >= 0 and msg.channel != self.channel:
|
||||
return False
|
||||
if self.is_nrpn:
|
||||
if not msg.is_nrpn or msg.nrpn_number != self.nrpn_number:
|
||||
return False
|
||||
else:
|
||||
if msg.controller != self.controller:
|
||||
return False
|
||||
if self.source_device and msg.source_device != self.source_device:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Mixer DSP engine — channel strips, routing matrix, buses, automation.
|
||||
|
||||
The mixer package provides the complete DSP engine for the
|
||||
Raspberry Pi RT Audio Mixer, including:
|
||||
- Channel strip parameter management (EQ, comp, gate, gain)
|
||||
- Carla OSC control for plugin parameter automation
|
||||
- JACK routing matrix for any-input-to-any-output signal flow
|
||||
- Bus manager (aux sends/returns, subgroups, VCA groups)
|
||||
- Fader automation with recording, playback, and scenes
|
||||
- Master DSP engine orchestrator
|
||||
|
||||
Companion to the midi package; MIDI events flow through the
|
||||
ParameterRegistry into the DSP engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Iterator
|
||||
|
||||
from ..midi.types import ParameterCategory, ParameterType, MixerParameter
|
||||
|
||||
|
||||
# ── Parameter change callback type ──────────────────────────────────────────
|
||||
|
||||
ParameterCallback = Callable[[MixerParameter, float], None]
|
||||
|
||||
|
||||
# ── Mixer strip factory ─────────────────────────────────────────────────────
|
||||
|
||||
def _channel_params(channel: int) -> list[MixerParameter]:
|
||||
"""Build the standard parameter set for one channel strip."""
|
||||
return [
|
||||
MixerParameter(ParameterType.VOLUME, ParameterCategory.CHANNEL, channel, "Volume", -60.0, 12.0, 0.0),
|
||||
MixerParameter(ParameterType.PAN, ParameterCategory.CHANNEL, channel, "Pan", -1.0, 1.0, 0.0),
|
||||
MixerParameter(ParameterType.MUTE, ParameterCategory.CHANNEL, channel, "Mute", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.SOLO, ParameterCategory.CHANNEL, channel, "Solo", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.GAIN, ParameterCategory.CHANNEL, channel, "Gain", -20.0, 60.0, 0.0),
|
||||
MixerParameter(ParameterType.PHASE_INVERT, ParameterCategory.CHANNEL, channel, "Phase", 0.0, 1.0, 0.0, step=1.0),
|
||||
# EQ
|
||||
MixerParameter(ParameterType.EQ_ENABLE, ParameterCategory.CHANNEL, channel, "EQ Enable", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.EQ_LOW_FREQ, ParameterCategory.CHANNEL, channel, "EQ Lo Freq", 20.0, 500.0, 100.0),
|
||||
MixerParameter(ParameterType.EQ_LOW_GAIN, ParameterCategory.CHANNEL, channel, "EQ Lo Gain", -15.0, 15.0, 0.0),
|
||||
MixerParameter(ParameterType.EQ_LOW_Q, ParameterCategory.CHANNEL, channel, "EQ Lo Q", 0.1, 6.0, 0.71),
|
||||
MixerParameter(ParameterType.EQ_MID_FREQ, ParameterCategory.CHANNEL, channel, "EQ Mid Freq", 200.0, 8000.0, 1000.0),
|
||||
MixerParameter(ParameterType.EQ_MID_GAIN, ParameterCategory.CHANNEL, channel, "EQ Mid Gain", -15.0, 15.0, 0.0),
|
||||
MixerParameter(ParameterType.EQ_MID_Q, ParameterCategory.CHANNEL, channel, "EQ Mid Q", 0.1, 6.0, 0.71),
|
||||
MixerParameter(ParameterType.EQ_HIGH_FREQ, ParameterCategory.CHANNEL, channel, "EQ Hi Freq", 2000.0, 20000.0, 5000.0),
|
||||
MixerParameter(ParameterType.EQ_HIGH_GAIN, ParameterCategory.CHANNEL, channel, "EQ Hi Gain", -15.0, 15.0, 0.0),
|
||||
MixerParameter(ParameterType.EQ_HIGH_Q, ParameterCategory.CHANNEL, channel, "EQ Hi Q", 0.1, 6.0, 0.71),
|
||||
# Dynamics
|
||||
MixerParameter(ParameterType.COMP_THRESHOLD, ParameterCategory.CHANNEL, channel, "Comp Thresh", -60.0, 0.0, -20.0),
|
||||
MixerParameter(ParameterType.COMP_RATIO, ParameterCategory.CHANNEL, channel, "Comp Ratio", 1.0, 20.0, 2.0),
|
||||
MixerParameter(ParameterType.COMP_ATTACK, ParameterCategory.CHANNEL, channel, "Comp Attack", 0.1, 100.0, 10.0),
|
||||
MixerParameter(ParameterType.COMP_RELEASE, ParameterCategory.CHANNEL, channel, "Comp Release", 10.0, 1000.0, 100.0),
|
||||
MixerParameter(ParameterType.COMP_GAIN, ParameterCategory.CHANNEL, channel, "Comp Gain", -20.0, 20.0, 0.0),
|
||||
MixerParameter(ParameterType.GATE_THRESHOLD, ParameterCategory.CHANNEL, channel, "Gate Thresh", -80.0, 0.0, -40.0),
|
||||
MixerParameter(ParameterType.GATE_RANGE, ParameterCategory.CHANNEL, channel, "Gate Range", -80.0, 0.0, -60.0),
|
||||
# FX sends (2 sends)
|
||||
MixerParameter(ParameterType.FX_SEND_A, ParameterCategory.CHANNEL, channel, "FX Send A", -60.0, 12.0, -60.0),
|
||||
MixerParameter(ParameterType.FX_SEND_B, ParameterCategory.CHANNEL, channel, "FX Send B", -60.0, 12.0, -60.0),
|
||||
]
|
||||
|
||||
|
||||
def _master_params() -> list[MixerParameter]:
|
||||
"""Build master bus parameters."""
|
||||
return [
|
||||
MixerParameter(ParameterType.MASTER_VOLUME, ParameterCategory.MASTER, -1, "Master Vol", -60.0, 12.0, 0.0),
|
||||
MixerParameter(ParameterType.MASTER_MUTE, ParameterCategory.MASTER, -1, "Master Mute", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.MASTER_DIM, ParameterCategory.MASTER, -1, "Dim", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.MONITOR_VOLUME, ParameterCategory.MASTER, -1, "Monitor Vol", -60.0, 12.0, 0.0),
|
||||
MixerParameter(ParameterType.PHONES_VOLUME, ParameterCategory.MASTER, -1, "Phones Vol", -60.0, 12.0, 0.0),
|
||||
]
|
||||
|
||||
|
||||
def _fx_params() -> list[MixerParameter]:
|
||||
"""Build FX return parameters."""
|
||||
return [
|
||||
MixerParameter(ParameterType.FX_RETURN_A, ParameterCategory.FX, -1, "FX Return A", -60.0, 12.0, 0.0),
|
||||
MixerParameter(ParameterType.FX_RETURN_B, ParameterCategory.FX, -1, "FX Return B", -60.0, 12.0, 0.0),
|
||||
MixerParameter(ParameterType.FX_RETURN_C, ParameterCategory.FX, -1, "FX Return C", -60.0, 12.0, 0.0),
|
||||
MixerParameter(ParameterType.FX_RETURN_D, ParameterCategory.FX, -1, "FX Return D", -60.0, 12.0, 0.0),
|
||||
]
|
||||
|
||||
|
||||
def _transport_params() -> list[MixerParameter]:
|
||||
"""Build transport parameters."""
|
||||
return [
|
||||
MixerParameter(ParameterType.PLAY, ParameterCategory.TRANSPORT, -1, "Play", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.STOP, ParameterCategory.TRANSPORT, -1, "Stop", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.RECORD, ParameterCategory.TRANSPORT, -1, "Record", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.LOOP, ParameterCategory.TRANSPORT, -1, "Loop", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.TEMPO, ParameterCategory.TRANSPORT, -1, "Tempo BPM", 20.0, 300.0, 120.0),
|
||||
MixerParameter(ParameterType.TAP_TEMPO, ParameterCategory.TRANSPORT, -1, "Tap Tempo", 0.0, 1.0, 0.0, step=1.0),
|
||||
]
|
||||
|
||||
|
||||
def _utility_params() -> list[MixerParameter]:
|
||||
"""Build utility parameters."""
|
||||
return [
|
||||
MixerParameter(ParameterType.SNAPSHOT_LOAD, ParameterCategory.UTILITY, -1, "Snapshot Load", 0.0, 127.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.SNAPSHOT_SAVE, ParameterCategory.UTILITY, -1, "Snapshot Save", 0.0, 127.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.SCENE_NEXT, ParameterCategory.UTILITY, -1, "Scene Next", 0.0, 1.0, 0.0, step=1.0),
|
||||
MixerParameter(ParameterType.SCENE_PREV, ParameterCategory.UTILITY, -1, "Scene Prev", 0.0, 1.0, 0.0, step=1.0),
|
||||
]
|
||||
|
||||
|
||||
# ── Parameter registry ──────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ParameterRegistry:
|
||||
"""Holds all mixer parameters and dispatches value changes.
|
||||
|
||||
This is the runtime registry the MIDI engine writes parameter
|
||||
changes into. Downstream consumers (DSP engine, UI) register
|
||||
callbacks to receive updates.
|
||||
"""
|
||||
channels: int = 16
|
||||
params: dict[tuple[ParameterType, int], MixerParameter] = field(default_factory=dict)
|
||||
_callbacks: list[ParameterCallback] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
"""Populate the registry with default parameter set."""
|
||||
# Master bus
|
||||
for p in _master_params():
|
||||
self.params[(p.param_type, p.channel)] = p
|
||||
|
||||
# FX returns
|
||||
for p in _fx_params():
|
||||
self.params[(p.param_type, p.channel)] = p
|
||||
|
||||
# Transport
|
||||
for p in _transport_params():
|
||||
self.params[(p.param_type, p.channel)] = p
|
||||
|
||||
# Utility
|
||||
for p in _utility_params():
|
||||
self.params[(p.param_type, p.channel)] = p
|
||||
|
||||
# Channel strips
|
||||
for ch in range(self.channels):
|
||||
for p in _channel_params(ch):
|
||||
self.params[(p.param_type, p.channel)] = p
|
||||
|
||||
def get(self, param_type: ParameterType, channel: int = -1) -> MixerParameter | None:
|
||||
"""Look up a parameter by type and channel."""
|
||||
return self.params.get((param_type, channel))
|
||||
|
||||
def iter_all(self) -> Iterator[MixerParameter]:
|
||||
yield from self.params.values()
|
||||
|
||||
def iter_by_category(self, category: ParameterCategory) -> Iterator[MixerParameter]:
|
||||
for p in self.params.values():
|
||||
if p.category == category:
|
||||
yield p
|
||||
|
||||
def iter_by_channel(self, channel: int) -> Iterator[MixerParameter]:
|
||||
for p in self.params.values():
|
||||
if p.channel == channel:
|
||||
yield p
|
||||
|
||||
# ── Callback management ─────────────────────────────────────────────
|
||||
|
||||
def subscribe(self, callback: ParameterCallback) -> None:
|
||||
"""Register a callback to receive parameter change events."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: ParameterCallback) -> None:
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def set_value(self, param_type: ParameterType, channel: int, value: float) -> None:
|
||||
"""Set a parameter value and notify subscribers."""
|
||||
param = self.get(param_type, channel)
|
||||
if param is None:
|
||||
return
|
||||
for cb in self._callbacks:
|
||||
try:
|
||||
cb(param, value)
|
||||
except Exception:
|
||||
pass # Don't let one broken callback break the chain
|
||||
|
||||
|
||||
# ── DSP engine exports ──────────────────────────────────────────────────────
|
||||
|
||||
from .osc_client import (
|
||||
CarlaOSCClient,
|
||||
CarlaPluginInfo,
|
||||
DEFAULT_PLUGIN_LAYOUT,
|
||||
encode_osc,
|
||||
decode_osc,
|
||||
linear_to_db,
|
||||
db_to_linear,
|
||||
freq_to_normalized,
|
||||
normalized_to_freq,
|
||||
time_ms_to_normalized,
|
||||
mix_to_pan,
|
||||
)
|
||||
|
||||
from .channel_strip import (
|
||||
ChannelStrip,
|
||||
ChannelState,
|
||||
)
|
||||
|
||||
from .routing_matrix import (
|
||||
RoutingMatrix,
|
||||
RouteNode,
|
||||
RoutingEdge,
|
||||
NodeType,
|
||||
get_jack_ports,
|
||||
get_jack_connections,
|
||||
)
|
||||
|
||||
from .bus_manager import (
|
||||
BusManager,
|
||||
AuxBus,
|
||||
SubgroupBus,
|
||||
VCAGroup,
|
||||
MasterBus,
|
||||
BusType,
|
||||
)
|
||||
|
||||
from .fader_automation import (
|
||||
FaderAutomation,
|
||||
AutomationLane,
|
||||
AutomationPoint,
|
||||
Scene,
|
||||
InterpolationMode,
|
||||
)
|
||||
|
||||
from .dsp_engine import (
|
||||
DSPEngine,
|
||||
DSPEngineConfig,
|
||||
create_default_engine,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Registry
|
||||
"ParameterRegistry",
|
||||
"ParameterCallback",
|
||||
# OSC
|
||||
"CarlaOSCClient",
|
||||
"CarlaPluginInfo",
|
||||
"DEFAULT_PLUGIN_LAYOUT",
|
||||
"encode_osc",
|
||||
"decode_osc",
|
||||
"linear_to_db",
|
||||
"db_to_linear",
|
||||
"freq_to_normalized",
|
||||
"normalized_to_freq",
|
||||
"time_ms_to_normalized",
|
||||
"mix_to_pan",
|
||||
# Channel strip
|
||||
"ChannelStrip",
|
||||
"ChannelState",
|
||||
# Routing
|
||||
"RoutingMatrix",
|
||||
"RouteNode",
|
||||
"RoutingEdge",
|
||||
"NodeType",
|
||||
"get_jack_ports",
|
||||
"get_jack_connections",
|
||||
# Buses
|
||||
"BusManager",
|
||||
"AuxBus",
|
||||
"SubgroupBus",
|
||||
"VCAGroup",
|
||||
"MasterBus",
|
||||
"BusType",
|
||||
# Automation
|
||||
"FaderAutomation",
|
||||
"AutomationLane",
|
||||
"AutomationPoint",
|
||||
"Scene",
|
||||
"InterpolationMode",
|
||||
# Engine
|
||||
"DSPEngine",
|
||||
"DSPEngineConfig",
|
||||
"create_default_engine",
|
||||
]
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Bus manager — aux sends/returns, subgroups, VCA groups, master bus.
|
||||
|
||||
Manages the mixer's bus architecture:
|
||||
- Aux buses: configurable sends per channel → aux bus → return (with FX) → master
|
||||
- Subgroups: group channels together, apply group-level processing
|
||||
- VCA groups: gang faders together (relative level only, no signal routing)
|
||||
- Master bus: stereo summing, insert points, master fader, dim, mute
|
||||
|
||||
Buses manage gain, mute, and routing but don't perform DSP themselves —
|
||||
that's handled by Carla plugins connected to the appropriate JACK ports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BusType(StrEnum):
|
||||
AUX = "aux"
|
||||
SUBGROUP = "subgroup"
|
||||
VCA = "vca"
|
||||
MASTER = "master"
|
||||
|
||||
|
||||
# ── Data classes ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuxBus:
|
||||
"""An aux send/return bus.
|
||||
|
||||
Each channel has a send level to the aux bus. The bus sums all
|
||||
sends, optionally routes through an FX plugin (Carla), then
|
||||
returns to the master bus at the return level.
|
||||
"""
|
||||
index: int # 0-based aux number
|
||||
label: str = ""
|
||||
send_gain_db: float = 0.0 # overall send master (dB)
|
||||
return_gain_db: float = 0.0 # overall return master (dB)
|
||||
muted: bool = False
|
||||
pre_fader: bool = False # True = pre-fader send, False = post-fader
|
||||
fx_plugin_id: int | None = None # Carla plugin ID for FX processing
|
||||
jack_send_port: str = "" # JACK port for the aux bus input
|
||||
jack_return_port: str = "" # JACK port for the aux bus output
|
||||
|
||||
# Per-channel send levels (dB, -inf to +12)
|
||||
channel_sends: dict[int, float] = field(default_factory=dict)
|
||||
# Per-channel send mutes
|
||||
channel_mutes: dict[int, bool] = field(default_factory=dict)
|
||||
|
||||
def get_send(self, channel: int) -> float:
|
||||
return self.channel_sends.get(channel, -60.0)
|
||||
|
||||
def set_send(self, channel: int, db: float) -> None:
|
||||
self.channel_sends[channel] = db
|
||||
|
||||
def get_mute(self, channel: int) -> bool:
|
||||
return self.channel_mutes.get(channel, False)
|
||||
|
||||
def set_mute(self, channel: int, muted: bool) -> None:
|
||||
self.channel_mutes[channel] = muted
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubgroupBus:
|
||||
"""A subgroup bus.
|
||||
|
||||
Channels assigned to a subgroup have their post-fader signal
|
||||
summed into the subgroup bus. The subgroup can then be routed
|
||||
to the master bus with its own level control, or sent to a
|
||||
separate output.
|
||||
"""
|
||||
index: int
|
||||
label: str = ""
|
||||
volume_db: float = 0.0
|
||||
pan: float = 0.0 # -1.0 L to 1.0 R
|
||||
muted: bool = False
|
||||
solo: bool = False
|
||||
is_stereo: bool = True
|
||||
jack_out_port_l: str = "" # JACK output L
|
||||
jack_out_port_r: str = "" # JACK output R
|
||||
members: set[int] = field(default_factory=set) # channel indices
|
||||
|
||||
|
||||
@dataclass
|
||||
class VCAGroup:
|
||||
"""A VCA (Voltage Controlled Amplifier) group.
|
||||
|
||||
VCAs don't carry audio — they gang faders together. When you
|
||||
move the VCA master, all member channel faders move relatively.
|
||||
Members still route individually to buses/master.
|
||||
|
||||
The VCA master provides dB offset applied to member faders.
|
||||
"""
|
||||
index: int
|
||||
label: str = ""
|
||||
master_db: float = 0.0 # VCA master level (dB offset)
|
||||
muted: bool = False
|
||||
members: set[int] = field(default_factory=set) # channel indices
|
||||
|
||||
|
||||
@dataclass
|
||||
class MasterBus:
|
||||
"""The stereo master bus.
|
||||
|
||||
Sums all channels, subgroups, and aux returns. Has insert
|
||||
points for external processing and a final output stage.
|
||||
"""
|
||||
volume_db: float = 0.0 # master fader (dB)
|
||||
dim_db: float = -20.0 # dim attenuation (dB)
|
||||
dim_active: bool = False
|
||||
muted: bool = False
|
||||
mono: bool = False # mono sum
|
||||
insert_enabled: bool = False # insert loop active
|
||||
jack_insert_send_l: str = ""
|
||||
jack_insert_send_r: str = ""
|
||||
jack_insert_return_l: str = ""
|
||||
jack_insert_return_r: str = ""
|
||||
jack_out_l: str = "system:playback_1"
|
||||
jack_out_r: str = "system:playback_2"
|
||||
|
||||
@property
|
||||
def effective_gain(self) -> float:
|
||||
"""Linear gain of master output."""
|
||||
if self.muted:
|
||||
return 0.0
|
||||
db = self.volume_db
|
||||
if self.dim_active:
|
||||
db += self.dim_db
|
||||
return 10.0 ** (db / 20.0)
|
||||
|
||||
|
||||
# ── Bus Manager ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BusManager:
|
||||
"""Manages all mixer buses.
|
||||
|
||||
Coordinates aux sends, subgroups, VCA groups, and the master bus.
|
||||
Provides a unified interface for the DSP engine to query and update
|
||||
bus state during parameter changes.
|
||||
|
||||
The bus manager does NOT directly change JACK connections — that's
|
||||
the routing matrix's job. It maintains bus state and provides the
|
||||
information needed to update the routing matrix.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_channels: int = 16,
|
||||
num_aux: int = 4,
|
||||
num_subgroups: int = 2,
|
||||
num_vca: int = 2,
|
||||
):
|
||||
self.num_channels = num_channels
|
||||
self.num_aux = num_aux
|
||||
|
||||
# Buses
|
||||
self.aux_buses: list[AuxBus] = []
|
||||
self.subgroups: list[SubgroupBus] = []
|
||||
self.vca_groups: list[VCAGroup] = []
|
||||
self.master = MasterBus()
|
||||
|
||||
self._init_buses(num_aux, num_subgroups, num_vca)
|
||||
|
||||
def _init_buses(self, num_aux: int, num_subgroups: int, num_vca: int) -> None:
|
||||
"""Initialize default bus configuration."""
|
||||
# Aux buses
|
||||
for i in range(num_aux):
|
||||
self.aux_buses.append(AuxBus(
|
||||
index=i,
|
||||
label=f"AUX {i+1}",
|
||||
jack_send_port=f"Carla:aux_{i}_in",
|
||||
jack_return_port=f"Carla:aux_{i}_out",
|
||||
))
|
||||
|
||||
# Subgroups
|
||||
for i in range(num_subgroups):
|
||||
self.subgroups.append(SubgroupBus(
|
||||
index=i,
|
||||
label=f"Subgroup {i+1}",
|
||||
jack_out_port_l=f"system:playback_{(i*2)+3}" if (i*2)+3 <= 8 else "",
|
||||
jack_out_port_r=f"system:playback_{(i*2)+4}" if (i*2)+4 <= 8 else "",
|
||||
))
|
||||
|
||||
# VCA groups
|
||||
for i in range(num_vca):
|
||||
self.vca_groups.append(VCAGroup(
|
||||
index=i,
|
||||
label=f"VCA {i+1}",
|
||||
))
|
||||
|
||||
# ── Aux bus operations ──────────────────────────────────────────────
|
||||
|
||||
def get_aux(self, index: int) -> AuxBus | None:
|
||||
if 0 <= index < len(self.aux_buses):
|
||||
return self.aux_buses[index]
|
||||
return None
|
||||
|
||||
def set_aux_send(self, aux_index: int, channel: int, db: float) -> None:
|
||||
"""Set the send level from a channel to an aux bus."""
|
||||
aux = self.get_aux(aux_index)
|
||||
if aux:
|
||||
aux.set_send(channel, db)
|
||||
|
||||
def set_aux_return(self, aux_index: int, db: float) -> None:
|
||||
"""Set the aux return level."""
|
||||
aux = self.get_aux(aux_index)
|
||||
if aux:
|
||||
aux.return_gain_db = db
|
||||
|
||||
def set_aux_mute(self, aux_index: int, muted: bool) -> None:
|
||||
aux = self.get_aux(aux_index)
|
||||
if aux:
|
||||
aux.muted = muted
|
||||
|
||||
# ── Subgroup operations ─────────────────────────────────────────────
|
||||
|
||||
def get_subgroup(self, index: int) -> SubgroupBus | None:
|
||||
if 0 <= index < len(self.subgroups):
|
||||
return self.subgroups[index]
|
||||
return None
|
||||
|
||||
def subgroup_add_channel(self, sg_index: int, channel: int) -> bool:
|
||||
sg = self.get_subgroup(sg_index)
|
||||
if sg and 0 <= channel < self.num_channels:
|
||||
sg.members.add(channel)
|
||||
return True
|
||||
return False
|
||||
|
||||
def subgroup_remove_channel(self, sg_index: int, channel: int) -> bool:
|
||||
sg = self.get_subgroup(sg_index)
|
||||
if sg:
|
||||
sg.members.discard(channel)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_channel_subgroups(self, channel: int) -> list[SubgroupBus]:
|
||||
"""Get all subgroups a channel belongs to."""
|
||||
return [sg for sg in self.subgroups if channel in sg.members]
|
||||
|
||||
# ── VCA operations ──────────────────────────────────────────────────
|
||||
|
||||
def get_vca(self, index: int) -> VCAGroup | None:
|
||||
if 0 <= index < len(self.vca_groups):
|
||||
return self.vca_groups[index]
|
||||
return None
|
||||
|
||||
def vca_add_channel(self, vca_index: int, channel: int) -> bool:
|
||||
vca = self.get_vca(vca_index)
|
||||
if vca and 0 <= channel < self.num_channels:
|
||||
vca.members.add(channel)
|
||||
return True
|
||||
return False
|
||||
|
||||
def vca_remove_channel(self, vca_index: int, channel: int) -> bool:
|
||||
vca = self.get_vca(vca_index)
|
||||
if vca:
|
||||
vca.members.discard(channel)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_channel_vcas(self, channel: int) -> list[VCAGroup]:
|
||||
"""Get all VCA groups a channel belongs to."""
|
||||
return [vca for vca in self.vca_groups if channel in vca.members]
|
||||
|
||||
def get_channel_vca_offset(self, channel: int) -> float:
|
||||
"""Get the total VCA dB offset for a channel.
|
||||
|
||||
When a channel belongs to multiple VCAs, their master levels
|
||||
are summed to produce a total offset applied to the channel
|
||||
fader.
|
||||
"""
|
||||
offset = 0.0
|
||||
for vca in self.vca_groups:
|
||||
if channel in vca.members and not vca.muted:
|
||||
offset += vca.master_db
|
||||
return offset
|
||||
|
||||
# ── Master bus operations ───────────────────────────────────────────
|
||||
|
||||
def set_master_volume(self, db: float) -> None:
|
||||
self.master.volume_db = db
|
||||
|
||||
def set_master_mute(self, muted: bool) -> None:
|
||||
self.master.muted = muted
|
||||
|
||||
def set_master_dim(self, active: bool) -> None:
|
||||
self.master.dim_active = active
|
||||
|
||||
def set_master_mono(self, mono: bool) -> None:
|
||||
self.master.mono = mono
|
||||
|
||||
def set_master_insert(self, enabled: bool) -> None:
|
||||
self.master.insert_enabled = enabled
|
||||
|
||||
# ── Snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize all bus state to a dict."""
|
||||
aux_data = []
|
||||
for aux in self.aux_buses:
|
||||
aux_data.append({
|
||||
"index": aux.index,
|
||||
"label": aux.label,
|
||||
"send_gain_db": aux.send_gain_db,
|
||||
"return_gain_db": aux.return_gain_db,
|
||||
"muted": aux.muted,
|
||||
"pre_fader": aux.pre_fader,
|
||||
"channel_sends": dict(aux.channel_sends),
|
||||
"channel_mutes": dict(aux.channel_mutes),
|
||||
})
|
||||
|
||||
sg_data = []
|
||||
for sg in self.subgroups:
|
||||
sg_data.append({
|
||||
"index": sg.index,
|
||||
"label": sg.label,
|
||||
"volume_db": sg.volume_db,
|
||||
"pan": sg.pan,
|
||||
"muted": sg.muted,
|
||||
"solo": sg.solo,
|
||||
"members": list(sg.members),
|
||||
})
|
||||
|
||||
vca_data = []
|
||||
for vca in self.vca_groups:
|
||||
vca_data.append({
|
||||
"index": vca.index,
|
||||
"label": vca.label,
|
||||
"master_db": vca.master_db,
|
||||
"muted": vca.muted,
|
||||
"members": list(vca.members),
|
||||
})
|
||||
|
||||
return {
|
||||
"aux_buses": aux_data,
|
||||
"subgroups": sg_data,
|
||||
"vca_groups": vca_data,
|
||||
"master": {
|
||||
"volume_db": self.master.volume_db,
|
||||
"dim_active": self.master.dim_active,
|
||||
"muted": self.master.muted,
|
||||
"mono": self.master.mono,
|
||||
"insert_enabled": self.master.insert_enabled,
|
||||
},
|
||||
}
|
||||
|
||||
def from_dict(self, data: dict) -> None:
|
||||
"""Restore bus state from a dict."""
|
||||
for i, aux_d in enumerate(data.get("aux_buses", [])):
|
||||
if i < len(self.aux_buses):
|
||||
aux = self.aux_buses[i]
|
||||
aux.label = aux_d.get("label", aux.label)
|
||||
aux.send_gain_db = aux_d.get("send_gain_db", 0.0)
|
||||
aux.return_gain_db = aux_d.get("return_gain_db", 0.0)
|
||||
aux.muted = aux_d.get("muted", False)
|
||||
aux.pre_fader = aux_d.get("pre_fader", False)
|
||||
aux.channel_sends = {int(k): v for k, v in aux_d.get("channel_sends", {}).items()}
|
||||
aux.channel_mutes = {int(k): v for k, v in aux_d.get("channel_mutes", {}).items()}
|
||||
|
||||
for i, sg_d in enumerate(data.get("subgroups", [])):
|
||||
if i < len(self.subgroups):
|
||||
sg = self.subgroups[i]
|
||||
sg.label = sg_d.get("label", sg.label)
|
||||
sg.volume_db = sg_d.get("volume_db", 0.0)
|
||||
sg.pan = sg_d.get("pan", 0.0)
|
||||
sg.muted = sg_d.get("muted", False)
|
||||
sg.solo = sg_d.get("solo", False)
|
||||
sg.members = set(sg_d.get("members", []))
|
||||
|
||||
for i, vca_d in enumerate(data.get("vca_groups", [])):
|
||||
if i < len(self.vca_groups):
|
||||
vca = self.vca_groups[i]
|
||||
vca.label = vca_d.get("label", vca.label)
|
||||
vca.master_db = vca_d.get("master_db", 0.0)
|
||||
vca.muted = vca_d.get("muted", False)
|
||||
vca.members = set(vca_d.get("members", []))
|
||||
|
||||
m = data.get("master", {})
|
||||
self.master.volume_db = m.get("volume_db", 0.0)
|
||||
self.master.dim_active = m.get("dim_active", False)
|
||||
self.master.muted = m.get("muted", False)
|
||||
self.master.mono = m.get("mono", False)
|
||||
self.master.insert_enabled = m.get("insert_enabled", False)
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Channel strip controller.
|
||||
|
||||
Each channel strip manages the state of one mixer channel, including
|
||||
gain, 3-band EQ, compressor, gate, FX sends, and pan/volume. Parameter
|
||||
changes are forwarded to Carla via OSC for real-time DSP processing.
|
||||
|
||||
Supports N channels (configurable; default 16 to match the parameter registry).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from ..midi.types import ParameterType
|
||||
from .osc_client import (
|
||||
CarlaOSCClient,
|
||||
CarlaPluginInfo,
|
||||
DEFAULT_PLUGIN_LAYOUT,
|
||||
linear_to_db,
|
||||
db_to_linear,
|
||||
freq_to_normalized,
|
||||
normalized_to_freq,
|
||||
time_ms_to_normalized,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Channel strip state ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelState:
|
||||
"""Snapshot of all parameters for one channel strip."""
|
||||
|
||||
# Fader
|
||||
volume: float = 0.0 # dB (-60 to +12)
|
||||
pan: float = 0.0 # -1.0 L to 1.0 R
|
||||
mute: bool = False
|
||||
solo: bool = False
|
||||
|
||||
# Preamp
|
||||
gain: float = 0.0 # dB (-20 to +60)
|
||||
phase_invert: bool = False
|
||||
|
||||
# EQ (3-band)
|
||||
eq_enable: bool = False
|
||||
eq_low_freq: float = 100.0 # Hz
|
||||
eq_low_gain: float = 0.0 # dB
|
||||
eq_low_q: float = 0.71
|
||||
eq_mid_freq: float = 1000.0 # Hz
|
||||
eq_mid_gain: float = 0.0 # dB
|
||||
eq_mid_q: float = 0.71
|
||||
eq_high_freq: float = 5000.0 # Hz
|
||||
eq_high_gain: float = 0.0 # dB
|
||||
eq_high_q: float = 0.71
|
||||
|
||||
# Compressor
|
||||
comp_threshold: float = -20.0 # dB
|
||||
comp_ratio: float = 2.0 # :1
|
||||
comp_attack: float = 10.0 # ms
|
||||
comp_release: float = 100.0 # ms
|
||||
comp_gain: float = 0.0 # dB (makeup)
|
||||
|
||||
# Gate
|
||||
gate_threshold: float = -40.0 # dB
|
||||
gate_range: float = -60.0 # dB
|
||||
|
||||
# FX sends
|
||||
fx_send_a: float = -60.0 # dB (reverb)
|
||||
fx_send_b: float = -60.0 # dB (delay)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelStrip:
|
||||
"""Controller for one mixer channel strip.
|
||||
|
||||
Manages parameter state and dispatches changes to Carla OSC
|
||||
when a Carla plugin is mapped to this channel.
|
||||
|
||||
Channels that don't have Carla plugins (e.g., channels 5–16 in
|
||||
the default 4-channel rack) still maintain state — their audio
|
||||
routing is handled purely through JACK port connections; DSP
|
||||
parameters are no-ops but state is preserved for future
|
||||
expansion.
|
||||
"""
|
||||
|
||||
index: int # 0-based channel number
|
||||
state: ChannelState = field(default_factory=ChannelState)
|
||||
_osc: Optional[CarlaOSCClient] = None
|
||||
_plugins: dict[str, CarlaPluginInfo] = field(default_factory=dict) # role → plugin info
|
||||
_last_osc_update: dict[str, float] = field(default_factory=dict) # param → timestamp
|
||||
_update_coalesce_s: float = 0.005 # coalesce updates within 5ms
|
||||
|
||||
def bind_osc(self, client: CarlaOSCClient) -> None:
|
||||
"""Attach an OSC client for Carla control."""
|
||||
self._osc = client
|
||||
|
||||
def register_plugin(self, info: CarlaPluginInfo) -> None:
|
||||
"""Register a Carla plugin mapped to this channel."""
|
||||
self._plugins[info.role] = info
|
||||
logger.debug("CH%d registered plugin: %s (id=%d, role=%s)",
|
||||
self.index, info.name, info.plugin_id, info.role)
|
||||
|
||||
def unregister_plugin(self, role: str) -> None:
|
||||
"""Remove a plugin mapping."""
|
||||
self._plugins.pop(role, None)
|
||||
|
||||
@property
|
||||
def has_dsp(self) -> bool:
|
||||
"""True if this channel has Carla DSP plugins mapped."""
|
||||
return len(self._plugins) > 0
|
||||
|
||||
# ── Parameter dispatch ──────────────────────────────────────────────
|
||||
|
||||
def set_parameter(self, param_type: ParameterType, value: float) -> None:
|
||||
"""Set a parameter value and dispatch to Carla if applicable."""
|
||||
# Update state
|
||||
_apply_state(self.state, param_type, value)
|
||||
|
||||
# Dispatch to Carla OSC
|
||||
if self._osc:
|
||||
self._dispatch_osc(param_type, value)
|
||||
|
||||
def set_many(self, updates: dict[ParameterType, float]) -> None:
|
||||
"""Set multiple parameters at once (batch OSC where possible)."""
|
||||
for pt, val in updates.items():
|
||||
_apply_state(self.state, pt, val)
|
||||
|
||||
if self._osc:
|
||||
osc_updates = []
|
||||
for pt, val in updates.items():
|
||||
commands = _get_osc_commands(self._plugins, self.index, pt, val)
|
||||
osc_updates.extend(commands)
|
||||
|
||||
# Send all updates
|
||||
for plugin_id, param_idx, v in osc_updates:
|
||||
self._osc.set_parameter(plugin_id, param_idx, v)
|
||||
|
||||
def _dispatch_osc(self, param_type: ParameterType, value: float) -> None:
|
||||
"""Dispatch a single parameter to Carla OSC."""
|
||||
if not self._osc:
|
||||
return
|
||||
|
||||
# Coalesce rapid updates for the same parameter
|
||||
now = time.monotonic()
|
||||
key = f"{param_type.value}"
|
||||
last = self._last_osc_update.get(key, 0)
|
||||
if now - last < self._update_coalesce_s:
|
||||
return # skip — too close to last update
|
||||
self._last_osc_update[key] = now
|
||||
|
||||
commands = _get_osc_commands(self._plugins, self.index, param_type, value)
|
||||
for plugin_id, param_idx, v in commands:
|
||||
self._osc.set_parameter(plugin_id, param_idx, v)
|
||||
|
||||
# ── Snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
def snapshot(self) -> ChannelState:
|
||||
"""Return a copy of current state."""
|
||||
return ChannelState(**self.state.__dict__)
|
||||
|
||||
def restore(self, state: ChannelState, send_osc: bool = True) -> None:
|
||||
"""Restore state from a snapshot."""
|
||||
self.state = ChannelState(**state.__dict__)
|
||||
if send_osc and self._osc:
|
||||
# Send all current parameters to OSC
|
||||
updates = _state_to_osc_updates(self._plugins, self.state)
|
||||
for plugin_id, param_idx, val in updates:
|
||||
self._osc.set_parameter(plugin_id, param_idx, val)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset to default state and send to Carla."""
|
||||
self.restore(ChannelState(), send_osc=True)
|
||||
|
||||
|
||||
# ── Parameter → state mapping ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _apply_state(cs: ChannelState, pt: ParameterType, value: float) -> None:
|
||||
"""Apply a parameter value to channel state."""
|
||||
match pt:
|
||||
case ParameterType.VOLUME: cs.volume = value
|
||||
case ParameterType.PAN: cs.pan = value
|
||||
case ParameterType.MUTE: cs.mute = value >= 0.5
|
||||
case ParameterType.SOLO: cs.solo = value >= 0.5
|
||||
case ParameterType.GAIN: cs.gain = value
|
||||
case ParameterType.PHASE_INVERT: cs.phase_invert = value >= 0.5
|
||||
case ParameterType.EQ_ENABLE: cs.eq_enable = value >= 0.5
|
||||
case ParameterType.EQ_LOW_FREQ: cs.eq_low_freq = value
|
||||
case ParameterType.EQ_LOW_GAIN: cs.eq_low_gain = value
|
||||
case ParameterType.EQ_LOW_Q: cs.eq_low_q = value
|
||||
case ParameterType.EQ_MID_FREQ: cs.eq_mid_freq = value
|
||||
case ParameterType.EQ_MID_GAIN: cs.eq_mid_gain = value
|
||||
case ParameterType.EQ_MID_Q: cs.eq_mid_q = value
|
||||
case ParameterType.EQ_HIGH_FREQ: cs.eq_high_freq = value
|
||||
case ParameterType.EQ_HIGH_GAIN: cs.eq_high_gain = value
|
||||
case ParameterType.EQ_HIGH_Q: cs.eq_high_q = value
|
||||
case ParameterType.COMP_THRESHOLD: cs.comp_threshold = value
|
||||
case ParameterType.COMP_RATIO: cs.comp_ratio = value
|
||||
case ParameterType.COMP_ATTACK: cs.comp_attack = value
|
||||
case ParameterType.COMP_RELEASE: cs.comp_release = value
|
||||
case ParameterType.COMP_GAIN: cs.comp_gain = value
|
||||
case ParameterType.GATE_THRESHOLD: cs.gate_threshold = value
|
||||
case ParameterType.GATE_RANGE: cs.gate_range = value
|
||||
case ParameterType.FX_SEND_A: cs.fx_send_a = value
|
||||
case ParameterType.FX_SEND_B: cs.fx_send_b = value
|
||||
case _:
|
||||
logger.debug("CH%d: unhandled param %s", cs.volume, pt.value)
|
||||
|
||||
|
||||
# ── Parameter → OSC command mapping ─────────────────────────────────────────
|
||||
|
||||
# Maps ParameterType to (plugin_role, param_name, value_transform_fn)
|
||||
_PARAM_TO_OSC: dict[ParameterType, tuple[str, str]] = {
|
||||
# Gate
|
||||
ParameterType.GATE_THRESHOLD: ("gate", "threshold"),
|
||||
ParameterType.GATE_RANGE: ("gate", "range"),
|
||||
# EQ
|
||||
ParameterType.EQ_LOW_FREQ: ("eq", "low_freq"),
|
||||
ParameterType.EQ_LOW_GAIN: ("eq", "low_gain"),
|
||||
ParameterType.EQ_LOW_Q: ("eq", "low_q"),
|
||||
ParameterType.EQ_MID_FREQ: ("eq", "mid_freq"),
|
||||
ParameterType.EQ_MID_GAIN: ("eq", "mid_gain"),
|
||||
ParameterType.EQ_MID_Q: ("eq", "mid_q"),
|
||||
ParameterType.EQ_HIGH_FREQ: ("eq", "high_freq"),
|
||||
ParameterType.EQ_HIGH_GAIN: ("eq", "high_gain"),
|
||||
ParameterType.EQ_HIGH_Q: ("eq", "high_q"),
|
||||
# Compressor
|
||||
ParameterType.COMP_THRESHOLD: ("comp", "threshold"),
|
||||
ParameterType.COMP_RATIO: ("comp", "ratio"),
|
||||
ParameterType.COMP_ATTACK: ("comp", "attack"),
|
||||
ParameterType.COMP_RELEASE: ("comp", "release"),
|
||||
ParameterType.COMP_GAIN: ("comp", "makeup"),
|
||||
}
|
||||
|
||||
|
||||
def _transform_value(param_type: ParameterType, value: float) -> float:
|
||||
"""Convert mixer parameter value to Carla normalized (0.0–1.0) value.
|
||||
|
||||
Carla Calf plugins expect parameters in their native display ranges,
|
||||
sent via OSC as normalized floats. We convert from our parameter
|
||||
ranges to Carla-compatible 0.0–1.0 values.
|
||||
"""
|
||||
match param_type:
|
||||
# Gain/dB parameters → 0–1 linear
|
||||
case (ParameterType.GAIN | ParameterType.GATE_THRESHOLD |
|
||||
ParameterType.GATE_RANGE | ParameterType.COMP_THRESHOLD |
|
||||
ParameterType.COMP_GAIN | ParameterType.EQ_LOW_GAIN |
|
||||
ParameterType.EQ_MID_GAIN | ParameterType.EQ_HIGH_GAIN):
|
||||
# These are already in dB — normalize to 0–1
|
||||
return _normalize_param(value, param_type)
|
||||
|
||||
# Frequency → log scale 0–1
|
||||
case (ParameterType.EQ_LOW_FREQ | ParameterType.EQ_MID_FREQ |
|
||||
ParameterType.EQ_HIGH_FREQ):
|
||||
return freq_to_normalized(value)
|
||||
|
||||
# Q → linear 0–1 (0.1–6.0)
|
||||
case (ParameterType.EQ_LOW_Q | ParameterType.EQ_MID_Q | ParameterType.EQ_HIGH_Q):
|
||||
return (value - 0.1) / 5.9
|
||||
|
||||
# Ratio → log scale (1–20)
|
||||
case ParameterType.COMP_RATIO:
|
||||
import math
|
||||
return math.log(value) / math.log(20.0)
|
||||
|
||||
# Attack/Release ms → 0–1
|
||||
case (ParameterType.COMP_ATTACK | ParameterType.COMP_RELEASE):
|
||||
return time_ms_to_normalized(value)
|
||||
|
||||
# Volume, FX sends → dB to 0–1
|
||||
case (ParameterType.VOLUME | ParameterType.FX_SEND_A | ParameterType.FX_SEND_B):
|
||||
return _normalize_param(value, param_type)
|
||||
|
||||
case _:
|
||||
return value # pass-through
|
||||
|
||||
|
||||
def _normalize_param(value: float, param_type: ParameterType) -> float:
|
||||
"""Normalize a parameter to 0–1 given its type's range."""
|
||||
# Default ranges per ParameterType
|
||||
ranges: dict[ParameterType, tuple[float, float]] = {
|
||||
ParameterType.GAIN: (-20.0, 60.0),
|
||||
ParameterType.GATE_THRESHOLD: (-80.0, 0.0),
|
||||
ParameterType.GATE_RANGE: (-80.0, 0.0),
|
||||
ParameterType.COMP_THRESHOLD: (-60.0, 0.0),
|
||||
ParameterType.COMP_GAIN: (-20.0, 20.0),
|
||||
ParameterType.EQ_LOW_GAIN: (-15.0, 15.0),
|
||||
ParameterType.EQ_MID_GAIN: (-15.0, 15.0),
|
||||
ParameterType.EQ_HIGH_GAIN: (-15.0, 15.0),
|
||||
ParameterType.VOLUME: (-60.0, 12.0),
|
||||
ParameterType.FX_SEND_A: (-60.0, 12.0),
|
||||
ParameterType.FX_SEND_B: (-60.0, 12.0),
|
||||
}
|
||||
|
||||
lo, hi = ranges.get(param_type, (0.0, 1.0))
|
||||
if hi == lo:
|
||||
return 0.5
|
||||
return max(0.0, min(1.0, (value - lo) / (hi - lo)))
|
||||
|
||||
|
||||
def _get_osc_commands(
|
||||
plugins: dict[str, CarlaPluginInfo],
|
||||
channel: int,
|
||||
param_type: ParameterType,
|
||||
value: float,
|
||||
) -> list[tuple[int, int, float]]:
|
||||
"""Convert a parameter change to OSC commands.
|
||||
|
||||
Returns list of (plugin_id, param_index, normalized_value).
|
||||
"""
|
||||
role_and_param = _PARAM_TO_OSC.get(param_type)
|
||||
if not role_and_param:
|
||||
return []
|
||||
|
||||
role, param_name = role_and_param
|
||||
plugin = plugins.get(role)
|
||||
if not plugin:
|
||||
return []
|
||||
|
||||
param_index = plugin.param_map.get(param_name)
|
||||
if param_index is None:
|
||||
return []
|
||||
|
||||
normalized = _transform_value(param_type, value)
|
||||
return [(plugin.plugin_id, param_index, normalized)]
|
||||
|
||||
|
||||
def _state_to_osc_updates(
|
||||
plugins: dict[str, CarlaPluginInfo],
|
||||
state: ChannelState,
|
||||
) -> list[tuple[int, int, float]]:
|
||||
"""Convert all channel state to OSC updates."""
|
||||
updates = []
|
||||
for param_type, (role, param_name) in _PARAM_TO_OSC.items():
|
||||
plugin = plugins.get(role)
|
||||
if not plugin:
|
||||
continue
|
||||
param_index = plugin.param_map.get(param_name)
|
||||
if param_index is None:
|
||||
continue
|
||||
raw_value = _get_state_value(state, param_type)
|
||||
normalized = _transform_value(param_type, raw_value)
|
||||
updates.append((plugin.plugin_id, param_index, normalized))
|
||||
return updates
|
||||
|
||||
|
||||
def _get_state_value(state: ChannelState, pt: ParameterType) -> float:
|
||||
"""Read a parameter value from channel state."""
|
||||
mapping = {
|
||||
ParameterType.GATE_THRESHOLD: state.gate_threshold,
|
||||
ParameterType.GATE_RANGE: state.gate_range,
|
||||
ParameterType.EQ_LOW_FREQ: state.eq_low_freq,
|
||||
ParameterType.EQ_LOW_GAIN: state.eq_low_gain,
|
||||
ParameterType.EQ_LOW_Q: state.eq_low_q,
|
||||
ParameterType.EQ_MID_FREQ: state.eq_mid_freq,
|
||||
ParameterType.EQ_MID_GAIN: state.eq_mid_gain,
|
||||
ParameterType.EQ_MID_Q: state.eq_mid_q,
|
||||
ParameterType.EQ_HIGH_FREQ: state.eq_high_freq,
|
||||
ParameterType.EQ_HIGH_GAIN: state.eq_high_gain,
|
||||
ParameterType.EQ_HIGH_Q: state.eq_high_q,
|
||||
ParameterType.COMP_THRESHOLD: state.comp_threshold,
|
||||
ParameterType.COMP_RATIO: state.comp_ratio,
|
||||
ParameterType.COMP_ATTACK: state.comp_attack,
|
||||
ParameterType.COMP_RELEASE: state.comp_release,
|
||||
ParameterType.COMP_GAIN: state.comp_gain,
|
||||
ParameterType.GAIN: state.gain,
|
||||
ParameterType.VOLUME: state.volume,
|
||||
ParameterType.FX_SEND_A: state.fx_send_a,
|
||||
ParameterType.FX_SEND_B: state.fx_send_b,
|
||||
}
|
||||
return mapping.get(pt, 0.0)
|
||||
@@ -0,0 +1,575 @@
|
||||
"""DSP Engine — main mixer orchestrator.
|
||||
|
||||
The DSPEngine is the central controller that ties together:
|
||||
- Channel strips (parameter state + Carla OSC control)
|
||||
- Routing matrix (JACK port connections)
|
||||
- Bus manager (aux, subgroups, VCA, master)
|
||||
- Fader automation (recording + playback)
|
||||
- Parameter registry (MIDI → parameter dispatch)
|
||||
|
||||
It receives parameter changes from MIDI (via the ParameterRegistry
|
||||
callback) and dispatches them to the appropriate channel strip,
|
||||
bus, and routing components.
|
||||
|
||||
Architecture:
|
||||
MIDI Engine → ParameterRegistry → DSPEngine.handle_parameter()
|
||||
├── ChannelStrip.set_parameter() → Carla OSC
|
||||
├── BusManager (aux send/return levels)
|
||||
├── RoutingMatrix (mute/solo/gain)
|
||||
└── FaderAutomation.record()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from ..midi.types import (
|
||||
ParameterType,
|
||||
ParameterCategory,
|
||||
MixerParameter,
|
||||
)
|
||||
from .osc_client import CarlaOSCClient, DEFAULT_PLUGIN_LAYOUT
|
||||
from .channel_strip import ChannelStrip, ChannelState
|
||||
from .routing_matrix import RoutingMatrix, RouteNode, NodeType
|
||||
from .bus_manager import BusManager, MasterBus, AuxBus, SubgroupBus, VCAGroup
|
||||
from .fader_automation import FaderAutomation, Scene
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── DSP Engine ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DSPEngineConfig:
|
||||
"""Configuration for the DSP engine."""
|
||||
num_channels: int = 16
|
||||
num_aux: int = 4
|
||||
num_subgroups: int = 2
|
||||
num_vca: int = 2
|
||||
osc_host: str = "127.0.0.1"
|
||||
osc_port: int = 22752
|
||||
osc_enabled: bool = True
|
||||
jack_routing_enabled: bool = True
|
||||
automation_enabled: bool = True
|
||||
samplerate: int = 48000
|
||||
buffer_size: int = 256
|
||||
|
||||
|
||||
class DSPEngine:
|
||||
"""Main mixer DSP engine.
|
||||
|
||||
Central controller for all audio processing and routing.
|
||||
Designed to run on a Raspberry Pi 4B with:
|
||||
- Carla rack for plugin processing (EQ, comp, gate, FX)
|
||||
- JACK for audio routing
|
||||
- OSC for Carla parameter control
|
||||
- Python for orchestration and MIDI integration
|
||||
|
||||
Thread-safe for concurrent parameter updates from MIDI callbacks.
|
||||
"""
|
||||
|
||||
def __init__(self, config: DSPEngineConfig | None = None):
|
||||
self.config = config or DSPEngineConfig()
|
||||
|
||||
# Components
|
||||
self.osc: Optional[CarlaOSCClient] = None
|
||||
self.channels: list[ChannelStrip] = []
|
||||
self.routing = RoutingMatrix(name="dsp-engine")
|
||||
self.buses = BusManager(
|
||||
num_channels=self.config.num_channels,
|
||||
num_aux=self.config.num_aux,
|
||||
num_subgroups=self.config.num_subgroups,
|
||||
num_vca=self.config.num_vca,
|
||||
)
|
||||
self.automation = FaderAutomation()
|
||||
|
||||
# Lifecycle
|
||||
self._running = False
|
||||
self._lock = threading.Lock()
|
||||
self._start_time: float = 0.0
|
||||
self._param_count: int = 0
|
||||
|
||||
# Initialize
|
||||
self._init_channels()
|
||||
self._init_routing()
|
||||
self._init_callbacks()
|
||||
|
||||
logger.info("DSP engine initialized: %d channels, %d aux, %d subgroups, %d VCA",
|
||||
self.config.num_channels, self.config.num_aux,
|
||||
self.config.num_subgroups, self.config.num_vca)
|
||||
|
||||
def _init_channels(self) -> None:
|
||||
"""Create channel strips."""
|
||||
self.channels = [
|
||||
ChannelStrip(index=i) for i in range(self.config.num_channels)
|
||||
]
|
||||
|
||||
def _init_routing(self) -> None:
|
||||
"""Build default routing matrix."""
|
||||
self.routing.build_default_8ch_matrix()
|
||||
|
||||
def _init_callbacks(self) -> None:
|
||||
"""Set up internal callbacks."""
|
||||
pass # Callbacks are registered externally via handle_parameter()
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
def start(self, connect_osc: bool = True) -> None:
|
||||
"""Start the DSP engine.
|
||||
|
||||
Args:
|
||||
connect_osc: If True, establish OSC connection to Carla.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._start_time = time.monotonic()
|
||||
|
||||
if connect_osc and self.config.osc_enabled:
|
||||
self.osc = CarlaOSCClient(
|
||||
host=self.config.osc_host,
|
||||
port=self.config.osc_port,
|
||||
)
|
||||
self._bind_osc_to_channels()
|
||||
logger.info("OSC client connected to %s:%d",
|
||||
self.config.osc_host, self.config.osc_port)
|
||||
|
||||
# Apply initial routing
|
||||
if self.config.jack_routing_enabled:
|
||||
stats = self.routing.apply_full_matrix()
|
||||
logger.info("JACK routing applied: %s", stats)
|
||||
|
||||
logger.info("DSP engine started (sr=%d, buf=%d)",
|
||||
self.config.samplerate, self.config.buffer_size)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the DSP engine and clean up."""
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
# Disconnect JACK routing
|
||||
if self.config.jack_routing_enabled:
|
||||
self.routing.disconnect_all()
|
||||
logger.info("JACK connections disconnected")
|
||||
|
||||
# Close OSC
|
||||
if self.osc:
|
||||
self.osc.close()
|
||||
self.osc = None
|
||||
|
||||
uptime = time.monotonic() - self._start_time
|
||||
logger.info("DSP engine stopped (uptime: %.1fs, params: %d)",
|
||||
uptime, self._param_count)
|
||||
|
||||
def _bind_osc_to_channels(self) -> None:
|
||||
"""Bind OSC client to channel strips based on plugin layout."""
|
||||
if not self.osc:
|
||||
return
|
||||
|
||||
for plugin_info in DEFAULT_PLUGIN_LAYOUT:
|
||||
if plugin_info.channel >= 0 and plugin_info.channel < len(self.channels):
|
||||
ch = self.channels[plugin_info.channel]
|
||||
ch.bind_osc(self.osc)
|
||||
ch.register_plugin(plugin_info)
|
||||
|
||||
logger.info("OSC bound to %d channels", len(self.channels))
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
# ── Parameter handling ──────────────────────────────────────────────
|
||||
|
||||
def handle_parameter(
|
||||
self,
|
||||
param: MixerParameter,
|
||||
value: float,
|
||||
) -> None:
|
||||
"""Handle a parameter change from the ParameterRegistry.
|
||||
|
||||
This is the primary entry point for all parameter changes,
|
||||
whether from MIDI controllers, UI, or automation playback.
|
||||
|
||||
Args:
|
||||
param: The mixer parameter being changed.
|
||||
value: New value in the parameter's native range.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._param_count += 1
|
||||
|
||||
cat = param.category
|
||||
ch = param.channel
|
||||
pt = param.param_type
|
||||
|
||||
try:
|
||||
# Dispatch based on category
|
||||
if cat == ParameterCategory.CHANNEL and ch >= 0 and ch < len(self.channels):
|
||||
self._handle_channel_param(ch, pt, value)
|
||||
|
||||
elif cat == ParameterCategory.MASTER:
|
||||
self._handle_master_param(pt, value)
|
||||
|
||||
elif cat == ParameterCategory.FX:
|
||||
self._handle_fx_param(pt, value)
|
||||
|
||||
elif cat == ParameterCategory.ROUTING:
|
||||
self._handle_routing_param(pt, ch, value)
|
||||
|
||||
elif cat == ParameterCategory.TRANSPORT:
|
||||
self._handle_transport_param(pt, value)
|
||||
|
||||
elif cat == ParameterCategory.UTILITY:
|
||||
self._handle_utility_param(pt, value)
|
||||
|
||||
# Record automation
|
||||
if self.config.automation_enabled and self.automation.is_recording:
|
||||
key = _make_param_key(cat, ch, pt)
|
||||
self.automation.record(key, value)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error handling param %s CH%d = %0.2f: %s",
|
||||
pt.value, ch, value, exc)
|
||||
|
||||
def _handle_channel_param(self, ch: int, pt: ParameterType, value: float) -> None:
|
||||
"""Handle a channel-level parameter change."""
|
||||
strip = self.channels[ch]
|
||||
|
||||
# Handle volume/mute/solo through routing matrix
|
||||
match pt:
|
||||
case ParameterType.VOLUME:
|
||||
strip.set_parameter(pt, value)
|
||||
# Update routing gain for this channel → master
|
||||
self.routing.set_gain(
|
||||
f"ch_{ch}_to_master", "master_input",
|
||||
value, # dB
|
||||
)
|
||||
case ParameterType.MUTE:
|
||||
strip.set_parameter(pt, value)
|
||||
self.routing.set_mute(
|
||||
f"ch_{ch}_to_master", "master_input",
|
||||
value >= 0.5,
|
||||
)
|
||||
case ParameterType.SOLO:
|
||||
strip.set_parameter(pt, value)
|
||||
self.routing.set_solo(f"ch_{ch}_to_master", value >= 0.5)
|
||||
case ParameterType.FX_SEND_A:
|
||||
strip.set_parameter(pt, value)
|
||||
self.buses.set_aux_send(0, ch, value)
|
||||
case ParameterType.FX_SEND_B:
|
||||
strip.set_parameter(pt, value)
|
||||
self.buses.set_aux_send(1, ch, value)
|
||||
case _:
|
||||
# EQ, comp, gate — handled by channel strip (OSC)
|
||||
strip.set_parameter(pt, value)
|
||||
|
||||
def _handle_master_param(self, pt: ParameterType, value: float) -> None:
|
||||
"""Handle master bus parameter changes."""
|
||||
match pt:
|
||||
case ParameterType.MASTER_VOLUME:
|
||||
self.buses.set_master_volume(value)
|
||||
case ParameterType.MASTER_MUTE:
|
||||
self.buses.set_master_mute(value >= 0.5)
|
||||
case ParameterType.MASTER_DIM:
|
||||
self.buses.set_master_dim(value >= 0.5)
|
||||
case ParameterType.MONITOR_VOLUME:
|
||||
# Monitor volume — would control a separate monitor bus
|
||||
logger.debug("Monitor volume: %0.1f dB", value)
|
||||
case ParameterType.PHONES_VOLUME:
|
||||
# Headphone volume
|
||||
logger.debug("Phones volume: %0.1f dB", value)
|
||||
case _:
|
||||
logger.debug("Unhandled master param: %s = %0.2f", pt.value, value)
|
||||
|
||||
def _handle_fx_param(self, pt: ParameterType, value: float) -> None:
|
||||
"""Handle FX return parameter changes."""
|
||||
match pt:
|
||||
case ParameterType.FX_RETURN_A:
|
||||
self.buses.set_aux_return(0, value)
|
||||
case ParameterType.FX_RETURN_B:
|
||||
self.buses.set_aux_return(1, value)
|
||||
case _:
|
||||
logger.debug("Unhandled FX param: %s = %0.2f", pt.value, value)
|
||||
|
||||
def _handle_routing_param(self, pt: ParameterType, ch: int, value: float) -> None:
|
||||
"""Handle routing parameter changes."""
|
||||
# Routing parameters for source assignment, output mapping, etc.
|
||||
logger.debug("Routing param: %s CH%d = %0.2f", pt.value, ch, value)
|
||||
|
||||
def _handle_transport_param(self, pt: ParameterType, value: float) -> None:
|
||||
"""Handle transport parameter changes."""
|
||||
match pt:
|
||||
case ParameterType.PLAY:
|
||||
if value >= 0.5:
|
||||
self.automation.start_playback()
|
||||
case ParameterType.STOP:
|
||||
if value >= 0.5:
|
||||
self.automation.stop_playback()
|
||||
case ParameterType.RECORD:
|
||||
if value >= 0.5:
|
||||
self.automation.start_recording(keep_existing=True)
|
||||
else:
|
||||
self.automation.stop_recording()
|
||||
case ParameterType.LOOP:
|
||||
logger.debug("Loop toggle: %s", value >= 0.5)
|
||||
case ParameterType.TEMPO:
|
||||
logger.debug("Tempo: %0.1f BPM", value)
|
||||
case _:
|
||||
logger.debug("Transport param: %s = %0.2f", pt.value, value)
|
||||
|
||||
def _handle_utility_param(self, pt: ParameterType, value: float) -> None:
|
||||
"""Handle utility parameter changes."""
|
||||
match pt:
|
||||
case ParameterType.SNAPSHOT_LOAD:
|
||||
scene_idx = int(value)
|
||||
scenes = self.automation.list_scenes()
|
||||
if 0 <= scene_idx < len(scenes):
|
||||
self.load_snapshot(scenes[scene_idx])
|
||||
case ParameterType.SNAPSHOT_SAVE:
|
||||
scene_idx = int(value)
|
||||
self.save_snapshot(f"Scene {scene_idx}")
|
||||
case ParameterType.SCENE_NEXT:
|
||||
self.next_scene()
|
||||
case ParameterType.SCENE_PREV:
|
||||
self.prev_scene()
|
||||
case _:
|
||||
logger.debug("Utility param: %s = %0.2f", pt.value, value)
|
||||
|
||||
# ── Channel access ──────────────────────────────────────────────────
|
||||
|
||||
def get_channel(self, index: int) -> ChannelStrip | None:
|
||||
if 0 <= index < len(self.channels):
|
||||
return self.channels[index]
|
||||
return None
|
||||
|
||||
def get_channel_state(self, index: int) -> ChannelState | None:
|
||||
ch = self.get_channel(index)
|
||||
return ch.state if ch else None
|
||||
|
||||
# ── Snapshot / Scene management ─────────────────────────────────────
|
||||
|
||||
def save_snapshot(self, name: str) -> Scene:
|
||||
"""Save current mixer state as a named snapshot."""
|
||||
channel_states = {
|
||||
i: dict(ch.state.__dict__) # copy to avoid live-mutation
|
||||
for i, ch in enumerate(self.channels)
|
||||
}
|
||||
bus_state = self.buses.to_dict()
|
||||
routing_state = self.routing.to_dict()
|
||||
|
||||
return self.automation.save_scene(
|
||||
name, channel_states, bus_state, routing_state
|
||||
)
|
||||
|
||||
def load_snapshot(self, name: str) -> bool:
|
||||
"""Load a named snapshot, restoring all mixer state."""
|
||||
scene = self.automation.recall_scene(name)
|
||||
if not scene:
|
||||
logger.warning("Scene not found: %s", name)
|
||||
return False
|
||||
|
||||
# Restore channel states
|
||||
for ch_idx, state_dict in scene.channel_states.items():
|
||||
if 0 <= ch_idx < len(self.channels):
|
||||
cs = ChannelState(**state_dict)
|
||||
self.channels[ch_idx].restore(cs, send_osc=True)
|
||||
|
||||
# Restore bus state
|
||||
self.buses.from_dict(scene.bus_state)
|
||||
|
||||
# Restore routing
|
||||
if scene.routing_state:
|
||||
self.routing.from_dict(scene.routing_state)
|
||||
if self.config.jack_routing_enabled:
|
||||
self.routing.apply_full_matrix()
|
||||
|
||||
logger.info("Snapshot loaded: %s", name)
|
||||
return True
|
||||
|
||||
def crossfade_to(self, scene_name: str, duration: float = 2.0) -> bool:
|
||||
"""Crossfade to a saved scene over a duration."""
|
||||
return self.automation.crossfade_to(scene_name, duration)
|
||||
|
||||
def next_scene(self) -> bool:
|
||||
"""Recall the next scene alphabetically."""
|
||||
scenes = self.automation.list_scenes()
|
||||
if not scenes:
|
||||
return False
|
||||
if self.automation._current_scene is None:
|
||||
return self.load_snapshot(scenes[0])
|
||||
try:
|
||||
idx = scenes.index(self.automation._current_scene)
|
||||
next_idx = (idx + 1) % len(scenes)
|
||||
return self.load_snapshot(scenes[next_idx])
|
||||
except ValueError:
|
||||
return self.load_snapshot(scenes[0])
|
||||
|
||||
def prev_scene(self) -> bool:
|
||||
"""Recall the previous scene alphabetically."""
|
||||
scenes = self.automation.list_scenes()
|
||||
if not scenes:
|
||||
return False
|
||||
if self.automation._current_scene is None:
|
||||
return self.load_snapshot(scenes[-1])
|
||||
try:
|
||||
idx = scenes.index(self.automation._current_scene)
|
||||
prev_idx = (idx - 1) % len(scenes)
|
||||
return self.load_snapshot(scenes[prev_idx])
|
||||
except ValueError:
|
||||
return self.load_snapshot(scenes[0])
|
||||
|
||||
# ── Automation tick ─────────────────────────────────────────────────
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Process one automation engine tick.
|
||||
|
||||
Call this periodically (e.g., every 10-50ms from a timer or
|
||||
the JACK process callback) to advance automation playback
|
||||
and crossfades.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Update crossfade
|
||||
if self.automation._crossfade_active:
|
||||
self.automation.update_crossfade()
|
||||
|
||||
# If automation is playing, read values and apply them
|
||||
if self.automation.is_playing:
|
||||
self._apply_automation()
|
||||
|
||||
def _apply_automation(self) -> None:
|
||||
"""Apply automation values to parameters."""
|
||||
# Iterate over lanes and apply values
|
||||
for key, lane in self.automation._lanes.items():
|
||||
if not lane.points:
|
||||
continue
|
||||
value = self.automation.get_value(key)
|
||||
cat, ch, pt = _parse_param_key(key)
|
||||
if cat is None or pt is None:
|
||||
continue
|
||||
|
||||
# Build a MixerParameter and handle it
|
||||
param = MixerParameter(
|
||||
param_type=pt,
|
||||
category=cat,
|
||||
channel=ch,
|
||||
)
|
||||
self.handle_parameter(param, value)
|
||||
|
||||
# ── Stats ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
uptime = time.monotonic() - self._start_time if self._start_time else 0
|
||||
return {
|
||||
"running": self._running,
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"param_count": self._param_count,
|
||||
"num_channels": len(self.channels),
|
||||
"num_aux": self.config.num_aux,
|
||||
"osc_connected": self.osc is not None,
|
||||
"osc_stats": self.osc.stats if self.osc else {},
|
||||
"automation_active": self.automation.is_playing,
|
||||
"automation_recording": self.automation.is_recording,
|
||||
"scenes": self.automation.list_scenes(),
|
||||
"samplerate": self.config.samplerate,
|
||||
"buffer_size": self.config.buffer_size,
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize full engine state."""
|
||||
return {
|
||||
"channels": {i: ch.state.__dict__ for i, ch in enumerate(self.channels)},
|
||||
"buses": self.buses.to_dict(),
|
||||
"routing": self.routing.to_dict(),
|
||||
"automation": self.automation.to_dict(),
|
||||
"config": {
|
||||
"num_channels": self.config.num_channels,
|
||||
"num_aux": self.config.num_aux,
|
||||
"num_subgroups": self.config.num_subgroups,
|
||||
"num_vca": self.config.num_vca,
|
||||
"samplerate": self.config.samplerate,
|
||||
"buffer_size": self.config.buffer_size,
|
||||
},
|
||||
}
|
||||
|
||||
def from_dict(self, data: dict) -> None:
|
||||
"""Restore engine state from a dict."""
|
||||
# Restore channels
|
||||
for ch_idx, state_dict in data.get("channels", {}).items():
|
||||
ch_idx = int(ch_idx)
|
||||
if 0 <= ch_idx < len(self.channels):
|
||||
self.channels[ch_idx].restore(
|
||||
ChannelState(**state_dict),
|
||||
send_osc=False,
|
||||
)
|
||||
|
||||
# Restore buses
|
||||
self.buses.from_dict(data.get("buses", {}))
|
||||
|
||||
# Restore routing
|
||||
if "routing" in data:
|
||||
self.routing.from_dict(data["routing"])
|
||||
|
||||
# Restore automation
|
||||
if "automation" in data:
|
||||
self.automation.from_dict(data["automation"])
|
||||
|
||||
logger.info("Engine state restored")
|
||||
|
||||
|
||||
# ── Parameter key helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_param_key(cat: ParameterCategory, ch: int, pt: ParameterType) -> str:
|
||||
"""Create a unique key for a parameter in automation."""
|
||||
ch_str = str(ch) if ch >= 0 else "master"
|
||||
return f"{cat.value}_{ch_str}_{pt.value}"
|
||||
|
||||
|
||||
def _parse_param_key(key: str) -> tuple[ParameterCategory | None, int, ParameterType | None]:
|
||||
"""Parse an automation parameter key back into components."""
|
||||
try:
|
||||
parts = key.split("_", 2)
|
||||
cat_str = parts[0]
|
||||
ch_str = parts[1]
|
||||
pt_str = parts[2]
|
||||
|
||||
cat = ParameterCategory(cat_str)
|
||||
ch = int(ch_str) if ch_str != "master" else -1
|
||||
pt = ParameterType(pt_str)
|
||||
return cat, ch, pt
|
||||
except (ValueError, IndexError):
|
||||
return None, -1, None
|
||||
|
||||
|
||||
# ── Factory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_default_engine(
|
||||
num_channels: int = 16,
|
||||
osc_enabled: bool = True,
|
||||
) -> DSPEngine:
|
||||
"""Create a DSP engine with sensible defaults for RPi4B."""
|
||||
config = DSPEngineConfig(
|
||||
num_channels=num_channels,
|
||||
num_aux=4,
|
||||
num_subgroups=2,
|
||||
num_vca=2,
|
||||
osc_enabled=osc_enabled,
|
||||
jack_routing_enabled=True,
|
||||
automation_enabled=True,
|
||||
samplerate=48000,
|
||||
buffer_size=256,
|
||||
)
|
||||
return DSPEngine(config)
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Fader automation engine.
|
||||
|
||||
Records and plays back parameter automation over time. Supports:
|
||||
- Recording: capture parameter changes with timestamps
|
||||
- Playback: interpolate between recorded points
|
||||
- Interpolation: linear, logarithmic, S-curve
|
||||
- Scenes/snapshots: store and recall full mixer state
|
||||
- Crossfade: smooth transition between scenes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class InterpolationMode(StrEnum):
|
||||
LINEAR = "linear"
|
||||
LOGARITHMIC = "logarithmic"
|
||||
S_CURVE = "s_curve" # smoothstep
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutomationPoint:
|
||||
"""A single automation data point."""
|
||||
time_sec: float # time in seconds
|
||||
value: float # parameter value
|
||||
mode: InterpolationMode = InterpolationMode.LINEAR
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutomationLane:
|
||||
"""An automation lane for one parameter on one channel/bus.
|
||||
|
||||
Each lane is a sorted list of automation points. Playback
|
||||
interpolates between points based on current time.
|
||||
"""
|
||||
param_key: str # e.g., "ch_0_volume", "master_volume"
|
||||
points: list[AutomationPoint] = field(default_factory=list)
|
||||
is_recording: bool = False
|
||||
record_start_time: float = 0.0
|
||||
|
||||
def add_point(self, time_sec: float, value: float,
|
||||
mode: InterpolationMode = InterpolationMode.LINEAR) -> None:
|
||||
"""Add an automation point, maintaining time-sorted order."""
|
||||
point = AutomationPoint(time_sec=time_sec, value=value, mode=mode)
|
||||
idx = bisect.bisect_left([p.time_sec for p in self.points], time_sec)
|
||||
self.points.insert(idx, point)
|
||||
|
||||
def get_value_at(self, time_sec: float, default: float = 0.0) -> float:
|
||||
"""Get the interpolated value at a given time."""
|
||||
if not self.points:
|
||||
return default
|
||||
|
||||
if time_sec <= self.points[0].time_sec:
|
||||
return self.points[0].value
|
||||
|
||||
if time_sec >= self.points[-1].time_sec:
|
||||
return self.points[-1].value
|
||||
|
||||
# Find surrounding points
|
||||
idx = bisect.bisect_right([p.time_sec for p in self.points], time_sec)
|
||||
if idx <= 0:
|
||||
return self.points[0].value
|
||||
if idx >= len(self.points):
|
||||
return self.points[-1].value
|
||||
|
||||
before = self.points[idx - 1]
|
||||
after = self.points[idx]
|
||||
|
||||
# Calculate interpolation factor
|
||||
duration = after.time_sec - before.time_sec
|
||||
if duration <= 0:
|
||||
return after.value
|
||||
t = (time_sec - before.time_sec) / duration
|
||||
|
||||
return _interpolate(t, before.value, after.value, after.mode)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.points.clear()
|
||||
|
||||
def clone(self) -> AutomationLane:
|
||||
return AutomationLane(
|
||||
param_key=self.param_key,
|
||||
points=[AutomationPoint(
|
||||
time_sec=p.time_sec, value=p.value, mode=p.mode
|
||||
) for p in self.points],
|
||||
)
|
||||
|
||||
|
||||
def _interpolate(t: float, v0: float, v1: float,
|
||||
mode: InterpolationMode) -> float:
|
||||
"""Interpolate between two values.
|
||||
|
||||
Args:
|
||||
t: 0.0–1.0 interpolation factor.
|
||||
v0: start value.
|
||||
v1: end value.
|
||||
mode: interpolation curve.
|
||||
|
||||
Returns:
|
||||
Interpolated value.
|
||||
"""
|
||||
t = max(0.0, min(1.0, t))
|
||||
|
||||
if mode == InterpolationMode.LINEAR:
|
||||
return v0 + t * (v1 - v0)
|
||||
|
||||
elif mode == InterpolationMode.LOGARITHMIC:
|
||||
# Log mapping for dB-like parameters
|
||||
if v0 <= 0 or v1 <= 0:
|
||||
return v0 + t * (v1 - v0) # fallback to linear
|
||||
import math
|
||||
log_v0 = math.log(v0)
|
||||
log_v1 = math.log(v1)
|
||||
return math.exp(log_v0 + t * (log_v1 - log_v0))
|
||||
|
||||
elif mode == InterpolationMode.S_CURVE:
|
||||
# Smoothstep (Hermite interpolation)
|
||||
t_smooth = t * t * (3.0 - 2.0 * t)
|
||||
return v0 + t_smooth * (v1 - v0)
|
||||
|
||||
return v0 + t * (v1 - v0)
|
||||
|
||||
|
||||
# ── Scene / Snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scene:
|
||||
"""A saved mixer scene (snapshot of all parameters)."""
|
||||
name: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
# Serialized state dicts
|
||||
channel_states: dict[int, dict] = field(default_factory=dict) # ch_idx → state dict
|
||||
bus_state: dict = field(default_factory=dict)
|
||||
routing_state: dict = field(default_factory=dict)
|
||||
automation_lanes: dict[str, list[dict]] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"created_at": self.created_at,
|
||||
"channel_states": {str(k): v for k, v in self.channel_states.items()},
|
||||
"bus_state": self.bus_state,
|
||||
"routing_state": self.routing_state,
|
||||
"automation_lanes": self.automation_lanes,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> Scene:
|
||||
return cls(
|
||||
name=data["name"],
|
||||
created_at=data.get("created_at", time.time()),
|
||||
channel_states={int(k): v for k, v in data.get("channel_states", {}).items()},
|
||||
bus_state=data.get("bus_state", {}),
|
||||
routing_state=data.get("routing_state", {}),
|
||||
automation_lanes=data.get("automation_lanes", {}),
|
||||
)
|
||||
|
||||
|
||||
# ── Fader Automation Engine ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FaderAutomation:
|
||||
"""Records and plays back fader automation.
|
||||
|
||||
Manages multiple automation lanes (one per parameter per channel),
|
||||
supports real-time recording, time-synced playback, and scene
|
||||
management (save/recall/crossfade).
|
||||
|
||||
Usage:
|
||||
auto = FaderAutomation()
|
||||
auto.start_recording()
|
||||
# ... parameter changes happen ...
|
||||
auto.record("ch_0_volume", -3.5)
|
||||
auto.stop_recording()
|
||||
|
||||
# Playback
|
||||
auto.start_playback()
|
||||
value = auto.get_value("ch_0_volume", current_time)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._lanes: dict[str, AutomationLane] = {}
|
||||
self._playback_active: bool = False
|
||||
self._playback_start_time: float = 0.0
|
||||
self._recording_active: bool = False
|
||||
self._record_start_time: float = 0.0
|
||||
self._scenes: dict[str, Scene] = {}
|
||||
self._current_scene: str | None = None
|
||||
self._crossfade_active: bool = False
|
||||
self._crossfade_start_time: float = 0.0
|
||||
self._crossfade_duration: float = 0.0
|
||||
self._crossfade_from: Scene | None = None
|
||||
self._crossfade_to: Scene | None = None
|
||||
|
||||
# ── Lane management ─────────────────────────────────────────────────
|
||||
|
||||
def _get_lane(self, param_key: str) -> AutomationLane:
|
||||
if param_key not in self._lanes:
|
||||
self._lanes[param_key] = AutomationLane(param_key=param_key)
|
||||
return self._lanes[param_key]
|
||||
|
||||
def record(self, param_key: str, value: float,
|
||||
mode: InterpolationMode = InterpolationMode.LINEAR) -> None:
|
||||
"""Record a parameter value at the current time."""
|
||||
if not self._recording_active:
|
||||
return
|
||||
|
||||
lane = self._get_lane(param_key)
|
||||
elapsed = time.monotonic() - self._record_start_time
|
||||
lane.add_point(elapsed, value, mode)
|
||||
|
||||
def get_value(self, param_key: str, default: float = 0.0) -> float:
|
||||
"""Get the current automated value for a parameter."""
|
||||
# Crossfade takes priority
|
||||
if self._crossfade_active:
|
||||
return self._get_crossfade_value(param_key, default)
|
||||
|
||||
# Playback
|
||||
if self._playback_active and param_key in self._lanes:
|
||||
elapsed = time.monotonic() - self._playback_start_time
|
||||
return self._lanes[param_key].get_value_at(elapsed, default)
|
||||
|
||||
return default
|
||||
|
||||
# ── Recording ───────────────────────────────────────────────────────
|
||||
|
||||
def start_recording(self, keep_existing: bool = False) -> None:
|
||||
"""Start recording automation.
|
||||
|
||||
Args:
|
||||
keep_existing: If False, clear existing lanes before recording.
|
||||
"""
|
||||
if not keep_existing:
|
||||
self._lanes.clear()
|
||||
self._recording_active = True
|
||||
self._record_start_time = time.monotonic()
|
||||
logger.info("Automation recording started")
|
||||
|
||||
def stop_recording(self) -> None:
|
||||
self._recording_active = False
|
||||
logger.info("Automation recording stopped")
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
return self._recording_active
|
||||
|
||||
# ── Playback ────────────────────────────────────────────────────────
|
||||
|
||||
def start_playback(self, start_time: float | None = None,
|
||||
loop: bool = False) -> None:
|
||||
"""Start playback of recorded automation.
|
||||
|
||||
Args:
|
||||
start_time: Offset into the automation timeline (seconds).
|
||||
loop: If True, loop playback.
|
||||
"""
|
||||
self._playback_active = True
|
||||
self._playback_start_time = time.monotonic() - (start_time or 0)
|
||||
self._loop = loop
|
||||
logger.info("Automation playback started")
|
||||
|
||||
def stop_playback(self) -> None:
|
||||
self._playback_active = False
|
||||
|
||||
def seek(self, time_sec: float) -> None:
|
||||
"""Seek to a specific time in the automation timeline."""
|
||||
self._playback_start_time = time.monotonic() - time_sec
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool:
|
||||
return self._playback_active
|
||||
|
||||
@property
|
||||
def current_time(self) -> float:
|
||||
"""Get current playback time."""
|
||||
if self._playback_active:
|
||||
elapsed = time.monotonic() - self._playback_start_time
|
||||
|
||||
# Handle looping
|
||||
if self._loop:
|
||||
max_time = self.get_duration()
|
||||
if max_time > 0:
|
||||
elapsed = elapsed % max_time
|
||||
|
||||
return elapsed
|
||||
return 0.0
|
||||
|
||||
def get_duration(self) -> float:
|
||||
"""Get the total duration of recorded automation (longest lane)."""
|
||||
max_time = 0.0
|
||||
for lane in self._lanes.values():
|
||||
if lane.points:
|
||||
max_time = max(max_time, lane.points[-1].time_sec)
|
||||
return max_time
|
||||
|
||||
# ── Scene management ────────────────────────────────────────────────
|
||||
|
||||
def save_scene(self, name: str, channel_states: dict[int, dict],
|
||||
bus_state: dict, routing_state: dict) -> Scene:
|
||||
"""Save current mixer state as a scene."""
|
||||
# Clone current automation lanes
|
||||
lanes_data = {}
|
||||
for key, lane in self._lanes.items():
|
||||
lanes_data[key] = [
|
||||
{"time_sec": p.time_sec, "value": p.value, "mode": p.mode.value}
|
||||
for p in lane.points
|
||||
]
|
||||
|
||||
scene = Scene(
|
||||
name=name,
|
||||
channel_states=channel_states,
|
||||
bus_state=bus_state,
|
||||
routing_state=routing_state,
|
||||
automation_lanes=lanes_data,
|
||||
)
|
||||
self._scenes[name] = scene
|
||||
logger.info("Scene saved: %s", name)
|
||||
return scene
|
||||
|
||||
def load_scene(self, name: str) -> Scene | None:
|
||||
"""Load a scene by name."""
|
||||
return self._scenes.get(name)
|
||||
|
||||
def delete_scene(self, name: str) -> bool:
|
||||
if name in self._scenes:
|
||||
del self._scenes[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_scenes(self) -> list[str]:
|
||||
return sorted(self._scenes.keys())
|
||||
|
||||
def recall_scene(self, name: str) -> Scene | None:
|
||||
"""Recall a scene (restore automation lanes)."""
|
||||
scene = self._scenes.get(name)
|
||||
if not scene:
|
||||
return None
|
||||
|
||||
# Restore automation lanes from scene
|
||||
self._lanes.clear()
|
||||
for key, points_data in scene.automation_lanes.items():
|
||||
lane = AutomationLane(param_key=key)
|
||||
for pd in points_data:
|
||||
lane.add_point(
|
||||
pd["time_sec"],
|
||||
pd["value"],
|
||||
InterpolationMode(pd.get("mode", "linear")),
|
||||
)
|
||||
self._lanes[key] = lane
|
||||
|
||||
self._current_scene = name
|
||||
logger.info("Scene recalled: %s", name)
|
||||
return scene
|
||||
|
||||
def crossfade_to(self, scene_name: str, duration_sec: float) -> bool:
|
||||
"""Crossfade from current state to a saved scene.
|
||||
|
||||
Args:
|
||||
scene_name: Target scene name.
|
||||
duration_sec: Crossfade duration in seconds.
|
||||
|
||||
Returns:
|
||||
True if crossfade started, False if scene not found.
|
||||
"""
|
||||
target = self._scenes.get(scene_name)
|
||||
if not target:
|
||||
return False
|
||||
|
||||
# Capture current state as 'from' scene
|
||||
current_lanes = {}
|
||||
for key, lane in self._lanes.items():
|
||||
current_lanes[key] = [{
|
||||
"time_sec": p.time_sec,
|
||||
"value": p.value,
|
||||
"mode": p.mode.value,
|
||||
} for p in lane.points]
|
||||
|
||||
self._crossfade_from = Scene(
|
||||
name="__crossfade_from__",
|
||||
automation_lanes=current_lanes,
|
||||
)
|
||||
self._crossfade_to = target
|
||||
self._crossfade_duration = duration_sec
|
||||
self._crossfade_start_time = time.monotonic()
|
||||
self._crossfade_active = True
|
||||
logger.info("Crossfade started to '%s' (%0.1fs)", scene_name, duration_sec)
|
||||
return True
|
||||
|
||||
def _get_crossfade_value(self, param_key: str, default: float) -> float:
|
||||
"""Get interpolated value during an active crossfade."""
|
||||
elapsed = time.monotonic() - self._crossfade_start_time
|
||||
t = min(1.0, elapsed / self._crossfade_duration) if self._crossfade_duration > 0 else 1.0
|
||||
|
||||
# Get from value
|
||||
from_value = default
|
||||
if self._crossfade_from:
|
||||
from_points = self._crossfade_from.automation_lanes.get(param_key, [])
|
||||
if from_points:
|
||||
from_value = from_points[-1]["value"]
|
||||
|
||||
# Get to value
|
||||
to_value = default
|
||||
if self._crossfade_to:
|
||||
to_points = self._crossfade_to.automation_lanes.get(param_key, [])
|
||||
if to_points:
|
||||
to_value = to_points[-1]["value"]
|
||||
|
||||
# S-curve crossfade for smooth transition
|
||||
return _interpolate(t, from_value, to_value, InterpolationMode.S_CURVE)
|
||||
|
||||
def update_crossfade(self) -> bool:
|
||||
"""Check if crossfade is complete. Returns True while crossfade is active."""
|
||||
if not self._crossfade_active:
|
||||
return False
|
||||
|
||||
elapsed = time.monotonic() - self._crossfade_start_time
|
||||
if elapsed >= self._crossfade_duration:
|
||||
self._crossfade_active = False
|
||||
if self._crossfade_to:
|
||||
self.recall_scene(self._crossfade_to.name)
|
||||
self._current_scene = self._crossfade_to.name
|
||||
self._crossfade_from = None
|
||||
self._crossfade_to = None
|
||||
logger.info("Crossfade complete")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def cancel_crossfade(self) -> None:
|
||||
self._crossfade_active = False
|
||||
self._crossfade_from = None
|
||||
self._crossfade_to = None
|
||||
|
||||
# ── Serialization ───────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
lanes_data = {}
|
||||
for key, lane in self._lanes.items():
|
||||
lanes_data[key] = [
|
||||
{"time_sec": p.time_sec, "value": p.value, "mode": p.mode.value}
|
||||
for p in lane.points
|
||||
]
|
||||
|
||||
scenes_data = {name: s.to_dict() for name, s in self._scenes.items()}
|
||||
|
||||
return {
|
||||
"lanes": lanes_data,
|
||||
"scenes": scenes_data,
|
||||
"current_scene": self._current_scene,
|
||||
}
|
||||
|
||||
def from_dict(self, data: dict) -> None:
|
||||
self._lanes.clear()
|
||||
for key, points_data in data.get("lanes", {}).items():
|
||||
lane = AutomationLane(param_key=key)
|
||||
for pd in points_data:
|
||||
lane.add_point(
|
||||
pd["time_sec"],
|
||||
pd["value"],
|
||||
InterpolationMode(pd.get("mode", "linear")),
|
||||
)
|
||||
self._lanes[key] = lane
|
||||
|
||||
self._scenes.clear()
|
||||
for name, sd in data.get("scenes", {}).items():
|
||||
self._scenes[name] = Scene.from_dict(sd)
|
||||
|
||||
self._current_scene = data.get("current_scene")
|
||||
@@ -0,0 +1,356 @@
|
||||
"""OSC client for Carla Rack control.
|
||||
|
||||
Carla exposes OSC on port 22752 by default. This module provides a
|
||||
lightweight OSC encoder/decoder and a client for setting plugin parameters,
|
||||
loading programs, and querying state.
|
||||
|
||||
OSC 1.0 packet format (subset we need):
|
||||
- Messages: /path\0\0\0 ,typetag\0\0\0 , args...
|
||||
- Type tags: f=float32, i=int32, s=string, T=True, F=False
|
||||
- All aligned to 4-byte boundaries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── OSC protocol helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pad_4(s: bytes) -> bytes:
|
||||
"""Pad to 4-byte boundary with null bytes."""
|
||||
rem = len(s) % 4
|
||||
return s + b"\x00" * (4 - rem) if rem else s
|
||||
|
||||
|
||||
def _osc_string(s: str) -> bytes:
|
||||
"""Encode an OSC string (null-terminated, 4-byte aligned)."""
|
||||
return _pad_4(s.encode("utf-8") + b"\x00")
|
||||
|
||||
|
||||
def _osc_float(v: float) -> bytes:
|
||||
"""Encode a 32-bit big-endian float."""
|
||||
return struct.pack(">f", v)
|
||||
|
||||
|
||||
def _osc_int(v: int) -> bytes:
|
||||
"""Encode a 32-bit big-endian int."""
|
||||
return struct.pack(">i", v)
|
||||
|
||||
|
||||
def encode_osc(address: str, *args) -> bytes:
|
||||
"""Encode an OSC message.
|
||||
|
||||
Args:
|
||||
address: OSC address pattern (e.g., '/Carla/1/set_parameter_value')
|
||||
*args: Values — floats, ints, or strings.
|
||||
|
||||
Returns:
|
||||
Raw OSC packet bytes.
|
||||
"""
|
||||
# Build type tag
|
||||
type_tag = ","
|
||||
for arg in args:
|
||||
if isinstance(arg, float):
|
||||
type_tag += "f"
|
||||
elif isinstance(arg, int):
|
||||
type_tag += "i"
|
||||
elif isinstance(arg, str):
|
||||
type_tag += "s"
|
||||
elif isinstance(arg, bool):
|
||||
type_tag += "T" if arg else "F"
|
||||
else:
|
||||
type_tag += "s"
|
||||
args = list(args)
|
||||
idx = args.index(arg)
|
||||
args[idx] = str(arg)
|
||||
|
||||
packet = _osc_string(address) + _osc_string(type_tag)
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, float):
|
||||
packet += _osc_float(arg)
|
||||
elif isinstance(arg, int):
|
||||
packet += _osc_int(arg)
|
||||
elif isinstance(arg, str):
|
||||
packet += _osc_string(arg)
|
||||
# bool types (T/F) have no data bytes
|
||||
|
||||
return packet
|
||||
|
||||
|
||||
def decode_osc(data: bytes) -> tuple[str, list[Any]]:
|
||||
"""Decode an OSC message.
|
||||
|
||||
Returns:
|
||||
(address, [args...])
|
||||
"""
|
||||
# Read address
|
||||
null_pos = data.find(b"\x00")
|
||||
if null_pos < 0:
|
||||
raise ValueError("Invalid OSC message: no null terminator")
|
||||
address = data[:null_pos].decode("utf-8")
|
||||
pos = (null_pos + 4) & ~3 # align to 4
|
||||
|
||||
# Read type tag
|
||||
type_start = data.find(b",", pos)
|
||||
if type_start < 0:
|
||||
return address, []
|
||||
type_end = data.find(b"\x00", type_start)
|
||||
type_tag = data[type_start + 1 : type_end].decode("utf-8")
|
||||
pos = (type_end + 4) & ~3
|
||||
|
||||
args = []
|
||||
for tag in type_tag:
|
||||
if tag == "f":
|
||||
args.append(struct.unpack(">f", data[pos : pos + 4])[0])
|
||||
pos += 4
|
||||
elif tag == "i":
|
||||
args.append(struct.unpack(">i", data[pos : pos + 4])[0])
|
||||
pos += 4
|
||||
elif tag == "s":
|
||||
null_end = data.find(b"\x00", pos)
|
||||
s = data[pos:null_end].decode("utf-8")
|
||||
args.append(s)
|
||||
pos = (null_end + 4) & ~3
|
||||
elif tag in ("T", "F"):
|
||||
args.append(tag == "T")
|
||||
# Skip unknown types (no data)
|
||||
|
||||
return address, args
|
||||
|
||||
|
||||
# ── Carla OSC API constants ──────────────────────────────────────────────────
|
||||
|
||||
# Carla OSC paths (port 22752)
|
||||
CARLA_SET_PARAM = "/Carla/1/set_parameter_value"
|
||||
CARLA_SET_PROGRAM = "/Carla/1/set_program"
|
||||
CARLA_SET_MIDI_PROGRAM = "/Carla/1/set_midi_program"
|
||||
CARLA_NOTE_ON = "/Carla/1/note_on"
|
||||
CARLA_NOTE_OFF = "/Carla/1/note_off"
|
||||
CARLA_ACTIVATE = "/Carla/1/activate"
|
||||
CARLA_DEACTIVATE = "/Carla/1/deactivate"
|
||||
CARLA_SET_VOLUME = "/Carla/1/set_volume" # per-plugin volume
|
||||
|
||||
|
||||
# ── Plugin ID registry ───────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CarlaPluginInfo:
|
||||
"""Maps a Carla plugin ID to its role in the mixer."""
|
||||
plugin_id: int
|
||||
name: str
|
||||
role: str # 'gate', 'eq', 'comp', 'gain', 'nam', 'ir', 'reverb', 'delay', 'limiter'
|
||||
channel: int = -1 # -1 for master/global plugins
|
||||
param_map: dict[str, int] = field(default_factory=dict) # param_name → param_index
|
||||
|
||||
|
||||
# Default 8-ch mixer plugin layout (matches carla-8ch-default.carxp)
|
||||
# Plugin IDs are assigned by Carla in order they appear in the rack.
|
||||
# This registry is loaded from config / carxp parsing at runtime.
|
||||
DEFAULT_PLUGIN_LAYOUT: list[CarlaPluginInfo] = [
|
||||
# Channel 1: Gate → EQ → Comp
|
||||
CarlaPluginInfo(1, "Ch1 Gate", "gate", 0, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "range": 4, "makeup": 5}),
|
||||
CarlaPluginInfo(2, "Ch1 EQ", "eq", 0, {"low_freq": 0, "low_gain": 1, "low_q": 2, "mid_freq": 3, "mid_gain": 4, "mid_q": 5, "high_freq": 6, "high_gain": 7, "high_q": 8}),
|
||||
CarlaPluginInfo(3, "Ch1 Comp", "comp", 0, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "makeup": 4, "knee": 5}),
|
||||
# Channel 2: Gate → EQ → Comp
|
||||
CarlaPluginInfo(4, "Ch2 Gate", "gate", 1, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "range": 4, "makeup": 5}),
|
||||
CarlaPluginInfo(5, "Ch2 EQ", "eq", 1, {"low_freq": 0, "low_gain": 1, "low_q": 2, "mid_freq": 3, "mid_gain": 4, "mid_q": 5, "high_freq": 6, "high_gain": 7, "high_q": 8}),
|
||||
CarlaPluginInfo(6, "Ch2 Comp", "comp", 1, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "makeup": 4, "knee": 5}),
|
||||
# Channel 3: NAM → IR (guitar)
|
||||
CarlaPluginInfo(7, "Ch3 NAM", "nam", 2, {"model": 0, "input_gain": 1, "output_gain": 2}),
|
||||
CarlaPluginInfo(8, "Ch3 IR", "ir", 2, {"ir_file": 0, "wet_dry": 1}),
|
||||
# Channel 4: Gate → EQ → Comp
|
||||
CarlaPluginInfo(9, "Ch4 Gate", "gate", 3, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "range": 4, "makeup": 5}),
|
||||
CarlaPluginInfo(10, "Ch4 EQ", "eq", 3, {"low_freq": 0, "low_gain": 1, "low_q": 2, "mid_freq": 3, "mid_gain": 4, "mid_q": 5, "high_freq": 6, "high_gain": 7, "high_q": 8}),
|
||||
CarlaPluginInfo(11, "Ch4 Comp", "comp", 3, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "makeup": 4, "knee": 5}),
|
||||
# Aux FX
|
||||
CarlaPluginInfo(12, "Aux Reverb", "reverb", -1, {"wet_dry": 0, "decay": 1, "predelay": 2, "size": 3, "damping": 4}),
|
||||
CarlaPluginInfo(13, "Aux Delay", "delay", -1, {"wet_dry": 0, "time": 1, "feedback": 2, "lpf": 3, "hpf": 4}),
|
||||
# Master Limiter
|
||||
CarlaPluginInfo(14, "Master Limiter", "limiter", -1, {"threshold": 0, "release": 1, "ceiling": 2}),
|
||||
]
|
||||
|
||||
|
||||
# ── OSC Client ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CarlaOSCClient:
|
||||
"""OSC client for controlling Carla plugin parameters.
|
||||
|
||||
Communicates with a local Carla instance via UDP OSC on the
|
||||
configured port (default 22752).
|
||||
|
||||
Thread-safe: all sends are serialized; UDP is connectionless
|
||||
so no shared connection state.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 22752,
|
||||
timeout: float = 0.1,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self._sock: socket.socket | None = None
|
||||
self._send_count: int = 0
|
||||
self._error_count: int = 0
|
||||
|
||||
@property
|
||||
def sock(self) -> socket.socket:
|
||||
if self._sock is None:
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._sock.settimeout(self.timeout)
|
||||
return self._sock
|
||||
|
||||
def _send(self, address: str, *args) -> bool:
|
||||
"""Send an OSC message. Returns True on success."""
|
||||
try:
|
||||
packet = encode_osc(address, *args)
|
||||
self.sock.sendto(packet, (self.host, self.port))
|
||||
self._send_count += 1
|
||||
return True
|
||||
except OSError as e:
|
||||
self._error_count += 1
|
||||
logger.warning("OSC send failed (%s): %s", address, e)
|
||||
return False
|
||||
|
||||
# ── Plugin parameter control ────────────────────────────────────────
|
||||
|
||||
def set_parameter(self, plugin_id: int, param_index: int, value: float) -> bool:
|
||||
"""Set a plugin parameter value via OSC.
|
||||
|
||||
Args:
|
||||
plugin_id: Carla internal plugin ID (1-based).
|
||||
param_index: Plugin parameter index (0-based).
|
||||
value: Normalized float (0.0–1.0) or native value.
|
||||
"""
|
||||
return self._send(CARLA_SET_PARAM, plugin_id, param_index, float(value))
|
||||
|
||||
def set_volume(self, plugin_id: int, volume: float) -> bool:
|
||||
"""Set plugin output volume (dB or linear scale)."""
|
||||
return self._send(CARLA_SET_VOLUME, plugin_id, float(volume))
|
||||
|
||||
def set_program(self, plugin_id: int, program: int) -> bool:
|
||||
"""Change plugin program/preset."""
|
||||
return self._send(CARLA_SET_PROGRAM, plugin_id, program)
|
||||
|
||||
def set_midi_program(self, plugin_id: int, program: int) -> bool:
|
||||
"""Change plugin MIDI program."""
|
||||
return self._send(CARLA_SET_MIDI_PROGRAM, plugin_id, program)
|
||||
|
||||
def note_on(self, plugin_id: int, note: int, velocity: int = 100) -> bool:
|
||||
"""Send MIDI note-on to a plugin."""
|
||||
return self._send(CARLA_NOTE_ON, plugin_id, note, velocity)
|
||||
|
||||
def note_off(self, plugin_id: int, note: int) -> bool:
|
||||
"""Send MIDI note-off to a plugin."""
|
||||
return self._send(CARLA_NOTE_OFF, plugin_id, note, 0)
|
||||
|
||||
def activate(self, plugin_id: int) -> bool:
|
||||
"""Activate a plugin."""
|
||||
return self._send(CARLA_ACTIVATE, plugin_id)
|
||||
|
||||
def deactivate(self, plugin_id: int) -> bool:
|
||||
"""Deactivate (bypass) a plugin."""
|
||||
return self._send(CARLA_DEACTIVATE, plugin_id)
|
||||
|
||||
# ── Batch operations ───────────────────────────────────────────────
|
||||
|
||||
def set_many(self, updates: list[tuple[int, int, float]]) -> int:
|
||||
"""Set multiple parameters in one call. Returns success count."""
|
||||
ok = 0
|
||||
for plugin_id, param_index, value in updates:
|
||||
if self.set_parameter(plugin_id, param_index, value):
|
||||
ok += 1
|
||||
return ok
|
||||
|
||||
# ── Stats ──────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"sends": self._send_count,
|
||||
"errors": self._error_count,
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
if self._sock:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
# ── Parameter value scaling ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def linear_to_db(linear: float, min_db: float = -60.0, max_db: float = 12.0) -> float:
|
||||
"""Convert 0.0–1.0 linear to dB."""
|
||||
if linear <= 0.0:
|
||||
return float("-inf") if min_db <= -60.0 else min_db
|
||||
return min_db + linear * (max_db - min_db)
|
||||
|
||||
|
||||
def db_to_linear(db: float, min_db: float = -60.0, max_db: float = 12.0) -> float:
|
||||
"""Convert dB to 0.0–1.0 linear."""
|
||||
if db <= min_db:
|
||||
return 0.0
|
||||
return (db - min_db) / (max_db - min_db)
|
||||
|
||||
|
||||
def freq_to_normalized(hz: float, min_hz: float = 20.0, max_hz: float = 20000.0) -> float:
|
||||
"""Convert frequency to 0.0–1.0 using log scale."""
|
||||
import math
|
||||
return math.log(hz / min_hz) / math.log(max_hz / min_hz)
|
||||
|
||||
|
||||
def normalized_to_freq(norm: float, min_hz: float = 20.0, max_hz: float = 20000.0) -> float:
|
||||
"""Convert 0.0–1.0 to frequency using log scale."""
|
||||
import math
|
||||
return min_hz * (max_hz / min_hz) ** norm
|
||||
|
||||
|
||||
def time_ms_to_normalized(ms: float, min_ms: float = 0.1, max_ms: float = 2000.0) -> float:
|
||||
"""Convert milliseconds to 0.0–1.0."""
|
||||
if ms <= min_ms:
|
||||
return 0.0
|
||||
if ms >= max_ms:
|
||||
return 1.0
|
||||
return (ms - min_ms) / (max_ms - min_ms)
|
||||
|
||||
|
||||
def mix_to_pan(mix: float, pan: float) -> tuple[float, float]:
|
||||
"""Convert mix + pan into L/R gain pair.
|
||||
|
||||
Args:
|
||||
mix: 0.0–1.0 (0 = full L, 1 = full R, 0.5 = center).
|
||||
pan: -1.0 (L) to 1.0 (R).
|
||||
|
||||
Returns:
|
||||
(left_gain, right_gain) in linear scale.
|
||||
"""
|
||||
# Constant-power panning
|
||||
import math
|
||||
angle = (pan + 1.0) * math.pi / 4.0 # 0 to pi/2
|
||||
left = math.cos(angle)
|
||||
right = math.sin(angle)
|
||||
# Blend with mix
|
||||
left *= mix
|
||||
right *= mix
|
||||
return left, right
|
||||
@@ -0,0 +1,541 @@
|
||||
"""Signal routing matrix.
|
||||
|
||||
Manages the JACK port connection graph: which inputs feed which
|
||||
channels, buses, and outputs. Implemented as a directed graph where
|
||||
edges carry gain, mute, and solo state.
|
||||
|
||||
Routing classes:
|
||||
- RouteNode: a point in the signal graph (input, channel, bus, output)
|
||||
- RoutingEdge: a connection with gain/mute/solo
|
||||
- RoutingMatrix: the full graph with JACK port management
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Node types ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NodeType(StrEnum):
|
||||
"""Types of nodes in the routing graph."""
|
||||
SYSTEM_INPUT = "system_input" # system:capture_N
|
||||
CHANNEL_INPUT = "channel_input" # mixer channel N input
|
||||
CHANNEL_DIRECT = "channel_direct" # direct out (pre-fader)
|
||||
AUX_SEND = "aux_send" # channel → aux bus send
|
||||
AUX_BUS = "aux_bus" # aux bus input (summing point)
|
||||
AUX_RETURN = "aux_return" # aux return (post-FX)
|
||||
SUBGROUP = "subgroup" # subgroup bus
|
||||
VCA_GROUP = "vca_group" # VCA control group (not a signal path)
|
||||
MASTER_INPUT = "master_input" # master bus summing input
|
||||
MASTER_INSERT_SEND = "master_insert_send"
|
||||
MASTER_INSERT_RETURN = "master_insert_return"
|
||||
SYSTEM_OUTPUT = "system_output" # system:playback_N
|
||||
CARLA_PLUGIN_IN = "carla_plugin_in"
|
||||
CARLA_PLUGIN_OUT = "carla_plugin_out"
|
||||
FX_INPUT = "fx_input" # external FX input
|
||||
FX_OUTPUT = "fx_output" # external FX output
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteNode:
|
||||
"""A node in the routing graph."""
|
||||
node_id: str # unique identifier
|
||||
node_type: NodeType
|
||||
label: str = ""
|
||||
channel: int = -1 # associated mixer channel (0-based)
|
||||
jack_port: str = "" # JACK port name (if directly mapped)
|
||||
is_stereo: bool = False # True for stereo pairs (handled as L/R pair)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.node_id)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, RouteNode):
|
||||
return self.node_id == other.node_id
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingEdge:
|
||||
"""A connection between two nodes."""
|
||||
source: RouteNode
|
||||
dest: RouteNode
|
||||
gain_db: float = 0.0 # dB trim
|
||||
muted: bool = False
|
||||
soloed: bool = False
|
||||
is_active: bool = True # edge enabled/disabled
|
||||
jack_connected: bool = False # actual JACK port connection status
|
||||
|
||||
@property
|
||||
def effective_gain(self) -> float:
|
||||
"""Linear gain with mute."""
|
||||
if self.muted:
|
||||
return 0.0
|
||||
return 10.0 ** (self.gain_db / 20.0)
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.source.node_id, self.dest.node_id))
|
||||
|
||||
|
||||
# ── Routing Matrix ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RoutingMatrix:
|
||||
"""Manages the full signal routing graph and JACK connections.
|
||||
|
||||
The routing matrix is the central authority on signal flow. It
|
||||
maintains a directed graph of RouteNodes connected by RoutingEdges.
|
||||
When the graph changes, it applies the changes to JACK via
|
||||
jack_connect/jack_disconnect calls.
|
||||
|
||||
Thread-safe for concurrent access from MIDI callbacks and UI.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "mixer-routing"):
|
||||
self.name = name
|
||||
self._nodes: dict[str, RouteNode] = {}
|
||||
self._edges: list[RoutingEdge] = []
|
||||
self._solo_active: bool = False # global solo flag
|
||||
self._solo_nodes: set[str] = set() # node IDs that have solo engaged
|
||||
|
||||
# ── Node management ─────────────────────────────────────────────────
|
||||
|
||||
def add_node(self, node: RouteNode) -> RouteNode:
|
||||
"""Add a node to the graph. Returns the node."""
|
||||
self._nodes[node.node_id] = node
|
||||
return node
|
||||
|
||||
def get_node(self, node_id: str) -> RouteNode | None:
|
||||
return self._nodes.get(node_id)
|
||||
|
||||
def remove_node(self, node_id: str) -> bool:
|
||||
if node_id not in self._nodes:
|
||||
return False
|
||||
# Remove all edges involving this node
|
||||
self._edges = [e for e in self._edges
|
||||
if e.source.node_id != node_id and e.dest.node_id != node_id]
|
||||
del self._nodes[node_id]
|
||||
return True
|
||||
|
||||
def find_nodes(self, node_type: NodeType | None = None,
|
||||
channel: int | None = None) -> list[RouteNode]:
|
||||
"""Find nodes by type and/or channel."""
|
||||
result = []
|
||||
for node in self._nodes.values():
|
||||
if node_type and node.node_type != node_type:
|
||||
continue
|
||||
if channel is not None and node.channel != channel:
|
||||
continue
|
||||
result.append(node)
|
||||
return result
|
||||
|
||||
# ── Edge management ─────────────────────────────────────────────────
|
||||
|
||||
def connect(
|
||||
self,
|
||||
source_id: str,
|
||||
dest_id: str,
|
||||
gain_db: float = 0.0,
|
||||
apply_jack: bool = True,
|
||||
) -> RoutingEdge | None:
|
||||
"""Create a routing edge between two nodes.
|
||||
|
||||
If apply_jack is True and both nodes have JACK ports, connects
|
||||
them via jack_connect immediately.
|
||||
"""
|
||||
source = self._nodes.get(source_id)
|
||||
dest = self._nodes.get(dest_id)
|
||||
if not source or not dest:
|
||||
logger.warning("Cannot connect %s → %s: node not found", source_id, dest_id)
|
||||
return None
|
||||
|
||||
# Check for duplicate
|
||||
for existing in self._edges:
|
||||
if existing.source.node_id == source_id and existing.dest.node_id == dest_id:
|
||||
# Update existing edge
|
||||
existing.gain_db = gain_db
|
||||
if apply_jack:
|
||||
self._jack_connect_edge(existing)
|
||||
return existing
|
||||
|
||||
edge = RoutingEdge(source=source, dest=dest, gain_db=gain_db)
|
||||
self._edges.append(edge)
|
||||
|
||||
if apply_jack:
|
||||
self._jack_connect_edge(edge)
|
||||
|
||||
return edge
|
||||
|
||||
def disconnect(self, source_id: str, dest_id: str, apply_jack: bool = True) -> bool:
|
||||
"""Remove a routing edge between two nodes."""
|
||||
for i, edge in enumerate(self._edges):
|
||||
if edge.source.node_id == source_id and edge.dest.node_id == dest_id:
|
||||
if apply_jack:
|
||||
self._jack_disconnect_edge(edge)
|
||||
self._edges.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_edges(self, source_id: str | None = None,
|
||||
dest_id: str | None = None) -> list[RoutingEdge]:
|
||||
"""Get edges, optionally filtered by source/dest."""
|
||||
result = []
|
||||
for edge in self._edges:
|
||||
if source_id and edge.source.node_id != source_id:
|
||||
continue
|
||||
if dest_id and edge.dest.node_id != dest_id:
|
||||
continue
|
||||
result.append(edge)
|
||||
return result
|
||||
|
||||
def set_gain(self, source_id: str, dest_id: str, gain_db: float) -> bool:
|
||||
"""Set the gain for an existing edge."""
|
||||
for edge in self._edges:
|
||||
if edge.source.node_id == source_id and edge.dest.node_id == dest_id:
|
||||
edge.gain_db = gain_db
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_mute(self, source_id: str, dest_id: str, muted: bool) -> bool:
|
||||
"""Mute/unmute an edge."""
|
||||
for edge in self._edges:
|
||||
if edge.source.node_id == source_id and edge.dest.node_id == dest_id:
|
||||
edge.muted = muted
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Solo logic ──────────────────────────────────────────────────────
|
||||
|
||||
def set_solo(self, source_id: str, solo: bool) -> None:
|
||||
"""Set solo state for a source node."""
|
||||
if solo:
|
||||
self._solo_nodes.add(source_id)
|
||||
else:
|
||||
self._solo_nodes.discard(source_id)
|
||||
|
||||
self._update_solo_routing()
|
||||
|
||||
def clear_solo(self) -> None:
|
||||
"""Clear all solo states."""
|
||||
self._solo_nodes.clear()
|
||||
self._update_solo_routing()
|
||||
|
||||
def _update_solo_routing(self) -> None:
|
||||
"""Update mute states based on solo logic.
|
||||
|
||||
Solo-in-place: when any source is soloed, only edges from
|
||||
soloed sources pass signal. All others are effectively muted.
|
||||
"""
|
||||
has_solo = len(self._solo_nodes) > 0
|
||||
self._solo_active = has_solo
|
||||
|
||||
if not has_solo:
|
||||
# Clear all solo flags when nothing is soloed
|
||||
for edge in self._edges:
|
||||
edge.soloed = False
|
||||
return
|
||||
|
||||
for edge in self._edges:
|
||||
if edge.source.node_id in self._solo_nodes:
|
||||
edge.soloed = True
|
||||
else:
|
||||
edge.soloed = False
|
||||
|
||||
# ── JACK port management ────────────────────────────────────────────
|
||||
|
||||
def _jack_connect_edge(self, edge: RoutingEdge) -> bool:
|
||||
"""Connect a routing edge via JACK if both nodes have jack_ports."""
|
||||
src_port = edge.source.jack_port
|
||||
dst_port = edge.dest.jack_port
|
||||
|
||||
if not src_port or not dst_port:
|
||||
return False
|
||||
|
||||
# Handle stereo pairs
|
||||
ports_to_connect = _get_port_pairs(src_port, dst_port, edge.source.is_stereo,
|
||||
edge.dest.is_stereo)
|
||||
|
||||
success = True
|
||||
for s, d in ports_to_connect:
|
||||
if _jack_connect(s, d):
|
||||
edge.jack_connected = True
|
||||
logger.debug("JACK connect: %s → %s", s, d)
|
||||
else:
|
||||
success = False
|
||||
logger.warning("JACK connect FAILED: %s → %s", s, d)
|
||||
|
||||
return success
|
||||
|
||||
def _jack_disconnect_edge(self, edge: RoutingEdge) -> bool:
|
||||
"""Disconnect a routing edge from JACK."""
|
||||
src_port = edge.source.jack_port
|
||||
dst_port = edge.dest.jack_port
|
||||
|
||||
if not src_port or not dst_port:
|
||||
return False
|
||||
|
||||
ports_to_disconnect = _get_port_pairs(src_port, dst_port, edge.source.is_stereo,
|
||||
edge.dest.is_stereo)
|
||||
|
||||
for s, d in ports_to_disconnect:
|
||||
_jack_disconnect(s, d)
|
||||
|
||||
edge.jack_connected = False
|
||||
return True
|
||||
|
||||
def apply_full_matrix(self) -> dict:
|
||||
"""Apply all edges to JACK. Returns stats."""
|
||||
stats = {"connected": 0, "failed": 0, "disconnected": 0}
|
||||
|
||||
# First, disconnect everything we manage, then reconnect
|
||||
# This ensures consistency with the matrix state.
|
||||
for edge in self._edges:
|
||||
if edge.jack_connected:
|
||||
if self._jack_disconnect_edge(edge):
|
||||
stats["disconnected"] += 1
|
||||
|
||||
for edge in self._edges:
|
||||
if edge.is_active and not edge.muted:
|
||||
if self._jack_connect_edge(edge):
|
||||
stats["connected"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def disconnect_all(self) -> int:
|
||||
"""Disconnect all managed JACK connections. Returns count."""
|
||||
count = 0
|
||||
for edge in self._edges:
|
||||
if edge.jack_connected:
|
||||
if self._jack_disconnect_edge(edge):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
# ── Graph convenience ───────────────────────────────────────────────
|
||||
|
||||
def build_default_8ch_matrix(self) -> None:
|
||||
"""Build the default 8-channel routing matrix.
|
||||
|
||||
system:capture_{1..8} → channel_{0..7}_input → [carla processing]
|
||||
→ channel_{0..7}_to_master → master_input → master_out → system:playback_{1..2}
|
||||
"""
|
||||
self._nodes.clear()
|
||||
self._edges.clear()
|
||||
|
||||
# System I/O nodes
|
||||
for i in range(1, 9):
|
||||
self.add_node(RouteNode(
|
||||
f"sys_in_{i}", NodeType.SYSTEM_INPUT,
|
||||
f"Capture {i}", channel=i - 1,
|
||||
jack_port=f"system:capture_{i}",
|
||||
))
|
||||
for i in range(1, 3):
|
||||
self.add_node(RouteNode(
|
||||
f"sys_out_{i}", NodeType.SYSTEM_OUTPUT,
|
||||
f"Playback {i}", channel=-1,
|
||||
jack_port=f"system:playback_{i}",
|
||||
))
|
||||
|
||||
# Channel inputs
|
||||
for ch in range(8):
|
||||
self.add_node(RouteNode(
|
||||
f"ch_{ch}_input", NodeType.CHANNEL_INPUT,
|
||||
f"CH{ch+1} Input", channel=ch,
|
||||
))
|
||||
self.add_node(RouteNode(
|
||||
f"ch_{ch}_to_master", NodeType.CHANNEL_INPUT,
|
||||
f"CH{ch+1} → Master", channel=ch,
|
||||
))
|
||||
|
||||
# Aux buses (4 sends)
|
||||
for aux in range(4):
|
||||
self.add_node(RouteNode(
|
||||
f"aux_{aux}_bus", NodeType.AUX_BUS,
|
||||
f"AUX {aux+1} Bus", channel=-1,
|
||||
))
|
||||
self.add_node(RouteNode(
|
||||
f"aux_{aux}_return", NodeType.AUX_RETURN,
|
||||
f"AUX {aux+1} Return", channel=-1,
|
||||
))
|
||||
|
||||
# Subgroups (2 stereo subgroups)
|
||||
for sg in range(2):
|
||||
self.add_node(RouteNode(
|
||||
f"subgroup_{sg}", NodeType.SUBGROUP,
|
||||
f"Subgroup {sg+1}", channel=-1, is_stereo=True,
|
||||
))
|
||||
|
||||
# Master bus
|
||||
self.add_node(RouteNode(
|
||||
"master_input", NodeType.MASTER_INPUT,
|
||||
"Master Bus", channel=-1, is_stereo=True,
|
||||
))
|
||||
self.add_node(RouteNode(
|
||||
"master_insert_send", NodeType.MASTER_INSERT_SEND,
|
||||
"Master Insert Send", channel=-1, is_stereo=True,
|
||||
))
|
||||
self.add_node(RouteNode(
|
||||
"master_insert_return", NodeType.MASTER_INSERT_RETURN,
|
||||
"Master Insert Return", channel=-1, is_stereo=True,
|
||||
))
|
||||
|
||||
# Connect system inputs → channels (these get routed through Carla)
|
||||
for ch in range(8):
|
||||
self.connect(f"sys_in_{ch+1}", f"ch_{ch}_input", apply_jack=False)
|
||||
|
||||
# Connect channels → master
|
||||
for ch in range(8):
|
||||
self.connect(f"ch_{ch}_to_master", "master_input", apply_jack=False)
|
||||
|
||||
# Connect master → system outputs
|
||||
self.connect("master_input", "sys_out_1", apply_jack=False)
|
||||
self.connect("master_input", "sys_out_2", apply_jack=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize the routing matrix to a dict."""
|
||||
nodes = []
|
||||
for n in self._nodes.values():
|
||||
nodes.append({
|
||||
"node_id": n.node_id,
|
||||
"type": n.node_type.value,
|
||||
"label": n.label,
|
||||
"channel": n.channel,
|
||||
"jack_port": n.jack_port,
|
||||
"is_stereo": n.is_stereo,
|
||||
})
|
||||
|
||||
edges = []
|
||||
for e in self._edges:
|
||||
edges.append({
|
||||
"source": e.source.node_id,
|
||||
"dest": e.dest.node_id,
|
||||
"gain_db": e.gain_db,
|
||||
"muted": e.muted,
|
||||
"is_active": e.is_active,
|
||||
})
|
||||
|
||||
return {"nodes": nodes, "edges": edges, "solo_nodes": list(self._solo_nodes)}
|
||||
|
||||
def from_dict(self, data: dict) -> None:
|
||||
"""Restore routing matrix from a dict."""
|
||||
self._nodes.clear()
|
||||
self._edges.clear()
|
||||
self._solo_nodes.clear()
|
||||
|
||||
for n in data.get("nodes", []):
|
||||
node = RouteNode(
|
||||
node_id=n["node_id"],
|
||||
node_type=NodeType(n["type"]),
|
||||
label=n.get("label", ""),
|
||||
channel=n.get("channel", -1),
|
||||
jack_port=n.get("jack_port", ""),
|
||||
is_stereo=n.get("is_stereo", False),
|
||||
)
|
||||
self._nodes[node.node_id] = node
|
||||
|
||||
for e in data.get("edges", []):
|
||||
src = self._nodes.get(e["source"])
|
||||
dst = self._nodes.get(e["dest"])
|
||||
if src and dst:
|
||||
edge = RoutingEdge(
|
||||
source=src,
|
||||
dest=dst,
|
||||
gain_db=e.get("gain_db", 0.0),
|
||||
muted=e.get("muted", False),
|
||||
is_active=e.get("is_active", True),
|
||||
)
|
||||
self._edges.append(edge)
|
||||
|
||||
self._solo_nodes = set(data.get("solo_nodes", []))
|
||||
|
||||
|
||||
# ── JACK helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_port_pairs(
|
||||
src: str, dst: str, src_stereo: bool, dst_stereo: bool
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get the actual JACK port pairs to connect/disconnect.
|
||||
|
||||
Handles stereo pairs: if both are stereo, connect L→L, R→R.
|
||||
If only one is stereo, connect both L/R of the stereo side to the mono side.
|
||||
"""
|
||||
# Simple case: both mono
|
||||
if not src_stereo and not dst_stereo:
|
||||
return [(src, dst)]
|
||||
|
||||
# Both stereo: match channels
|
||||
if src_stereo and dst_stereo:
|
||||
return [
|
||||
(src.replace("_1", "_1").replace("_L", "_L") if "_L" not in src else src,
|
||||
dst.replace("_1", "_1").replace("_L", "_L") if "_L" not in dst else dst),
|
||||
]
|
||||
|
||||
# Mono → Stereo or Stereo → Mono
|
||||
# For simplicity, just connect the given ports directly
|
||||
return [(src, dst)]
|
||||
|
||||
|
||||
def _jack_connect(source: str, dest: str) -> bool:
|
||||
"""Connect two JACK ports. Returns True on success."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jack_connect", source, dest],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.debug("jack_connect failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _jack_disconnect(source: str, dest: str) -> bool:
|
||||
"""Disconnect two JACK ports."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jack_disconnect", source, dest],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_jack_ports() -> list[str]:
|
||||
"""Get all current JACK ports."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jack_lsp", "-c"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_jack_connections() -> list[tuple[str, str]]:
|
||||
"""Get all current JACK connections."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jack_lsp", "-c"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
connections = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2:
|
||||
for dest in parts[1:]:
|
||||
connections.append((parts[0], dest))
|
||||
return connections
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Network API — OSC bridge, REST, and WebSocket for the RPi Mixer.
|
||||
|
||||
Provides the external control surface for the mixer engine:
|
||||
- OSC server for DAW and hardware controller integration
|
||||
- REST API for mixer state query and control
|
||||
- WebSocket for real-time bidirectional parameter updates
|
||||
- Session-based auth for browser WebSocket clients
|
||||
- Web UI static file serving
|
||||
- Settings and file management endpoints
|
||||
- API key authentication
|
||||
- Rate limiting and connection management
|
||||
"""
|
||||
|
||||
from .schemas import (
|
||||
ChannelSchema,
|
||||
MasterBusSchema,
|
||||
MixerStateSchema,
|
||||
PluginSchema,
|
||||
TransportSchema,
|
||||
RoutingSchema,
|
||||
AuxBusSchema,
|
||||
SubgroupSchema,
|
||||
VCASchema,
|
||||
)
|
||||
from .server import NetworkServer
|
||||
from .session import SessionManager, Session
|
||||
from .web_routes import create_web_routes, get_static_files_app
|
||||
|
||||
__all__ = [
|
||||
"NetworkServer",
|
||||
"SessionManager",
|
||||
"Session",
|
||||
"create_web_routes",
|
||||
"get_static_files_app",
|
||||
"ChannelSchema",
|
||||
"MasterBusSchema",
|
||||
"MixerStateSchema",
|
||||
"PluginSchema",
|
||||
"TransportSchema",
|
||||
"RoutingSchema",
|
||||
"AuxBusSchema",
|
||||
"SubgroupSchema",
|
||||
"VCASchema",
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""API key authentication for the mixer network API.
|
||||
|
||||
Simple shared-secret authentication for local network use.
|
||||
The key is loaded from the MIXER_API_KEY environment variable or
|
||||
a config file. Supports multiple valid keys for key rotation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Header name for API key
|
||||
API_KEY_HEADER = "X-API-Key"
|
||||
|
||||
# Default key (only used when no env var is set — prints a warning)
|
||||
_DEFAULT_KEY = "mixer-local"
|
||||
|
||||
|
||||
class APIKeyAuth:
|
||||
"""Simple API key validator for local network use.
|
||||
|
||||
The API key is set via the MIXER_API_KEY environment variable.
|
||||
Multiple keys can be provided as comma-separated values to support
|
||||
key rotation. Keys are compared in constant time to prevent timing attacks.
|
||||
"""
|
||||
|
||||
def __init__(self, keys: list[str] | None = None):
|
||||
if keys is not None:
|
||||
self._valid_hashes = {_hash_key(k.strip()) for k in keys if k.strip()}
|
||||
else:
|
||||
env_key = os.environ.get("MIXER_API_KEY", _DEFAULT_KEY)
|
||||
if env_key == _DEFAULT_KEY:
|
||||
logger.warning(
|
||||
"MIXER_API_KEY not set — using default key '%s'. "
|
||||
"Set MIXER_API_KEY env var for production use.",
|
||||
_DEFAULT_KEY,
|
||||
)
|
||||
self._valid_hashes = {_hash_key(k.strip()) for k in env_key.split(",") if k.strip()}
|
||||
|
||||
def validate(self, api_key: str | None) -> bool:
|
||||
"""Validate an API key. Constant-time comparison."""
|
||||
if not api_key:
|
||||
return False
|
||||
key_hash = _hash_key(api_key)
|
||||
return any(
|
||||
hmac.compare_digest(key_hash, valid_hash)
|
||||
for valid_hash in self._valid_hashes
|
||||
)
|
||||
|
||||
def get_keys_count(self) -> int:
|
||||
"""Return the number of valid keys configured."""
|
||||
return len(self._valid_hashes)
|
||||
|
||||
|
||||
def _hash_key(key: str) -> str:
|
||||
"""Hash a key for secure storage."""
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ── FastAPI dependencies ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class APIKeyHeader(HTTPBearer):
|
||||
"""FastAPI dependency that validates the X-API-Key header.
|
||||
|
||||
Usage:
|
||||
auth = APIKeyAuth()
|
||||
router = APIRouter(dependencies=[Depends(APIKeyHeader(auth))])
|
||||
"""
|
||||
|
||||
def __init__(self, auth: APIKeyAuth, auto_error: bool = True):
|
||||
super().__init__(auto_error=auto_error)
|
||||
self._auth = auth
|
||||
|
||||
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
|
||||
"""Validate the API key from the request header."""
|
||||
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing X-API-Key header",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
if not self._auth.validate(api_key):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
return None # Auth succeeded, no credentials object needed
|
||||
|
||||
|
||||
def require_api_key(auth: APIKeyAuth):
|
||||
"""Create a FastAPI dependency that requires a valid API key.
|
||||
|
||||
Usage:
|
||||
auth = APIKeyAuth()
|
||||
app.include_router(router, dependencies=[Depends(require_api_key(auth))])
|
||||
"""
|
||||
async def dependency(request: Request) -> None:
|
||||
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing X-API-Key header",
|
||||
)
|
||||
if not auth.validate(api_key):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
return dependency
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Asynchronous OSC server for external mixer control.
|
||||
|
||||
Listens on a configurable UDP port for OSC messages and dispatches
|
||||
parameter changes to the mixer engine via the ParameterRegistry.
|
||||
|
||||
OSC address pattern:
|
||||
/mixer/channel/<n>/<parameter> <value>
|
||||
/mixer/master/<parameter> <value>
|
||||
/mixer/transport/<parameter> <value>
|
||||
/mixer/fx/<parameter> <value>
|
||||
|
||||
Examples:
|
||||
/mixer/channel/0/volume 0.75
|
||||
/mixer/channel/3/mute 1.0
|
||||
/mixer/master/volume -6.0
|
||||
/mixer/transport/play 1.0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from typing import Optional, Callable
|
||||
|
||||
from ..midi.types import ParameterType, ParameterCategory, MixerParameter
|
||||
from ..mixer.osc_client import encode_osc, decode_osc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default OSC port (different from Carla's 22752)
|
||||
DEFAULT_OSC_PORT = 9001
|
||||
|
||||
# Allowed parameter types for OSC address routing
|
||||
_CHANNEL_PARAMS = {
|
||||
"volume": ParameterType.VOLUME,
|
||||
"pan": ParameterType.PAN,
|
||||
"mute": ParameterType.MUTE,
|
||||
"solo": ParameterType.SOLO,
|
||||
"gain": ParameterType.GAIN,
|
||||
"phase": ParameterType.PHASE_INVERT,
|
||||
"phase_invert": ParameterType.PHASE_INVERT,
|
||||
"eq_enable": ParameterType.EQ_ENABLE,
|
||||
"eq_low_freq": ParameterType.EQ_LOW_FREQ,
|
||||
"eq_low_gain": ParameterType.EQ_LOW_GAIN,
|
||||
"eq_low_q": ParameterType.EQ_LOW_Q,
|
||||
"eq_mid_freq": ParameterType.EQ_MID_FREQ,
|
||||
"eq_mid_gain": ParameterType.EQ_MID_GAIN,
|
||||
"eq_mid_q": ParameterType.EQ_MID_Q,
|
||||
"eq_high_freq": ParameterType.EQ_HIGH_FREQ,
|
||||
"eq_high_gain": ParameterType.EQ_HIGH_GAIN,
|
||||
"eq_high_q": ParameterType.EQ_HIGH_Q,
|
||||
"comp_threshold": ParameterType.COMP_THRESHOLD,
|
||||
"comp_ratio": ParameterType.COMP_RATIO,
|
||||
"comp_attack": ParameterType.COMP_ATTACK,
|
||||
"comp_release": ParameterType.COMP_RELEASE,
|
||||
"comp_gain": ParameterType.COMP_GAIN,
|
||||
"gate_threshold": ParameterType.GATE_THRESHOLD,
|
||||
"gate_range": ParameterType.GATE_RANGE,
|
||||
"fx_send_a": ParameterType.FX_SEND_A,
|
||||
"fx_send_b": ParameterType.FX_SEND_B,
|
||||
}
|
||||
|
||||
_MASTER_PARAMS = {
|
||||
"volume": ParameterType.MASTER_VOLUME,
|
||||
"mute": ParameterType.MASTER_MUTE,
|
||||
"dim": ParameterType.MASTER_DIM,
|
||||
"monitor": ParameterType.MONITOR_VOLUME,
|
||||
"phones": ParameterType.PHONES_VOLUME,
|
||||
}
|
||||
|
||||
_FX_PARAMS = {
|
||||
"return_a": ParameterType.FX_RETURN_A,
|
||||
"return_b": ParameterType.FX_RETURN_B,
|
||||
}
|
||||
|
||||
_TRANSPORT_PARAMS = {
|
||||
"play": ParameterType.PLAY,
|
||||
"stop": ParameterType.STOP,
|
||||
"record": ParameterType.RECORD,
|
||||
"loop": ParameterType.LOOP,
|
||||
"tempo": ParameterType.TEMPO,
|
||||
"tap_tempo": ParameterType.TAP_TEMPO,
|
||||
}
|
||||
|
||||
_UTILITY_PARAMS = {
|
||||
"snapshot_load": ParameterType.SNAPSHOT_LOAD,
|
||||
"snapshot_save": ParameterType.SNAPSHOT_SAVE,
|
||||
"scene_next": ParameterType.SCENE_NEXT,
|
||||
"scene_prev": ParameterType.SCENE_PREV,
|
||||
}
|
||||
|
||||
|
||||
class OSCServer:
|
||||
"""Asynchronous OSC server for mixer control.
|
||||
|
||||
Listens on UDP for OSC messages and dispatches parameter changes.
|
||||
Designed to run alongside the REST/WebSocket server.
|
||||
|
||||
Usage:
|
||||
server = OSCServer(host="0.0.0.0", port=9001)
|
||||
server.set_dispatcher(my_dispatch_function)
|
||||
await server.start()
|
||||
# ... mixer runs ...
|
||||
await server.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = DEFAULT_OSC_PORT,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_OSCProtocol] = None
|
||||
self._running = False
|
||||
self._dispatch_fn: Optional[Callable[[MixerParameter, float], None]] = None
|
||||
self._msg_count: int = 0
|
||||
self._err_count: int = 0
|
||||
|
||||
def set_dispatcher(
|
||||
self, fn: Callable[[MixerParameter, float], None]
|
||||
) -> None:
|
||||
"""Set the callback for parameter changes.
|
||||
|
||||
The dispatcher receives (MixerParameter, value) for each valid
|
||||
OSC message received.
|
||||
"""
|
||||
self._dispatch_fn = fn
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the OSC server."""
|
||||
if self._running:
|
||||
return # Already running — idempotent
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
self._protocol = _OSCProtocol(self._handle_message, self.host, self.port)
|
||||
|
||||
protocol = self._protocol
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: protocol, # type: ignore[return-value]
|
||||
local_addr=(self.host, self.port),
|
||||
)
|
||||
|
||||
self._running = True
|
||||
logger.info("OSC server listening on %s:%d", self.host, self.port)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the OSC server."""
|
||||
self._running = False
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
logger.info("OSC server stopped (%d messages, %d errors)",
|
||||
self._msg_count, self._err_count)
|
||||
|
||||
def _handle_message(self, address: str, args: list) -> None:
|
||||
"""Handle a decoded OSC message."""
|
||||
self._msg_count += 1
|
||||
|
||||
if not self._dispatch_fn:
|
||||
logger.debug("OSC message received but no dispatcher set: %s", address)
|
||||
return
|
||||
|
||||
try:
|
||||
value = _extract_value(args)
|
||||
param_info = _parse_osc_address(address)
|
||||
if param_info is None:
|
||||
logger.debug("Unknown OSC address: %s", address)
|
||||
return
|
||||
|
||||
category, param_type, channel, _ = param_info
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=param_type,
|
||||
category=category,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
self._dispatch_fn(param, value)
|
||||
|
||||
except Exception as exc:
|
||||
self._err_count += 1
|
||||
logger.error("Error handling OSC message %s: %s", address, exc)
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"running": self._running,
|
||||
"messages": self._msg_count,
|
||||
"errors": self._err_count,
|
||||
}
|
||||
|
||||
|
||||
class _OSCProtocol(asyncio.DatagramProtocol):
|
||||
"""asyncio UDP protocol for receiving OSC messages."""
|
||||
|
||||
def __init__(self, handler, host: str, port: int):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._handler = handler
|
||||
self._buffer = b""
|
||||
|
||||
def connection_made(self, transport):
|
||||
pass
|
||||
|
||||
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
||||
"""Process a received OSC datagram."""
|
||||
try:
|
||||
address, args = decode_osc(data)
|
||||
self._handler(address, args)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to decode OSC from %s: %s", addr, exc)
|
||||
|
||||
|
||||
# ── OSC address parsing ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Channel: /mixer/channel/<n>/<param> <value>
|
||||
_CHANNEL_RE = re.compile(r"^/mixer/channel/(\d+)/([a-z_]+)$")
|
||||
|
||||
# Master: /mixer/master/<param> <value>
|
||||
_MASTER_RE = re.compile(r"^/mixer/master/([a-z_]+)$")
|
||||
|
||||
# FX: /mixer/fx/<param> <value>
|
||||
_FX_RE = re.compile(r"^/mixer/fx/([a-z_]+)$")
|
||||
|
||||
# Transport: /mixer/transport/<param> <value>
|
||||
_TRANSPORT_RE = re.compile(r"^/mixer/transport/([a-z_]+)$")
|
||||
|
||||
# Utility: /mixer/utility/<param> <value>
|
||||
_UTILITY_RE = re.compile(r"^/mixer/utility/([a-z_]+)$")
|
||||
|
||||
|
||||
def _parse_osc_address(address: str) -> Optional[tuple[ParameterCategory, ParameterType, int, float]]:
|
||||
"""Parse an OSC address into a parameter change.
|
||||
|
||||
Returns:
|
||||
(category, param_type, channel, value) or None if not recognized.
|
||||
"""
|
||||
# Strip trailing nulls that OSC encoders sometimes add
|
||||
address = address.rstrip("\x00")
|
||||
|
||||
# Split off the last argument which should be the value
|
||||
# (the args come separately from the address, but we handle
|
||||
# the case where value is encoded as a float in the args list)
|
||||
|
||||
# Channel pattern
|
||||
m = _CHANNEL_RE.match(address)
|
||||
if m:
|
||||
channel = int(m.group(1))
|
||||
param_name = m.group(2)
|
||||
if param_name in _CHANNEL_PARAMS:
|
||||
return (
|
||||
ParameterCategory.CHANNEL,
|
||||
_CHANNEL_PARAMS[param_name],
|
||||
channel,
|
||||
0.0, # value will be set from args
|
||||
)
|
||||
|
||||
# Master pattern
|
||||
m = _MASTER_RE.match(address)
|
||||
if m:
|
||||
param_name = m.group(1)
|
||||
if param_name in _MASTER_PARAMS:
|
||||
return (
|
||||
ParameterCategory.MASTER,
|
||||
_MASTER_PARAMS[param_name],
|
||||
-1,
|
||||
0.0,
|
||||
)
|
||||
|
||||
# FX pattern
|
||||
m = _FX_RE.match(address)
|
||||
if m:
|
||||
param_name = m.group(1)
|
||||
if param_name in _FX_PARAMS:
|
||||
return (
|
||||
ParameterCategory.FX,
|
||||
_FX_PARAMS[param_name],
|
||||
-1,
|
||||
0.0,
|
||||
)
|
||||
|
||||
# Transport pattern
|
||||
m = _TRANSPORT_RE.match(address)
|
||||
if m:
|
||||
param_name = m.group(1)
|
||||
if param_name in _TRANSPORT_PARAMS:
|
||||
return (
|
||||
ParameterCategory.TRANSPORT,
|
||||
_TRANSPORT_PARAMS[param_name],
|
||||
-1,
|
||||
0.0,
|
||||
)
|
||||
|
||||
# Utility pattern
|
||||
m = _UTILITY_RE.match(address)
|
||||
if m:
|
||||
param_name = m.group(1)
|
||||
if param_name in _UTILITY_PARAMS:
|
||||
return (
|
||||
ParameterCategory.UTILITY,
|
||||
_UTILITY_PARAMS[param_name],
|
||||
-1,
|
||||
0.0,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_value(args: list) -> float:
|
||||
"""Extract a float value from OSC args."""
|
||||
if not args:
|
||||
return 0.0
|
||||
arg = args[0]
|
||||
if isinstance(arg, (int, float)):
|
||||
return float(arg)
|
||||
if isinstance(arg, bool):
|
||||
return 1.0 if arg else 0.0
|
||||
if isinstance(arg, str):
|
||||
try:
|
||||
return float(arg)
|
||||
except ValueError:
|
||||
pass
|
||||
return 0.0
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Rate limiter — token bucket algorithm for REST and WebSocket connections.
|
||||
|
||||
Provides per-IP rate limiting with configurable burst and steady-state
|
||||
rates. Uses a token bucket with periodic refill. Thread-safe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenBucket:
|
||||
"""Single token bucket for one client.
|
||||
|
||||
Tokens refill at `rate` per second, up to `capacity`.
|
||||
Each request consumes one token. If no tokens are available,
|
||||
the request should be rate-limited.
|
||||
"""
|
||||
|
||||
__slots__ = ("rate", "capacity", "_tokens", "_last_refill")
|
||||
|
||||
def __init__(self, rate: float, capacity: int):
|
||||
self.rate = rate # tokens per second
|
||||
self.capacity = capacity # max burst
|
||||
self._tokens: float = float(capacity)
|
||||
self._last_refill: float = time.monotonic()
|
||||
|
||||
def consume(self, tokens: int = 1) -> bool:
|
||||
"""Try to consume tokens. Returns True if allowed."""
|
||||
self._refill()
|
||||
if self._tokens >= tokens:
|
||||
self._tokens -= tokens
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def available(self) -> float:
|
||||
"""Return the current number of available tokens."""
|
||||
self._refill()
|
||||
return self._tokens
|
||||
|
||||
def _refill(self) -> None:
|
||||
"""Refill tokens based on elapsed time."""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_refill
|
||||
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
|
||||
self._last_refill = now
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Per-IP rate limiter using token buckets.
|
||||
|
||||
Maintains a bucket for each client IP. Old buckets are evicted
|
||||
after a configurable idle timeout.
|
||||
|
||||
Thread-safe for concurrent access.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rate: float = 100.0,
|
||||
capacity: int = 200,
|
||||
eviction_timeout: float = 300.0,
|
||||
ws_rate: float = 500.0,
|
||||
ws_capacity: int = 1000,
|
||||
):
|
||||
self._rate = rate
|
||||
self._capacity = capacity
|
||||
self._ws_rate = ws_rate
|
||||
self._ws_capacity = ws_capacity
|
||||
self._eviction_timeout = eviction_timeout
|
||||
self._buckets: dict[str, TokenBucket] = {}
|
||||
self._last_access: dict[str, float] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _get_client_id(self, request: Request) -> str:
|
||||
"""Extract client identifier from request.
|
||||
|
||||
Prefers X-Forwarded-For header for proxied setups,
|
||||
falls back to client host.
|
||||
"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
# Take the first IP in the chain
|
||||
return forwarded.split(",")[0].strip()
|
||||
client = getattr(request, "client", None)
|
||||
if client:
|
||||
return client.host if hasattr(client, "host") else str(client)
|
||||
return "unknown"
|
||||
|
||||
async def check_rest(self, request: Request) -> bool:
|
||||
"""Check REST API rate limit. Raises HTTPException if exceeded."""
|
||||
client_id = self._get_client_id(request)
|
||||
async with self._lock:
|
||||
bucket = await self._get_bucket(client_id, self._rate, self._capacity)
|
||||
if not bucket.consume(1):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Rate limit exceeded. Try again later.",
|
||||
headers={"Retry-After": "1", "X-RateLimit-Remaining": "0"},
|
||||
)
|
||||
return True
|
||||
|
||||
async def check_ws(self, client_id: str) -> bool:
|
||||
"""Check WebSocket rate limit. Returns False if exceeded."""
|
||||
async with self._lock:
|
||||
bucket = await self._get_bucket(client_id, self._ws_rate, self._ws_capacity)
|
||||
return bucket.consume(1)
|
||||
|
||||
async def _get_bucket(self, client_id: str, rate: float, capacity: int) -> TokenBucket:
|
||||
"""Get or create a token bucket for a client."""
|
||||
now = time.monotonic()
|
||||
|
||||
# Evict stale buckets
|
||||
stale = [
|
||||
cid for cid, last in self._last_access.items()
|
||||
if now - last > self._eviction_timeout
|
||||
]
|
||||
for cid in stale:
|
||||
self._buckets.pop(cid, None)
|
||||
self._last_access.pop(cid, None)
|
||||
|
||||
self._last_access[client_id] = now
|
||||
|
||||
if client_id not in self._buckets:
|
||||
self._buckets[client_id] = TokenBucket(rate=rate, capacity=capacity)
|
||||
return self._buckets[client_id]
|
||||
|
||||
@property
|
||||
def active_connections(self) -> int:
|
||||
"""Number of active client buckets."""
|
||||
return len(self._buckets)
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
"""Rate limiter statistics."""
|
||||
return {
|
||||
"active_clients": len(self._buckets),
|
||||
"rest_rate": self._rate,
|
||||
"rest_capacity": self._capacity,
|
||||
"ws_rate": self._ws_rate,
|
||||
"ws_capacity": self._ws_capacity,
|
||||
}
|
||||
|
||||
|
||||
# ── FastAPI dependency ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def rate_limit(limiter: RateLimiter):
|
||||
"""Create a FastAPI dependency for rate limiting.
|
||||
|
||||
Usage:
|
||||
limiter = RateLimiter()
|
||||
router = APIRouter(dependencies=[Depends(rate_limit(limiter))])
|
||||
"""
|
||||
async def dependency(request: Request) -> None:
|
||||
await limiter.check_rest(request)
|
||||
return dependency
|
||||
@@ -0,0 +1,257 @@
|
||||
"""REST API for mixer state — FastAPI router with all endpoints.
|
||||
|
||||
Endpoints:
|
||||
GET /channels — list all channel states
|
||||
GET /channels/{id} — get specific channel
|
||||
PUT /channels/{id}/parameter — set channel parameter
|
||||
GET /mixes — master bus + aux + subgroups + VCA
|
||||
PUT /mixes/parameter — set master parameter
|
||||
GET /plugins — list plugins
|
||||
GET /transport — transport state
|
||||
PUT /transport/command — transport control
|
||||
GET /routing — routing matrix
|
||||
GET /scenes — list saved scenes
|
||||
POST /scenes/{name}/load — load a scene
|
||||
POST /scenes/{name}/save — save current state as scene
|
||||
GET /state — full mixer state
|
||||
GET /stats — server statistics
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional, Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .schemas import (
|
||||
ChannelSchema,
|
||||
MasterBusSchema,
|
||||
MixerStateSchema,
|
||||
PluginSchema,
|
||||
TransportSchema,
|
||||
RoutingSchema,
|
||||
ParameterUpdateSchema,
|
||||
ParameterUpdateBatchSchema,
|
||||
ParameterSetRequest,
|
||||
APIResponse,
|
||||
)
|
||||
from .auth import APIKeyAuth, require_api_key
|
||||
from .rate_limiter import RateLimiter, rate_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MixerStateProvider:
|
||||
"""Adapter that provides mixer state to the REST API.
|
||||
|
||||
The REST API is stateless — it queries the DSP engine for current
|
||||
state on each request. This adapter wraps those queries.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Callbacks set by the server
|
||||
self.get_full_state: Optional[Callable[[], MixerStateSchema]] = None
|
||||
self.get_channel: Optional[Callable[[int], Optional[ChannelSchema]]] = None
|
||||
self.get_all_channels: Optional[Callable[[], list[ChannelSchema]]] = None
|
||||
self.get_master: Optional[Callable[[], MasterBusSchema]] = None
|
||||
self.get_transport: Optional[Callable[[], TransportSchema]] = None
|
||||
self.get_plugins: Optional[Callable[[], list[PluginSchema]]] = None
|
||||
self.get_routing: Optional[Callable[[], RoutingSchema]] = None
|
||||
self.list_scenes: Optional[Callable[[], list[str]]] = None
|
||||
self.load_scene: Optional[Callable[[str], bool]] = None
|
||||
self.save_scene: Optional[Callable[[str], bool]] = None
|
||||
self.handle_parameter: Optional[Callable[[str, float, int], None]] = None
|
||||
self.handle_transport: Optional[Callable[[str], None]] = None
|
||||
|
||||
|
||||
# ── Router factory ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_router(
|
||||
state: MixerStateProvider,
|
||||
auth: APIKeyAuth,
|
||||
rate_limiter: RateLimiter,
|
||||
prefix: str = "",
|
||||
) -> APIRouter:
|
||||
"""Create the FastAPI router with all mixer API endpoints.
|
||||
|
||||
Args:
|
||||
state: MixerStateProvider for querying engine state.
|
||||
auth: APIKeyAuth for authentication.
|
||||
rate_limiter: RateLimiter for per-IP rate limiting.
|
||||
prefix: Optional path prefix (e.g., "/api/v1").
|
||||
"""
|
||||
|
||||
router = APIRouter(
|
||||
prefix=prefix,
|
||||
tags=["mixer"],
|
||||
dependencies=[
|
||||
Depends(require_api_key(auth)),
|
||||
Depends(rate_limit(rate_limiter)),
|
||||
],
|
||||
)
|
||||
|
||||
# ── Channels ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/channels", response_model=list[ChannelSchema])
|
||||
async def list_channels():
|
||||
"""List all channel states."""
|
||||
if not state.get_all_channels:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
return state.get_all_channels()
|
||||
|
||||
@router.get("/channels/{channel_id}", response_model=ChannelSchema)
|
||||
async def get_channel(channel_id: int):
|
||||
"""Get a specific channel's state."""
|
||||
if not state.get_channel:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
ch = state.get_channel(channel_id)
|
||||
if ch is None:
|
||||
raise HTTPException(status_code=404, detail=f"Channel {channel_id} not found")
|
||||
return ch
|
||||
|
||||
@router.put("/channels/{channel_id}/parameter", response_model=APIResponse)
|
||||
async def set_channel_parameter(channel_id: int, body: ParameterSetRequest):
|
||||
"""Set a parameter on a specific channel."""
|
||||
if not state.handle_parameter:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
|
||||
param_type = body.param_type if hasattr(body, "param_type") else "volume"
|
||||
value = body.value
|
||||
|
||||
state.handle_parameter(param_type, value, channel_id)
|
||||
return APIResponse(ok=True, data={"channel": channel_id, "value": value})
|
||||
|
||||
@router.put("/channels/{channel_id}/parameters", response_model=APIResponse)
|
||||
async def set_channel_parameters(channel_id: int, body: ParameterUpdateBatchSchema):
|
||||
"""Set multiple parameters on a specific channel."""
|
||||
if not state.handle_parameter:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
|
||||
for update in body.updates:
|
||||
state.handle_parameter(
|
||||
update.param_type.value,
|
||||
update.value,
|
||||
update.channel if update.channel >= 0 else channel_id,
|
||||
)
|
||||
|
||||
return APIResponse(ok=True, data={"channel": channel_id, "count": len(body.updates)})
|
||||
|
||||
# ── Mixes / Master ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/mixes", response_model=MasterBusSchema)
|
||||
async def get_mixes():
|
||||
"""Get master bus and all aux/subgroup/VCA states."""
|
||||
if not state.get_master:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
return state.get_master()
|
||||
|
||||
@router.put("/mixes/parameter", response_model=APIResponse)
|
||||
async def set_master_parameter(body: ParameterSetRequest):
|
||||
"""Set a master bus parameter."""
|
||||
if not state.handle_parameter:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
param_type = getattr(body, "param_type", "master_volume")
|
||||
state.handle_parameter(param_type, body.value, -1)
|
||||
return APIResponse(ok=True, data={"param_type": param_type, "value": body.value})
|
||||
|
||||
# ── Plugins ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/plugins", response_model=list[PluginSchema])
|
||||
async def list_plugins():
|
||||
"""List all Carla plugins in the mixer rack."""
|
||||
if not state.get_plugins:
|
||||
raise HTTPException(status_code=503, detail="Plugin info not available")
|
||||
return state.get_plugins()
|
||||
|
||||
# ── Transport ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/transport", response_model=TransportSchema)
|
||||
async def get_transport():
|
||||
"""Get transport state."""
|
||||
if not state.get_transport:
|
||||
raise HTTPException(status_code=503, detail="Transport not available")
|
||||
return state.get_transport()
|
||||
|
||||
@router.put("/transport/command", response_model=APIResponse)
|
||||
async def transport_command(command: str = Query(..., description="Transport command (play, stop, record, loop)")):
|
||||
"""Send a transport command."""
|
||||
if not state.handle_transport:
|
||||
raise HTTPException(status_code=503, detail="Transport not available")
|
||||
state.handle_transport(command)
|
||||
return APIResponse(ok=True, data={"command": command})
|
||||
|
||||
# ── Routing ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/routing", response_model=RoutingSchema)
|
||||
async def get_routing():
|
||||
"""Get the full routing matrix."""
|
||||
if not state.get_routing:
|
||||
raise HTTPException(status_code=503, detail="Routing info not available")
|
||||
return state.get_routing()
|
||||
|
||||
# ── Scenes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/scenes", response_model=list[str])
|
||||
async def list_scenes():
|
||||
"""List saved scene names."""
|
||||
if not state.list_scenes:
|
||||
raise HTTPException(status_code=503, detail="Scenes not available")
|
||||
return state.list_scenes()
|
||||
|
||||
@router.post("/scenes/{name}/load", response_model=APIResponse)
|
||||
async def load_scene(name: str):
|
||||
"""Load a saved scene."""
|
||||
if not state.load_scene:
|
||||
raise HTTPException(status_code=503, detail="Scenes not available")
|
||||
ok = state.load_scene(name)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"Scene '{name}' not found")
|
||||
return APIResponse(ok=True, data={"scene": name})
|
||||
|
||||
@router.post("/scenes/{name}/save", response_model=APIResponse)
|
||||
async def save_scene(name: str):
|
||||
"""Save current mixer state as a scene."""
|
||||
if not state.save_scene:
|
||||
raise HTTPException(status_code=503, detail="Scenes not available")
|
||||
ok = state.save_scene(name)
|
||||
return APIResponse(ok=True, data={"scene": name, "saved": ok})
|
||||
|
||||
# ── Full state ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/state", response_model=MixerStateSchema)
|
||||
async def get_full_state():
|
||||
"""Get the complete mixer state (channels, buses, routing, plugins, scenes)."""
|
||||
if not state.get_full_state:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
return state.get_full_state()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ── Unified parameter endpoint ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def add_unified_parameter_route(router: APIRouter, state: MixerStateProvider) -> None:
|
||||
"""Add a unified /parameter endpoint that accepts category+channel+type.
|
||||
|
||||
PUT /parameter
|
||||
Body: {"category": "channel", "channel": 0, "param_type": "volume", "value": -3.0}
|
||||
"""
|
||||
@router.put("/parameter", response_model=APIResponse)
|
||||
async def set_parameter(body: dict):
|
||||
if not state.handle_parameter:
|
||||
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||
|
||||
param_type = body.get("param_type", "")
|
||||
value = float(body.get("value", 0.0))
|
||||
channel = int(body.get("channel", -1))
|
||||
|
||||
state.handle_parameter(param_type, value, channel)
|
||||
return APIResponse(ok=True, data={
|
||||
"param_type": param_type,
|
||||
"value": value,
|
||||
"channel": channel,
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone network API server for the RPi Audio Mixer.
|
||||
|
||||
Starts the OSC, REST, and WebSocket servers. The DSP engine must
|
||||
be running with Carla and JACK for full functionality.
|
||||
|
||||
Usage:
|
||||
python -m src.network.run [--host HOST] [--port PORT] [--osc-port PORT]
|
||||
|
||||
Environment:
|
||||
MIXER_API_KEY — API key for authentication (default: mixer-local)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from src.mixer.dsp_engine import DSPEngine, DSPEngineConfig
|
||||
from src.network.server import NetworkServer
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("mixer-network")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="RPi Audio Mixer — Network API Server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default="0.0.0.0",
|
||||
help="HTTP/WS listen address (default: 0.0.0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8080,
|
||||
help="HTTP/WS listen port (default: 8080)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--osc-port", type=int, default=9001,
|
||||
help="OSC UDP listen port (default: 9001)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--channels", type=int, default=16,
|
||||
help="Number of mixer channels (default: 16)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-osc", action="store_true",
|
||||
help="Disable OSC server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
help="API key (overrides MIXER_API_KEY env var)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def main():
|
||||
args = parse_args()
|
||||
|
||||
# API key
|
||||
if args.api_key:
|
||||
os.environ["MIXER_API_KEY"] = args.api_key
|
||||
|
||||
# Create DSP engine (simplified — no Carla OSC connection)
|
||||
config = DSPEngineConfig(
|
||||
num_channels=args.channels,
|
||||
osc_enabled=False, # Don't connect to Carla in standalone mode
|
||||
jack_routing_enabled=False, # Don't touch JACK connections in standalone
|
||||
)
|
||||
engine = DSPEngine(config)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
# Create network server
|
||||
server = NetworkServer(
|
||||
engine=engine,
|
||||
osc_host=args.host,
|
||||
osc_port=args.osc_port if not args.no_osc else 0,
|
||||
http_host=args.host,
|
||||
http_port=args.port,
|
||||
)
|
||||
|
||||
# Graceful shutdown
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def _shutdown(sig=None, frame=None):
|
||||
logger.info("Shutdown signal received")
|
||||
shutdown_event.set()
|
||||
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
|
||||
# Start server
|
||||
await server.start()
|
||||
|
||||
if not args.no_osc:
|
||||
logger.info("OSC server: udp://%s:%d", args.host, args.osc_port)
|
||||
logger.info("REST API: http://%s:%d/api/v1", args.host, args.port)
|
||||
logger.info("WebSocket: ws://%s:%d/ws", args.host, args.port)
|
||||
logger.info("API Key: %s", os.environ.get("MIXER_API_KEY", "mixer-local"))
|
||||
|
||||
# Wait for shutdown
|
||||
await shutdown_event.wait()
|
||||
|
||||
# Stop
|
||||
await server.stop()
|
||||
engine.stop()
|
||||
logger.info("Server stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,356 @@
|
||||
"""JSON schemas for mixer state — Pydantic models for REST/WebSocket API.
|
||||
|
||||
These models define the canonical serialization format for mixer state,
|
||||
ensuring UI interoperability across web, mobile, and desktop clients.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Enums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ParameterCategoryEnum(StrEnum):
|
||||
CHANNEL = "channel"
|
||||
MASTER = "master"
|
||||
FX = "fx"
|
||||
ROUTING = "routing"
|
||||
TRANSPORT = "transport"
|
||||
UTILITY = "utility"
|
||||
|
||||
|
||||
class ParameterTypeEnum(StrEnum):
|
||||
"""All mixer parameter types."""
|
||||
# Channel strip
|
||||
VOLUME = "volume"
|
||||
PAN = "pan"
|
||||
MUTE = "mute"
|
||||
SOLO = "solo"
|
||||
GAIN = "gain"
|
||||
PHASE_INVERT = "phase_invert"
|
||||
# EQ
|
||||
EQ_LOW_FREQ = "eq_low_freq"
|
||||
EQ_LOW_GAIN = "eq_low_gain"
|
||||
EQ_LOW_Q = "eq_low_q"
|
||||
EQ_MID_FREQ = "eq_mid_freq"
|
||||
EQ_MID_GAIN = "eq_mid_gain"
|
||||
EQ_MID_Q = "eq_mid_q"
|
||||
EQ_HIGH_FREQ = "eq_high_freq"
|
||||
EQ_HIGH_GAIN = "eq_high_gain"
|
||||
EQ_HIGH_Q = "eq_high_q"
|
||||
EQ_ENABLE = "eq_enable"
|
||||
# Dynamics
|
||||
COMP_THRESHOLD = "comp_threshold"
|
||||
COMP_RATIO = "comp_ratio"
|
||||
COMP_ATTACK = "comp_attack"
|
||||
COMP_RELEASE = "comp_release"
|
||||
COMP_GAIN = "comp_gain"
|
||||
GATE_THRESHOLD = "gate_threshold"
|
||||
GATE_RANGE = "gate_range"
|
||||
# FX
|
||||
FX_SEND_A = "fx_send_a"
|
||||
FX_SEND_B = "fx_send_b"
|
||||
FX_RETURN_A = "fx_return_a"
|
||||
FX_RETURN_B = "fx_return_b"
|
||||
# Master
|
||||
MASTER_VOLUME = "master_volume"
|
||||
MASTER_MUTE = "master_mute"
|
||||
MASTER_DIM = "master_dim"
|
||||
MONITOR_VOLUME = "monitor_volume"
|
||||
PHONES_VOLUME = "phones_volume"
|
||||
# Transport
|
||||
PLAY = "play"
|
||||
STOP = "stop"
|
||||
RECORD = "record"
|
||||
LOOP = "loop"
|
||||
TEMPO = "tempo"
|
||||
TAP_TEMPO = "tap_tempo"
|
||||
# Utility
|
||||
SNAPSHOT_LOAD = "snapshot_load"
|
||||
SNAPSHOT_SAVE = "snapshot_save"
|
||||
SCENE_NEXT = "scene_next"
|
||||
SCENE_PREV = "scene_prev"
|
||||
|
||||
|
||||
class InterpolationModeEnum(StrEnum):
|
||||
LINEAR = "linear"
|
||||
LOGARITHMIC = "logarithmic"
|
||||
S_CURVE = "s_curve"
|
||||
|
||||
|
||||
class NodeTypeEnum(StrEnum):
|
||||
SYSTEM_INPUT = "system_input"
|
||||
CHANNEL_INPUT = "channel_input"
|
||||
CHANNEL_DIRECT = "channel_direct"
|
||||
AUX_SEND = "aux_send"
|
||||
AUX_BUS = "aux_bus"
|
||||
AUX_RETURN = "aux_return"
|
||||
SUBGROUP = "subgroup"
|
||||
VCA_GROUP = "vca_group"
|
||||
MASTER_INPUT = "master_input"
|
||||
MASTER_INSERT_SEND = "master_insert_send"
|
||||
MASTER_INSERT_RETURN = "master_insert_return"
|
||||
SYSTEM_OUTPUT = "system_output"
|
||||
CARLA_PLUGIN_IN = "carla_plugin_in"
|
||||
CARLA_PLUGIN_OUT = "carla_plugin_out"
|
||||
FX_INPUT = "fx_input"
|
||||
FX_OUTPUT = "fx_output"
|
||||
|
||||
|
||||
# ── Parameter definition ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MixerParameterSchema(BaseModel):
|
||||
"""A single mixer parameter definition."""
|
||||
param_type: ParameterTypeEnum
|
||||
category: ParameterCategoryEnum
|
||||
channel: int = -1
|
||||
label: str = ""
|
||||
min_val: float = 0.0
|
||||
max_val: float = 1.0
|
||||
default_val: float = 0.5
|
||||
step: float = 0.0
|
||||
|
||||
|
||||
# ── Channel schema ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EQBandSchema(BaseModel):
|
||||
"""One band of parametric EQ."""
|
||||
freq_hz: float = 100.0
|
||||
gain_db: float = 0.0
|
||||
q: float = 0.71
|
||||
|
||||
|
||||
class EQSchema(BaseModel):
|
||||
"""3-band parametric EQ state."""
|
||||
enabled: bool = False
|
||||
low: EQBandSchema = Field(default_factory=lambda: EQBandSchema(freq_hz=100.0, gain_db=0.0, q=0.71))
|
||||
mid: EQBandSchema = Field(default_factory=lambda: EQBandSchema(freq_hz=1000.0, gain_db=0.0, q=0.71))
|
||||
high: EQBandSchema = Field(default_factory=lambda: EQBandSchema(freq_hz=5000.0, gain_db=0.0, q=0.71))
|
||||
|
||||
|
||||
class CompressorSchema(BaseModel):
|
||||
"""Compressor state."""
|
||||
threshold_db: float = -20.0
|
||||
ratio: float = 2.0
|
||||
attack_ms: float = 10.0
|
||||
release_ms: float = 100.0
|
||||
makeup_gain_db: float = 0.0
|
||||
|
||||
|
||||
class GateSchema(BaseModel):
|
||||
"""Noise gate state."""
|
||||
threshold_db: float = -40.0
|
||||
range_db: float = -60.0
|
||||
|
||||
|
||||
class FXSendsSchema(BaseModel):
|
||||
"""FX send levels."""
|
||||
send_a_db: float = -60.0
|
||||
send_b_db: float = -60.0
|
||||
|
||||
|
||||
class ChannelSchema(BaseModel):
|
||||
"""Complete state of one mixer channel strip."""
|
||||
channel: int = Field(ge=0, description="Zero-based channel index")
|
||||
label: str = ""
|
||||
# Fader
|
||||
volume_db: float = 0.0
|
||||
pan: float = 0.0 # -1.0 L to 1.0 R
|
||||
mute: bool = False
|
||||
solo: bool = False
|
||||
# Preamp
|
||||
gain_db: float = 0.0
|
||||
phase_invert: bool = False
|
||||
# Processing blocks
|
||||
eq: EQSchema = Field(default_factory=EQSchema)
|
||||
compressor: CompressorSchema = Field(default_factory=CompressorSchema)
|
||||
gate: GateSchema = Field(default_factory=GateSchema)
|
||||
fx_sends: FXSendsSchema = Field(default_factory=FXSendsSchema)
|
||||
# Plugin info
|
||||
has_dsp: bool = False
|
||||
plugins: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Bus schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AuxBusSchema(BaseModel):
|
||||
"""Aux send/return bus state."""
|
||||
index: int
|
||||
label: str = ""
|
||||
send_gain_db: float = 0.0
|
||||
return_gain_db: float = 0.0
|
||||
muted: bool = False
|
||||
pre_fader: bool = False
|
||||
fx_plugin_id: Optional[int] = None
|
||||
channel_sends: dict[str, float] = Field(default_factory=dict) # "ch_N" → dB
|
||||
|
||||
|
||||
class SubgroupSchema(BaseModel):
|
||||
"""Subgroup bus state."""
|
||||
index: int
|
||||
label: str = ""
|
||||
volume_db: float = 0.0
|
||||
pan: float = 0.0
|
||||
muted: bool = False
|
||||
solo: bool = False
|
||||
is_stereo: bool = True
|
||||
members: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VCASchema(BaseModel):
|
||||
"""VCA group state."""
|
||||
index: int
|
||||
label: str = ""
|
||||
master_db: float = 0.0
|
||||
muted: bool = False
|
||||
members: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MasterBusSchema(BaseModel):
|
||||
"""Master bus state."""
|
||||
volume_db: float = 0.0
|
||||
dim_db: float = -20.0
|
||||
dim_active: bool = False
|
||||
muted: bool = False
|
||||
mono: bool = False
|
||||
insert_enabled: bool = False
|
||||
aux_buses: list[AuxBusSchema] = Field(default_factory=list)
|
||||
subgroups: list[SubgroupSchema] = Field(default_factory=list)
|
||||
vca_groups: list[VCASchema] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Transport schema ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransportSchema(BaseModel):
|
||||
"""Transport state."""
|
||||
playing: bool = False
|
||||
recording: bool = False
|
||||
loop: bool = False
|
||||
tempo_bpm: float = 120.0
|
||||
position_sec: float = 0.0
|
||||
|
||||
|
||||
# ── Plugin schema ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PluginParamMapSchema(BaseModel):
|
||||
"""Parameter mapping info for a plugin."""
|
||||
name: str
|
||||
index: int
|
||||
value: float = 0.0
|
||||
|
||||
|
||||
class PluginSchema(BaseModel):
|
||||
"""Information about a Carla plugin in the mixer rack."""
|
||||
plugin_id: int
|
||||
name: str
|
||||
role: str # 'gate', 'eq', 'comp', 'gain', 'nam', 'ir', 'reverb', 'delay', 'limiter'
|
||||
channel: int = -1 # -1 for master/global
|
||||
params: list[PluginParamMapSchema] = Field(default_factory=list)
|
||||
active: bool = True
|
||||
|
||||
|
||||
# ── Routing schema ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RouteNodeSchema(BaseModel):
|
||||
"""A node in the routing graph."""
|
||||
node_id: str
|
||||
type: NodeTypeEnum
|
||||
label: str = ""
|
||||
channel: int = -1
|
||||
jack_port: str = ""
|
||||
is_stereo: bool = False
|
||||
|
||||
|
||||
class RoutingEdgeSchema(BaseModel):
|
||||
"""An edge in the routing graph."""
|
||||
source: str
|
||||
dest: str
|
||||
gain_db: float = 0.0
|
||||
muted: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class RoutingSchema(BaseModel):
|
||||
"""Full routing matrix state."""
|
||||
nodes: list[RouteNodeSchema] = Field(default_factory=list)
|
||||
edges: list[RoutingEdgeSchema] = Field(default_factory=list)
|
||||
solo_nodes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Full mixer state ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MixerStateSchema(BaseModel):
|
||||
"""Complete mixer state — the canonical serialization format."""
|
||||
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
channels: list[ChannelSchema] = Field(default_factory=list)
|
||||
master: MasterBusSchema = Field(default_factory=MasterBusSchema)
|
||||
transport: TransportSchema = Field(default_factory=TransportSchema)
|
||||
plugins: list[PluginSchema] = Field(default_factory=list)
|
||||
routing: RoutingSchema = Field(default_factory=RoutingSchema)
|
||||
scenes: list[str] = Field(default_factory=list)
|
||||
uptime_seconds: float = 0.0
|
||||
osc_stats: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Parameter update schema ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ParameterUpdateSchema(BaseModel):
|
||||
"""A single parameter update — used in REST PUT and WebSocket messages."""
|
||||
param_type: ParameterTypeEnum
|
||||
value: float
|
||||
channel: int = -1
|
||||
|
||||
|
||||
class ParameterUpdateBatchSchema(BaseModel):
|
||||
"""Multiple parameter updates in one request."""
|
||||
updates: list[ParameterUpdateSchema]
|
||||
|
||||
|
||||
# ── WebSocket message schemas ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WSMessageType(StrEnum):
|
||||
"""WebSocket message types."""
|
||||
FULL_STATE = "full_state"
|
||||
PARAMETER_UPDATE = "parameter_update"
|
||||
PARAMETER_BATCH = "parameter_batch"
|
||||
TRANSPORT_COMMAND = "transport_command"
|
||||
ERROR = "error"
|
||||
SUBSCRIBE = "subscribe"
|
||||
UNSUBSCRIBE = "unsubscribe"
|
||||
|
||||
|
||||
class WSMessage(BaseModel):
|
||||
"""A WebSocket protocol message."""
|
||||
type: WSMessageType
|
||||
payload: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── API response wrappers ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
"""Standard API response wrapper."""
|
||||
ok: bool = True
|
||||
data: Optional[dict] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ParameterSetRequest(BaseModel):
|
||||
"""Request body for setting a parameter via REST."""
|
||||
param_type: str = "volume"
|
||||
value: float
|
||||
@@ -0,0 +1,715 @@
|
||||
"""Main network server — ties OSC, REST, and WebSocket together.
|
||||
|
||||
The NetworkServer is the top-level class that:
|
||||
1. Creates and manages the OSC server (UDP)
|
||||
2. Creates the FastAPI app with REST + WebSocket endpoints
|
||||
3. Bridges the mixer DSP engine to all external protocols
|
||||
4. Handles authentication and rate limiting
|
||||
5. Provides a unified start/stop lifecycle
|
||||
|
||||
Usage:
|
||||
from src.mixer.dsp_engine import DSPEngine
|
||||
from src.network import NetworkServer
|
||||
|
||||
engine = DSPEngine()
|
||||
engine.start()
|
||||
|
||||
server = NetworkServer(engine)
|
||||
await server.start()
|
||||
# ... mixer runs ...
|
||||
await server.stop()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, WebSocket, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..midi.types import (
|
||||
ParameterType,
|
||||
ParameterCategory,
|
||||
MixerParameter,
|
||||
)
|
||||
from ..mixer.dsp_engine import DSPEngine
|
||||
|
||||
from .auth import APIKeyAuth, require_api_key, API_KEY_HEADER
|
||||
from .rate_limiter import RateLimiter, rate_limit
|
||||
from .osc_server import OSCServer
|
||||
from .rest_api import MixerStateProvider, create_router
|
||||
from .websocket import WebSocketManager
|
||||
from .session import SessionManager
|
||||
from .web_routes import create_web_routes, get_static_files_app
|
||||
from .schemas import (
|
||||
ChannelSchema,
|
||||
MasterBusSchema,
|
||||
MixerStateSchema,
|
||||
PluginSchema,
|
||||
TransportSchema,
|
||||
RoutingSchema,
|
||||
AuxBusSchema,
|
||||
SubgroupSchema,
|
||||
VCASchema,
|
||||
EQSchema,
|
||||
EQBandSchema,
|
||||
CompressorSchema,
|
||||
GateSchema,
|
||||
FXSendsSchema,
|
||||
ParameterUpdateSchema,
|
||||
WSMessage,
|
||||
WSMessageType,
|
||||
RouteNodeSchema,
|
||||
RoutingEdgeSchema,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NetworkServer:
|
||||
"""Unified network server for the RPi Audio Mixer.
|
||||
|
||||
Manages OSC (UDP), REST (HTTP), and WebSocket servers, bridging
|
||||
them to the DSP engine. Provides authentication, rate limiting,
|
||||
and connection management.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: DSPEngine,
|
||||
osc_host: str = "0.0.0.0",
|
||||
osc_port: int = 9001,
|
||||
http_host: str = "0.0.0.0",
|
||||
http_port: int = 8080,
|
||||
api_keys: list[str] | None = None,
|
||||
rate_limit: float = 100.0,
|
||||
rate_capacity: int = 200,
|
||||
ws_max_connections: int = 20,
|
||||
session_ttl: float = 86400,
|
||||
web_dir: str | None = None,
|
||||
audio_dir: str | None = None,
|
||||
):
|
||||
self._engine = engine
|
||||
self._osc_host = osc_host
|
||||
self._osc_port = osc_port
|
||||
self._http_host = http_host
|
||||
self._http_port = http_port
|
||||
|
||||
# Auth
|
||||
self._auth = APIKeyAuth(keys=api_keys)
|
||||
|
||||
# Session manager for WebSocket browser auth
|
||||
self._session_manager = SessionManager(ttl=session_ttl)
|
||||
|
||||
# Settings storage
|
||||
self._settings: dict = {
|
||||
"sample_rate": 48000,
|
||||
"buffer_size": 256,
|
||||
"num_channels": len(engine.channels),
|
||||
"osc_enabled": True,
|
||||
"osc_port": osc_port,
|
||||
"http_port": http_port,
|
||||
}
|
||||
|
||||
# Rate limiter
|
||||
self._rate_limiter = RateLimiter(
|
||||
rate=rate_limit,
|
||||
capacity=rate_capacity,
|
||||
)
|
||||
|
||||
# State provider (bridge between REST API and DSP engine)
|
||||
self._state_provider = MixerStateProvider()
|
||||
self._setup_state_provider()
|
||||
|
||||
# OSC server
|
||||
self._osc_server = OSCServer(host=osc_host, port=osc_port)
|
||||
self._osc_server.set_dispatcher(self._handle_osc_parameter)
|
||||
|
||||
# WebSocket manager
|
||||
self._ws_manager = WebSocketManager(
|
||||
max_connections=ws_max_connections,
|
||||
rate_limiter=self._rate_limiter,
|
||||
)
|
||||
self._ws_manager.set_state_provider(self._get_mixer_state)
|
||||
self._ws_manager.set_parameter_handler(self._handle_ws_parameter)
|
||||
self._ws_manager.set_transport_handler(self._handle_ws_transport)
|
||||
|
||||
# Web UI config (must be set before _build_app)
|
||||
self._web_dir = web_dir
|
||||
self._audio_dir = audio_dir or os.path.expanduser("~/mixer-audio")
|
||||
|
||||
# FastAPI app
|
||||
self._app = self._build_app()
|
||||
|
||||
# Server handle
|
||||
self._uvicorn_server: Optional[uvicorn.Server] = None
|
||||
self._running = False
|
||||
self._start_time: float = 0.0
|
||||
|
||||
# Register parameter callback on the DSP engine's registry
|
||||
self._setup_parameter_forwarding()
|
||||
|
||||
# ── Settings management ──────────────────────────────────────────────
|
||||
|
||||
def _get_settings(self) -> dict:
|
||||
"""Get current server settings."""
|
||||
return dict(self._settings)
|
||||
|
||||
def _set_settings(self, updates: dict) -> bool:
|
||||
"""Apply settings updates. Returns True if all keys are valid."""
|
||||
valid_keys = set(self._settings.keys())
|
||||
for key in updates:
|
||||
if key not in valid_keys:
|
||||
logger.warning("Rejected unknown setting key: %s", key)
|
||||
return False
|
||||
self._settings.update(updates)
|
||||
logger.info("Settings updated: %s", updates)
|
||||
return True
|
||||
|
||||
# ── State provider setup ──────────────────────────────────────────────
|
||||
|
||||
def _setup_state_provider(self) -> None:
|
||||
"""Wire the state provider to the DSP engine."""
|
||||
sp = self._state_provider
|
||||
|
||||
sp.get_full_state = self._get_mixer_state
|
||||
sp.get_channel = self._get_channel_schema
|
||||
sp.get_all_channels = self._get_all_channels
|
||||
sp.get_master = self._get_master_schema
|
||||
sp.get_transport = self._get_transport_schema
|
||||
sp.get_plugins = self._get_plugins_schema
|
||||
sp.get_routing = self._get_routing_schema
|
||||
sp.list_scenes = lambda: self._engine.automation.list_scenes()
|
||||
sp.load_scene = lambda name: self._engine.load_snapshot(name)
|
||||
sp.save_scene = lambda name: bool(self._engine.save_snapshot(name))
|
||||
sp.handle_parameter = self._handle_api_parameter
|
||||
sp.handle_transport = self._handle_transport_command
|
||||
|
||||
def _setup_parameter_forwarding(self) -> None:
|
||||
"""Forward parameter changes from the DSP engine to WebSocket clients."""
|
||||
# We don't use the ParameterRegistry callback here because the
|
||||
# DSP engine already handles parameter dispatch through its
|
||||
# handle_parameter method. Instead, we hook into that flow
|
||||
# by wrapping the engine's parameter handler, or by subscribing
|
||||
# to the ParameterRegistry.
|
||||
|
||||
# The engine's ParameterRegistry is accessible via the MIDI engine,
|
||||
# but for simplicity, we forward from our own handle_parameter calls.
|
||||
# The WebSocket manager broadcasts to all connected clients.
|
||||
pass
|
||||
|
||||
# ── FastAPI app construction ──────────────────────────────────────────
|
||||
|
||||
def _build_app(self) -> FastAPI:
|
||||
"""Build the FastAPI application with all routes."""
|
||||
app = FastAPI(
|
||||
title="RPi Audio Mixer API",
|
||||
description="Network control API for the Raspberry Pi Real-Time Audio Mixer",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# CORS — allow local network access
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Health check (no auth required)
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "running": self._running}
|
||||
|
||||
# API info endpoint
|
||||
@app.get("/api")
|
||||
async def api_root():
|
||||
return {
|
||||
"name": "RPi Audio Mixer API",
|
||||
"version": "0.1.0",
|
||||
"endpoints": {
|
||||
"rest": f"http://{self._http_host}:{self._http_port}/api/v1",
|
||||
"websocket": f"ws://{self._http_host}:{self._http_port}/ws",
|
||||
"osc": f"osc://{self._osc_host}:{self._osc_port}",
|
||||
"webui": f"http://{self._http_host}:{self._http_port}/",
|
||||
},
|
||||
"auth": f"Header: {API_KEY_HEADER}",
|
||||
}
|
||||
|
||||
# REST API router (auth + rate-limited)
|
||||
api_router = create_router(
|
||||
state=self._state_provider,
|
||||
auth=self._auth,
|
||||
rate_limiter=self._rate_limiter,
|
||||
prefix="/api/v1",
|
||||
)
|
||||
app.include_router(api_router)
|
||||
|
||||
# Web application routes (login, settings, file management)
|
||||
web_routers = create_web_routes(
|
||||
auth=self._auth,
|
||||
session_manager=self._session_manager,
|
||||
settings_getter=self._get_settings,
|
||||
settings_setter=self._set_settings,
|
||||
audio_dir=Path(self._audio_dir) if self._audio_dir else None,
|
||||
)
|
||||
for router in web_routers:
|
||||
app.include_router(router)
|
||||
|
||||
# WebSocket endpoint (supports both API key and session token)
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket):
|
||||
await ws.accept()
|
||||
|
||||
# Get client IP for identification
|
||||
client_host = "unknown"
|
||||
if ws.client:
|
||||
client_host = getattr(ws.client, "host", "unknown")
|
||||
client_id = f"{client_host}:{id(ws)}"
|
||||
|
||||
# Try session token first (browser clients)
|
||||
session_token = ws.query_params.get("session", "")
|
||||
if session_token:
|
||||
session = self._session_manager.validate_token(session_token)
|
||||
if session:
|
||||
logger.debug("WS session auth OK: %s", session.client_id)
|
||||
await self._ws_manager.handle_connection(ws, client_id)
|
||||
return
|
||||
else:
|
||||
await ws.send_text('{"type":"error","payload":{"message":"Invalid or expired session token"}}')
|
||||
await ws.close(code=4001, reason="Invalid session token")
|
||||
return
|
||||
|
||||
# Fall back to API key auth
|
||||
api_key = ws.query_params.get("api_key", "")
|
||||
if not api_key:
|
||||
# Try reading first message as auth
|
||||
try:
|
||||
import json
|
||||
auth_msg = await asyncio.wait_for(ws.receive_text(), timeout=5.0)
|
||||
auth_data = json.loads(auth_msg)
|
||||
api_key = auth_data.get("api_key", "")
|
||||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
if not self._auth.validate(api_key):
|
||||
await ws.send_text('{"type":"error","payload":{"message":"Invalid API key"}}')
|
||||
await ws.close(code=4001, reason="Invalid API key")
|
||||
return
|
||||
|
||||
await self._ws_manager.handle_connection(ws, client_id)
|
||||
|
||||
# Stats endpoint (auth required)
|
||||
@app.get("/api/v1/stats")
|
||||
async def get_full_stats(request: Request):
|
||||
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||
if not self._auth.validate(api_key):
|
||||
return JSONResponse(status_code=403, content={"error": "Invalid API key"})
|
||||
|
||||
return {
|
||||
"engine": self._engine.stats,
|
||||
"osc": self._osc_server.stats,
|
||||
"ws": self._ws_manager.stats,
|
||||
"rate_limiter": self._rate_limiter.stats,
|
||||
"auth_keys": self._auth.get_keys_count(),
|
||||
"sessions": self._session_manager.stats,
|
||||
}
|
||||
|
||||
# Mount static web UI files at /
|
||||
try:
|
||||
static_app = get_static_files_app()
|
||||
app.mount("/", static_app, name="webui")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not mount web UI static files: %s", exc)
|
||||
|
||||
return app
|
||||
|
||||
# ── Parameter handlers ────────────────────────────────────────────────
|
||||
|
||||
def _handle_osc_parameter(self, param: MixerParameter, value: float) -> None:
|
||||
"""Handle a parameter change from the OSC server."""
|
||||
try:
|
||||
self._engine.handle_parameter(param, value)
|
||||
|
||||
# Broadcast to WebSocket clients
|
||||
asyncio.create_task(
|
||||
self._ws_manager.broadcast_parameter_update(
|
||||
param.param_type.value,
|
||||
value,
|
||||
param.channel,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Error handling OSC parameter: %s", exc)
|
||||
|
||||
def _handle_api_parameter(self, param_type: str, value: float, channel: int) -> None:
|
||||
"""Handle a parameter change from the REST API."""
|
||||
try:
|
||||
pt = ParameterType(param_type)
|
||||
except ValueError:
|
||||
logger.warning("Unknown parameter type from API: %s", param_type)
|
||||
return
|
||||
|
||||
# Determine category and build MixerParameter
|
||||
category = _infer_category(pt)
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=pt,
|
||||
category=category,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
self._engine.handle_parameter(param, value)
|
||||
|
||||
# Broadcast to WebSocket clients (non-blocking)
|
||||
asyncio.create_task(
|
||||
self._ws_manager.broadcast_parameter_update(param_type, value, channel)
|
||||
)
|
||||
|
||||
def _handle_ws_parameter(self, param_type: str, value: float, channel: int) -> None:
|
||||
"""Handle a parameter change from a WebSocket client."""
|
||||
self._handle_api_parameter(param_type, value, channel)
|
||||
|
||||
def _handle_ws_transport(self, command: str) -> None:
|
||||
"""Handle a transport command from a WebSocket client."""
|
||||
self._handle_transport_command(command)
|
||||
|
||||
def _handle_transport_command(self, command: str) -> None:
|
||||
"""Handle a transport command."""
|
||||
transport_map = {
|
||||
"play": ParameterType.PLAY,
|
||||
"stop": ParameterType.STOP,
|
||||
"record": ParameterType.RECORD,
|
||||
"loop": ParameterType.LOOP,
|
||||
}
|
||||
|
||||
pt = transport_map.get(command.lower())
|
||||
if pt is None:
|
||||
logger.warning("Unknown transport command: %s", command)
|
||||
return
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=pt,
|
||||
category=ParameterCategory.TRANSPORT,
|
||||
channel=-1,
|
||||
)
|
||||
|
||||
self._engine.handle_parameter(param, 1.0)
|
||||
|
||||
# ── State serialization ───────────────────────────────────────────────
|
||||
|
||||
def _get_mixer_state(self) -> MixerStateSchema:
|
||||
"""Build the full mixer state schema from the DSP engine."""
|
||||
channels = self._get_all_channels()
|
||||
master = self._get_master_schema()
|
||||
transport = self._get_transport_schema()
|
||||
plugins = self._get_plugins_schema()
|
||||
routing = self._get_routing_schema()
|
||||
scenes = self._engine.automation.list_scenes()
|
||||
|
||||
return MixerStateSchema(
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
channels=channels,
|
||||
master=master,
|
||||
transport=transport,
|
||||
plugins=plugins,
|
||||
routing=routing,
|
||||
scenes=scenes,
|
||||
uptime_seconds=self.uptime,
|
||||
osc_stats=self._osc_server.stats,
|
||||
)
|
||||
|
||||
def _get_channel_schema(self, index: int) -> Optional[ChannelSchema]:
|
||||
"""Build a ChannelSchema from a DSP channel strip."""
|
||||
ch = self._engine.get_channel(index)
|
||||
if ch is None:
|
||||
return None
|
||||
|
||||
state = ch.state
|
||||
return ChannelSchema(
|
||||
channel=index,
|
||||
label=f"CH{index + 1}",
|
||||
volume_db=state.volume,
|
||||
pan=state.pan,
|
||||
mute=state.mute,
|
||||
solo=state.solo,
|
||||
gain_db=state.gain,
|
||||
phase_invert=state.phase_invert,
|
||||
eq=EQSchema(
|
||||
enabled=state.eq_enable,
|
||||
low=EQBandSchema(freq_hz=state.eq_low_freq, gain_db=state.eq_low_gain, q=state.eq_low_q),
|
||||
mid=EQBandSchema(freq_hz=state.eq_mid_freq, gain_db=state.eq_mid_gain, q=state.eq_mid_q),
|
||||
high=EQBandSchema(freq_hz=state.eq_high_freq, gain_db=state.eq_high_gain, q=state.eq_high_q),
|
||||
),
|
||||
compressor=CompressorSchema(
|
||||
threshold_db=state.comp_threshold,
|
||||
ratio=state.comp_ratio,
|
||||
attack_ms=state.comp_attack,
|
||||
release_ms=state.comp_release,
|
||||
makeup_gain_db=state.comp_gain,
|
||||
),
|
||||
gate=GateSchema(
|
||||
threshold_db=state.gate_threshold,
|
||||
range_db=state.gate_range,
|
||||
),
|
||||
fx_sends=FXSendsSchema(
|
||||
send_a_db=state.fx_send_a,
|
||||
send_b_db=state.fx_send_b,
|
||||
),
|
||||
has_dsp=ch.has_dsp,
|
||||
plugins=list(ch._plugins.keys()),
|
||||
)
|
||||
|
||||
def _get_all_channels(self) -> list[ChannelSchema]:
|
||||
"""Get all channel schemas."""
|
||||
return [
|
||||
self._get_channel_schema(i)
|
||||
for i in range(len(self._engine.channels))
|
||||
]
|
||||
|
||||
def _get_master_schema(self) -> MasterBusSchema:
|
||||
"""Build a MasterBusSchema from the bus manager."""
|
||||
bm = self._engine.buses
|
||||
m = bm.master
|
||||
|
||||
aux_buses = []
|
||||
for aux in bm.aux_buses:
|
||||
aux_buses.append(AuxBusSchema(
|
||||
index=aux.index,
|
||||
label=aux.label,
|
||||
send_gain_db=aux.send_gain_db,
|
||||
return_gain_db=aux.return_gain_db,
|
||||
muted=aux.muted,
|
||||
pre_fader=aux.pre_fader,
|
||||
fx_plugin_id=aux.fx_plugin_id,
|
||||
channel_sends={f"ch_{k}": v for k, v in aux.channel_sends.items()},
|
||||
))
|
||||
|
||||
subgroups = []
|
||||
for sg in bm.subgroups:
|
||||
subgroups.append(SubgroupSchema(
|
||||
index=sg.index,
|
||||
label=sg.label,
|
||||
volume_db=sg.volume_db,
|
||||
pan=sg.pan,
|
||||
muted=sg.muted,
|
||||
solo=sg.solo,
|
||||
is_stereo=sg.is_stereo,
|
||||
members=list(sg.members),
|
||||
))
|
||||
|
||||
vca_groups = []
|
||||
for vca in bm.vca_groups:
|
||||
vca_groups.append(VCASchema(
|
||||
index=vca.index,
|
||||
label=vca.label,
|
||||
master_db=vca.master_db,
|
||||
muted=vca.muted,
|
||||
members=list(vca.members),
|
||||
))
|
||||
|
||||
return MasterBusSchema(
|
||||
volume_db=m.volume_db,
|
||||
dim_db=m.dim_db,
|
||||
dim_active=m.dim_active,
|
||||
muted=m.muted,
|
||||
mono=m.mono,
|
||||
insert_enabled=m.insert_enabled,
|
||||
aux_buses=aux_buses,
|
||||
subgroups=subgroups,
|
||||
vca_groups=vca_groups,
|
||||
)
|
||||
|
||||
def _get_transport_schema(self) -> TransportSchema:
|
||||
"""Build a TransportSchema from the automation engine."""
|
||||
auto = self._engine.automation
|
||||
return TransportSchema(
|
||||
playing=auto.is_playing,
|
||||
recording=auto.is_recording,
|
||||
loop=getattr(auto, "_loop", False),
|
||||
tempo_bpm=120.0, # tempo is stored in engine config
|
||||
position_sec=auto.current_time,
|
||||
)
|
||||
|
||||
def _get_plugins_schema(self) -> list[PluginSchema]:
|
||||
"""Build plugin schemas from the OSC plugin registry."""
|
||||
from ..mixer.osc_client import DEFAULT_PLUGIN_LAYOUT
|
||||
|
||||
plugins = []
|
||||
for info in DEFAULT_PLUGIN_LAYOUT:
|
||||
params = [
|
||||
{"name": name, "index": idx, "value": 0.0}
|
||||
for name, idx in info.param_map.items()
|
||||
]
|
||||
plugins.append(PluginSchema(
|
||||
plugin_id=info.plugin_id,
|
||||
name=info.name,
|
||||
role=info.role,
|
||||
channel=info.channel,
|
||||
params=params,
|
||||
active=True,
|
||||
))
|
||||
return plugins
|
||||
|
||||
def _get_routing_schema(self) -> RoutingSchema:
|
||||
"""Build a RoutingSchema from the routing matrix."""
|
||||
rm = self._engine.routing
|
||||
data = rm.to_dict()
|
||||
|
||||
nodes = []
|
||||
for n in data.get("nodes", []):
|
||||
nodes.append(RouteNodeSchema(
|
||||
node_id=n["node_id"],
|
||||
type=n["type"],
|
||||
label=n.get("label", ""),
|
||||
channel=n.get("channel", -1),
|
||||
jack_port=n.get("jack_port", ""),
|
||||
is_stereo=n.get("is_stereo", False),
|
||||
))
|
||||
|
||||
edges = []
|
||||
for e in data.get("edges", []):
|
||||
edges.append(RoutingEdgeSchema(
|
||||
source=e["source"],
|
||||
dest=e["dest"],
|
||||
gain_db=e.get("gain_db", 0.0),
|
||||
muted=e.get("muted", False),
|
||||
is_active=e.get("is_active", True),
|
||||
))
|
||||
|
||||
return RoutingSchema(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
solo_nodes=data.get("solo_nodes", []),
|
||||
)
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start all network servers."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.monotonic()
|
||||
|
||||
# Start OSC server
|
||||
await self._osc_server.start()
|
||||
|
||||
# Start HTTP/WS server (uvicorn in the event loop)
|
||||
config = uvicorn.Config(
|
||||
app=self._app,
|
||||
host=self._http_host,
|
||||
port=self._http_port,
|
||||
log_level="info",
|
||||
access_log=False,
|
||||
)
|
||||
self._uvicorn_server = uvicorn.Server(config)
|
||||
|
||||
logger.info("Starting HTTP/WS server on %s:%d", self._http_host, self._http_port)
|
||||
# Run uvicorn as a task so it doesn't block
|
||||
self._uvicorn_task = asyncio.create_task(self._uvicorn_server.serve())
|
||||
|
||||
# Wait for uvicorn to be ready
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
logger.info(
|
||||
"Network server started: OSC udp://%s:%d, REST http://%s:%d, WS ws://%s:%d/ws",
|
||||
self._osc_host, self._osc_port,
|
||||
self._http_host, self._http_port,
|
||||
self._http_host, self._http_port,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop all network servers."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
# Stop OSC
|
||||
await self._osc_server.stop()
|
||||
|
||||
# Stop HTTP/WS
|
||||
if self._uvicorn_server:
|
||||
self._uvicorn_server.should_exit = True
|
||||
try:
|
||||
if hasattr(self, "_uvicorn_task"):
|
||||
await asyncio.wait_for(self._uvicorn_task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Uvicorn server did not shut down gracefully")
|
||||
self._uvicorn_server = None
|
||||
|
||||
uptime = time.monotonic() - self._start_time
|
||||
logger.info("Network server stopped (uptime: %.1fs)", uptime)
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def uptime(self) -> float:
|
||||
if not self._start_time:
|
||||
return 0.0
|
||||
return time.monotonic() - self._start_time
|
||||
|
||||
@property
|
||||
def app(self) -> FastAPI:
|
||||
"""The FastAPI application (for mounting in other ASGI servers)."""
|
||||
return self._app
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _infer_category(pt: ParameterType) -> ParameterCategory:
|
||||
"""Infer the parameter category from its type."""
|
||||
channel_params = {
|
||||
ParameterType.VOLUME, ParameterType.PAN, ParameterType.MUTE,
|
||||
ParameterType.SOLO, ParameterType.GAIN, ParameterType.PHASE_INVERT,
|
||||
ParameterType.EQ_LOW_FREQ, ParameterType.EQ_LOW_GAIN, ParameterType.EQ_LOW_Q,
|
||||
ParameterType.EQ_MID_FREQ, ParameterType.EQ_MID_GAIN, ParameterType.EQ_MID_Q,
|
||||
ParameterType.EQ_HIGH_FREQ, ParameterType.EQ_HIGH_GAIN, ParameterType.EQ_HIGH_Q,
|
||||
ParameterType.EQ_ENABLE,
|
||||
ParameterType.COMP_THRESHOLD, ParameterType.COMP_RATIO, ParameterType.COMP_ATTACK,
|
||||
ParameterType.COMP_RELEASE, ParameterType.COMP_GAIN,
|
||||
ParameterType.GATE_THRESHOLD, ParameterType.GATE_RANGE,
|
||||
ParameterType.FX_SEND_A, ParameterType.FX_SEND_B,
|
||||
}
|
||||
master_params = {
|
||||
ParameterType.MASTER_VOLUME, ParameterType.MASTER_MUTE,
|
||||
ParameterType.MASTER_DIM, ParameterType.MONITOR_VOLUME,
|
||||
ParameterType.PHONES_VOLUME,
|
||||
}
|
||||
fx_params = {ParameterType.FX_RETURN_A, ParameterType.FX_RETURN_B}
|
||||
transport_params = {
|
||||
ParameterType.PLAY, ParameterType.STOP, ParameterType.RECORD,
|
||||
ParameterType.LOOP, ParameterType.TEMPO, ParameterType.TAP_TEMPO,
|
||||
}
|
||||
utility_params = {
|
||||
ParameterType.SNAPSHOT_LOAD, ParameterType.SNAPSHOT_SAVE,
|
||||
ParameterType.SCENE_NEXT, ParameterType.SCENE_PREV,
|
||||
}
|
||||
|
||||
if pt in channel_params:
|
||||
return ParameterCategory.CHANNEL
|
||||
if pt in master_params:
|
||||
return ParameterCategory.MASTER
|
||||
if pt in fx_params:
|
||||
return ParameterCategory.FX
|
||||
if pt in transport_params:
|
||||
return ParameterCategory.TRANSPORT
|
||||
if pt in utility_params:
|
||||
return ParameterCategory.UTILITY
|
||||
return ParameterCategory.CHANNEL
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Session-based authentication for WebSocket connections.
|
||||
|
||||
Provides a token-based session system for browser clients:
|
||||
- POST /auth/login → returns a session token
|
||||
- WS /ws?session=TOKEN → validates the session token
|
||||
|
||||
Sessions expire after a configurable TTL. Tokens are HMAC-SHA256 to
|
||||
avoid storing raw tokens on the server.
|
||||
|
||||
Design:
|
||||
- Tokens are opaque to the client (HMAC-derived)
|
||||
- Server stores session data in memory (dict) with expiry
|
||||
- Stale sessions are evicted on validation or periodically
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default session TTL: 24 hours
|
||||
DEFAULT_SESSION_TTL = 86400
|
||||
|
||||
# Server secret for token signing (generated at startup or from env)
|
||||
_SERVER_SECRET: Optional[str] = None
|
||||
|
||||
|
||||
def _get_server_secret() -> str:
|
||||
"""Get or generate the server secret for token signing."""
|
||||
global _SERVER_SECRET
|
||||
if _SERVER_SECRET is None:
|
||||
env_secret = os.environ.get("MIXER_SESSION_SECRET", "")
|
||||
if env_secret:
|
||||
_SERVER_SECRET = env_secret
|
||||
else:
|
||||
_SERVER_SECRET = secrets.token_hex(32)
|
||||
logger.info("Generated new session secret (set MIXER_SESSION_SECRET env var to persist)")
|
||||
return _SERVER_SECRET
|
||||
|
||||
|
||||
def _make_token(session_id: str, salt: str) -> str:
|
||||
"""Create an opaque token from a session ID and salt using HMAC-SHA256."""
|
||||
secret = _get_server_secret()
|
||||
data = f"{session_id}:{salt}"
|
||||
mac = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
return mac
|
||||
|
||||
|
||||
class Session:
|
||||
"""A single user session."""
|
||||
|
||||
__slots__ = ("session_id", "client_id", "created_at", "expires_at", "metadata")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
client_id: str,
|
||||
ttl: float = DEFAULT_SESSION_TTL,
|
||||
metadata: Optional[dict] = None,
|
||||
):
|
||||
self.session_id = session_id
|
||||
self.client_id = client_id
|
||||
self.created_at = time.time()
|
||||
self.expires_at = self.created_at + ttl
|
||||
self.metadata = metadata or {}
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
return time.time() > self.expires_at
|
||||
|
||||
@property
|
||||
def ttl_remaining(self) -> float:
|
||||
return max(0.0, self.expires_at - time.time())
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages user sessions for WebSocket authentication.
|
||||
|
||||
Usage:
|
||||
mgr = SessionManager(ttl=3600)
|
||||
|
||||
# Login: create a session
|
||||
token = mgr.create_session("browser-chrome-1")
|
||||
# Returns opaque token
|
||||
|
||||
# Validate: check a token
|
||||
session = mgr.validate_token(token)
|
||||
if session:
|
||||
print(f"Valid session for {session.client_id}")
|
||||
|
||||
# Logout: destroy a session
|
||||
mgr.destroy_session(token)
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: float = DEFAULT_SESSION_TTL, max_sessions: int = 100):
|
||||
self._ttl = ttl
|
||||
self._max_sessions = max_sessions
|
||||
|
||||
# In-memory storage: token → Session
|
||||
self._sessions: dict[str, Session] = {}
|
||||
|
||||
# Reverse index: session_id → token (for revocation by ID)
|
||||
self._session_ids: dict[str, str] = {}
|
||||
|
||||
self._total_created: int = 0
|
||||
self._total_destroyed: int = 0
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
client_id: str,
|
||||
metadata: Optional[dict] = None,
|
||||
ttl: Optional[float] = None,
|
||||
) -> str:
|
||||
"""Create a new session and return its opaque token.
|
||||
|
||||
Args:
|
||||
client_id: Human-readable client identifier (e.g., browser user-agent).
|
||||
metadata: Optional metadata stored with the session.
|
||||
ttl: Override the default session TTL (seconds).
|
||||
|
||||
Returns:
|
||||
An opaque token string that can be used to validate the session.
|
||||
"""
|
||||
# Evict stale sessions first
|
||||
self._evict_stale()
|
||||
|
||||
# Enforce max sessions
|
||||
if len(self._sessions) >= self._max_sessions:
|
||||
self._evict_oldest()
|
||||
|
||||
session_id = secrets.token_hex(16)
|
||||
salt = secrets.token_hex(8)
|
||||
token = _make_token(session_id, salt)
|
||||
|
||||
effective_ttl = ttl if ttl is not None else self._ttl
|
||||
|
||||
session = Session(
|
||||
session_id=session_id,
|
||||
client_id=client_id,
|
||||
ttl=effective_ttl,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
self._sessions[token] = session
|
||||
self._session_ids[session_id] = token
|
||||
self._total_created += 1
|
||||
|
||||
logger.debug(
|
||||
"Session created: %s for %s (expires in %ds)",
|
||||
session_id, client_id, effective_ttl,
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
def validate_token(self, token: str) -> Optional[Session]:
|
||||
"""Validate a session token and return the Session if valid.
|
||||
|
||||
Returns None if the token is invalid or expired.
|
||||
Stale sessions are evicted during validation.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
session = self._sessions.get(token)
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
if session.expired:
|
||||
self._destroy(token, session)
|
||||
return None
|
||||
|
||||
return session
|
||||
|
||||
def destroy_session(self, token: str) -> bool:
|
||||
"""Destroy a session by token. Returns True if the session existed."""
|
||||
session = self._sessions.get(token)
|
||||
if session is None:
|
||||
return False
|
||||
self._destroy(token, session)
|
||||
return True
|
||||
|
||||
def destroy_by_id(self, session_id: str) -> bool:
|
||||
"""Destroy a session by its internal ID."""
|
||||
token = self._session_ids.pop(session_id, None)
|
||||
if token is None:
|
||||
return False
|
||||
session = self._sessions.pop(token, None)
|
||||
if session:
|
||||
self._total_destroyed += 1
|
||||
return session is not None
|
||||
|
||||
def get_session(self, token: str) -> Optional[Session]:
|
||||
"""Get a session without validating expiry."""
|
||||
return self._sessions.get(token)
|
||||
|
||||
def refresh_session(self, token: str, ttl: Optional[float] = None) -> bool:
|
||||
"""Extend the TTL of a valid session."""
|
||||
session = self._sessions.get(token)
|
||||
if session is None or session.expired:
|
||||
return False
|
||||
effective_ttl = ttl if ttl is not None else self._ttl
|
||||
session.expires_at = time.time() + effective_ttl
|
||||
return True
|
||||
|
||||
# ── Internal ──────────────────────────────────────────────────────────
|
||||
|
||||
def _destroy(self, token: str, session: Session) -> None:
|
||||
"""Remove a session from storage."""
|
||||
self._sessions.pop(token, None)
|
||||
self._session_ids.pop(session.session_id, None)
|
||||
self._total_destroyed += 1
|
||||
|
||||
def _evict_stale(self) -> int:
|
||||
"""Remove all expired sessions. Returns number evicted."""
|
||||
now = time.time()
|
||||
stale = [
|
||||
(tok, sess)
|
||||
for tok, sess in self._sessions.items()
|
||||
if now > sess.expires_at
|
||||
]
|
||||
for tok, sess in stale:
|
||||
self._destroy(tok, sess)
|
||||
if stale:
|
||||
logger.debug("Evicted %d stale sessions", len(stale))
|
||||
return len(stale)
|
||||
|
||||
def _evict_oldest(self) -> None:
|
||||
"""Evict the oldest session to make room."""
|
||||
if not self._sessions:
|
||||
return
|
||||
oldest = min(self._sessions.values(), key=lambda s: s.created_at)
|
||||
token = self._session_ids.get(oldest.session_id)
|
||||
if token:
|
||||
self._destroy(token, oldest)
|
||||
logger.debug("Evicted oldest session to make room")
|
||||
|
||||
# ── Stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def active_sessions(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
self._evict_stale()
|
||||
return {
|
||||
"active_sessions": len(self._sessions),
|
||||
"total_created": self._total_created,
|
||||
"total_destroyed": self._total_destroyed,
|
||||
"max_sessions": self._max_sessions,
|
||||
"default_ttl": self._ttl,
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Web application routes — login, settings, file management, and static file serving.
|
||||
|
||||
Routes:
|
||||
POST /api/v1/auth/login — exchange API key for session token
|
||||
POST /api/v1/auth/logout — invalidate a session token
|
||||
GET /api/v1/settings — get server settings
|
||||
PUT /api/v1/settings — update server settings
|
||||
GET /api/v1/files — list audio files
|
||||
GET /api/v1/files/{name} — download an audio file
|
||||
GET / — serve web UI (index.html)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
PlainTextResponse,
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .auth import APIKeyAuth, API_KEY_HEADER
|
||||
from .session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default web UI directory (relative to project root)
|
||||
DEFAULT_WEB_DIR = Path(__file__).resolve().parent.parent.parent / "web"
|
||||
|
||||
# Default audio files directory
|
||||
DEFAULT_AUDIO_DIR = Path.home() / "mixer-audio"
|
||||
|
||||
# Recognized audio file extensions
|
||||
AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".aiff", ".aif", ".m4a"}
|
||||
|
||||
|
||||
def _make_login_router(
|
||||
auth: APIKeyAuth,
|
||||
session_manager: SessionManager,
|
||||
) -> APIRouter:
|
||||
"""Create the session auth router (login/logout)."""
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: Request):
|
||||
"""Exchange an API key for a session token.
|
||||
|
||||
Request body: {"api_key": "your-key"} or X-API-Key header.
|
||||
Returns: {"token": "session-token", "expires_in": 86400}
|
||||
"""
|
||||
# Try JSON body first, then header
|
||||
api_key = ""
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = await request.json()
|
||||
api_key = body.get("api_key", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not api_key:
|
||||
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||
|
||||
if not auth.validate(api_key):
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
|
||||
client_id = request.headers.get("user-agent", "unknown")
|
||||
token = session_manager.create_session(client_id=client_id)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"expires_in": int(session_manager._ttl),
|
||||
}
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Invalidate a session token."""
|
||||
content_type = request.headers.get("content-type", "")
|
||||
token = ""
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = await request.json()
|
||||
token = body.get("token", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="Missing token")
|
||||
|
||||
destroyed = session_manager.destroy_session(token)
|
||||
return {"ok": destroyed}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _make_settings_router(
|
||||
settings_getter: Callable[[], dict],
|
||||
settings_setter: Callable[[dict], bool],
|
||||
) -> APIRouter:
|
||||
"""Create the settings REST router."""
|
||||
|
||||
router = APIRouter(prefix="/api/v1/settings", tags=["settings"])
|
||||
|
||||
@router.get("")
|
||||
async def get_settings():
|
||||
"""Get current server settings."""
|
||||
return settings_getter()
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(request: Request):
|
||||
"""Update server settings.
|
||||
|
||||
Request body: {"key": "value", ...}
|
||||
Only known keys are accepted; unknown keys return 400.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=400, detail="Body must be a JSON object")
|
||||
|
||||
ok = settings_setter(body)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail="Invalid setting keys")
|
||||
|
||||
return {"ok": True, "settings": settings_getter()}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _make_files_router(
|
||||
audio_dir: Path,
|
||||
) -> APIRouter:
|
||||
"""Create the file management REST router."""
|
||||
|
||||
router = APIRouter(prefix="/api/v1/files", tags=["files"])
|
||||
|
||||
@router.get("")
|
||||
async def list_files():
|
||||
"""List available audio files."""
|
||||
if not audio_dir.exists():
|
||||
return {"files": [], "directory": str(audio_dir), "exists": False}
|
||||
|
||||
files = []
|
||||
try:
|
||||
for entry in sorted(audio_dir.iterdir()):
|
||||
if entry.is_file() and entry.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
stat = entry.stat()
|
||||
files.append({
|
||||
"name": entry.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
})
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Cannot read audio directory")
|
||||
|
||||
return {
|
||||
"files": files,
|
||||
"directory": str(audio_dir),
|
||||
"exists": True,
|
||||
"count": len(files),
|
||||
}
|
||||
|
||||
@router.get("/{filename:path}")
|
||||
async def download_file(filename: str):
|
||||
"""Download an audio file."""
|
||||
file_path = (audio_dir / filename).resolve()
|
||||
|
||||
# Security: ensure the resolved path is within the audio directory
|
||||
try:
|
||||
file_path.relative_to(audio_dir.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Path traversal denied")
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
||||
|
||||
if file_path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||
raise HTTPException(status_code=403, detail="Not an audio file")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type="application/octet-stream",
|
||||
filename=file_path.name,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def create_web_routes(
|
||||
auth: APIKeyAuth,
|
||||
session_manager: SessionManager,
|
||||
settings_getter: Callable[[], dict],
|
||||
settings_setter: Callable[[dict], bool],
|
||||
web_dir: Optional[Path] = None,
|
||||
audio_dir: Optional[Path] = None,
|
||||
) -> list[APIRouter]:
|
||||
"""Create all web application routers.
|
||||
|
||||
Args:
|
||||
auth: API key authenticator for login.
|
||||
session_manager: Session manager for WebSocket auth.
|
||||
settings_getter: Returns current settings dict.
|
||||
settings_setter: Accepts partial settings dict, returns True if valid.
|
||||
web_dir: Directory containing web UI static files.
|
||||
audio_dir: Directory for audio file management.
|
||||
|
||||
Returns:
|
||||
List of APIRouter instances to include in the FastAPI app.
|
||||
"""
|
||||
if web_dir is None:
|
||||
web_dir = DEFAULT_WEB_DIR
|
||||
if audio_dir is None:
|
||||
audio_dir = DEFAULT_AUDIO_DIR
|
||||
|
||||
routers = [
|
||||
_make_login_router(auth, session_manager),
|
||||
_make_settings_router(settings_getter, settings_setter),
|
||||
_make_files_router(audio_dir),
|
||||
]
|
||||
|
||||
return routers
|
||||
|
||||
|
||||
def get_static_files_app(web_dir: Optional[Path] = None) -> StaticFiles:
|
||||
"""Get the StaticFiles app for serving the web UI.
|
||||
|
||||
Returns a StaticFiles instance that can be mounted on the FastAPI app.
|
||||
If the web directory doesn't exist, creates it with a placeholder index.html.
|
||||
"""
|
||||
if web_dir is None:
|
||||
web_dir = DEFAULT_WEB_DIR
|
||||
|
||||
if not web_dir.exists():
|
||||
web_dir.mkdir(parents=True, exist_ok=True)
|
||||
_create_placeholder_ui(web_dir)
|
||||
|
||||
return StaticFiles(directory=str(web_dir), html=True)
|
||||
|
||||
|
||||
def _create_placeholder_ui(web_dir: Path) -> None:
|
||||
"""Create a minimal placeholder web UI."""
|
||||
index_path = web_dir / "index.html"
|
||||
if not index_path.exists():
|
||||
index_path.write_text("""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RPi Audio Mixer</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1 { color: #333; }
|
||||
.status { padding: 1rem; border-radius: 8px; margin: 1rem 0; }
|
||||
.connected { background: #d4edda; color: #155724; }
|
||||
.disconnected { background: #f8d7da; color: #721c24; }
|
||||
input, button { padding: 0.5rem; margin: 0.25rem; }
|
||||
#messages { background: #f5f5f5; padding: 1rem; border-radius: 8px; max-height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RPi Audio Mixer</h1>
|
||||
<div id="status" class="status disconnected">Disconnected</div>
|
||||
<div>
|
||||
<input type="text" id="token" placeholder="Session token" style="width: 300px;">
|
||||
<button onclick="connect()">Connect</button>
|
||||
<button onclick="disconnect()">Disconnect</button>
|
||||
</div>
|
||||
<div style="margin: 1rem 0;">
|
||||
<button onclick="login()">Login (get session)</button>
|
||||
<input type="text" id="apikey" placeholder="API Key" value="mixer-local">
|
||||
</div>
|
||||
<h3>Messages</h3>
|
||||
<div id="messages"></div>
|
||||
<script>
|
||||
let ws = null;
|
||||
const status = document.getElementById('status');
|
||||
const messages = document.getElementById('messages');
|
||||
|
||||
function log(msg) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = new Date().toLocaleTimeString() + ' ' + msg;
|
||||
messages.prepend(div);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const apiKey = document.getElementById('apikey').value;
|
||||
try {
|
||||
const resp = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-API-Key': apiKey},
|
||||
body: JSON.stringify({api_key: apiKey}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.token) {
|
||||
document.getElementById('token').value = data.token;
|
||||
log('Logged in, token: ' + data.token.substring(0, 16) + '...');
|
||||
} else {
|
||||
log('Login failed: ' + JSON.stringify(data));
|
||||
}
|
||||
} catch (e) {
|
||||
log('Login error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const token = document.getElementById('token').value;
|
||||
if (!token) { log('No session token'); return; }
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(proto + '//' + location.host + '/ws?session=' + token);
|
||||
ws.onopen = () => {
|
||||
status.textContent = 'Connected';
|
||||
status.className = 'status connected';
|
||||
log('WebSocket connected');
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
log(msg.type + ': ' + JSON.stringify(msg.payload).substring(0, 100));
|
||||
} catch (err) {
|
||||
log('Message: ' + e.data);
|
||||
}
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
status.textContent = 'Disconnected';
|
||||
status.className = 'status disconnected';
|
||||
log('WebSocket closed: ' + e.code);
|
||||
ws = null;
|
||||
};
|
||||
ws.onerror = (e) => log('WebSocket error');
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>""")
|
||||
logger.info("Created placeholder web UI at %s", index_path)
|
||||
@@ -0,0 +1,286 @@
|
||||
"""WebSocket manager for real-time bidirectional parameter updates.
|
||||
|
||||
Manages WebSocket client connections, broadcasting parameter updates
|
||||
to all connected clients, and receiving parameter changes from clients.
|
||||
|
||||
Uses the `websockets` library for low-level WebSocket support, or
|
||||
wraps FastAPI's built-in WebSocket for HTTP integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Callable, Any
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from .schemas import (
|
||||
WSMessage,
|
||||
WSMessageType,
|
||||
MixerStateSchema,
|
||||
)
|
||||
from .rate_limiter import RateLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections and broadcasts.
|
||||
|
||||
Usage:
|
||||
manager = WebSocketManager()
|
||||
manager.set_state_provider(my_get_state_fn)
|
||||
manager.set_parameter_handler(my_handle_param_fn)
|
||||
|
||||
# In FastAPI WebSocket endpoint:
|
||||
await manager.handle_connection(websocket, client_id)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_connections: int = 20,
|
||||
rate_limiter: Optional[RateLimiter] = None,
|
||||
):
|
||||
self._max_connections = max_connections
|
||||
self._rate_limiter = rate_limiter
|
||||
|
||||
# Connected clients: client_id → WebSocket
|
||||
self._clients: dict[str, WebSocket] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# Callbacks
|
||||
self._state_provider: Optional[Callable[[], MixerStateSchema]] = None
|
||||
self._parameter_handler: Optional[Callable[[str, float, int], None]] = None
|
||||
self._transport_handler: Optional[Callable[[str], None]] = None
|
||||
|
||||
# Stats
|
||||
self._total_connections: int = 0
|
||||
self._total_messages: int = 0
|
||||
self._total_broadcasts: int = 0
|
||||
self._start_time: float = time.monotonic()
|
||||
|
||||
def set_state_provider(self, fn: Callable[[], MixerStateSchema]) -> None:
|
||||
"""Set the callback that provides the current mixer state snapshot."""
|
||||
self._state_provider = fn
|
||||
|
||||
def set_parameter_handler(self, fn: Callable[[str, float, int], None]) -> None:
|
||||
"""Set the callback for incoming parameter changes.
|
||||
|
||||
Args:
|
||||
fn: Callable(param_type: str, value: float, channel: int)
|
||||
"""
|
||||
self._parameter_handler = fn
|
||||
|
||||
def set_transport_handler(self, fn: Callable[[str], None]) -> None:
|
||||
"""Set the callback for transport commands from clients."""
|
||||
self._transport_handler = fn
|
||||
|
||||
async def handle_connection(self, websocket: WebSocket, client_id: str) -> None:
|
||||
"""Handle a new WebSocket connection.
|
||||
|
||||
Args:
|
||||
websocket: The FastAPI WebSocket connection.
|
||||
client_id: Unique client identifier (IP-based).
|
||||
"""
|
||||
# Check connection limit
|
||||
async with self._lock:
|
||||
if len(self._clients) >= self._max_connections:
|
||||
await websocket.close(code=1013, reason="Too many connections")
|
||||
return
|
||||
self._clients[client_id] = websocket
|
||||
self._total_connections += 1
|
||||
|
||||
logger.info("WS client connected: %s (total: %d)", client_id, len(self._clients))
|
||||
|
||||
try:
|
||||
# Send full state on connect
|
||||
await self._send_full_state(websocket)
|
||||
|
||||
# Main message loop
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except RuntimeError:
|
||||
break
|
||||
|
||||
# Rate limit check
|
||||
if self._rate_limiter:
|
||||
if not await self._rate_limiter.check_ws(client_id):
|
||||
await self._send_error(websocket, "Rate limit exceeded")
|
||||
continue
|
||||
|
||||
self._total_messages += 1
|
||||
|
||||
try:
|
||||
msg = WSMessage.model_validate_json(data)
|
||||
await self._handle_client_message(client_id, msg)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
await self._send_error(websocket, f"Invalid message: {exc}")
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("WS error for %s: %s", client_id, exc)
|
||||
finally:
|
||||
await self._remove_client(client_id)
|
||||
logger.info("WS client disconnected: %s (remaining: %d)", client_id, len(self._clients))
|
||||
|
||||
async def _send_full_state(self, websocket: WebSocket) -> None:
|
||||
"""Send the full mixer state to a newly connected client."""
|
||||
if self._state_provider:
|
||||
try:
|
||||
state = self._state_provider()
|
||||
await self._send_json(websocket, {
|
||||
"type": WSMessageType.FULL_STATE.value,
|
||||
"payload": state.model_dump(),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Error getting full state: %s", exc)
|
||||
await self._send_error(websocket, "Failed to get mixer state")
|
||||
|
||||
async def broadcast(self, message: WSMessage) -> int:
|
||||
"""Broadcast a message to all connected clients.
|
||||
|
||||
Returns:
|
||||
Number of clients the message was sent to.
|
||||
"""
|
||||
async with self._lock:
|
||||
clients = list(self._clients.items())
|
||||
|
||||
self._total_broadcasts += 1
|
||||
|
||||
payload = json.dumps({
|
||||
"type": message.type.value,
|
||||
"payload": message.payload,
|
||||
})
|
||||
|
||||
count = 0
|
||||
for client_id, ws in clients:
|
||||
try:
|
||||
await ws.send_text(payload)
|
||||
count += 1
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
await self._remove_client(client_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Broadcast failed to %s: %s", client_id, exc)
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_parameter_update(
|
||||
self,
|
||||
param_type: str,
|
||||
value: float,
|
||||
channel: int = -1,
|
||||
) -> int:
|
||||
"""Broadcast a parameter update to all clients."""
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.PARAMETER_UPDATE,
|
||||
payload={
|
||||
"param_type": param_type,
|
||||
"value": value,
|
||||
"channel": channel,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
)
|
||||
return await self.broadcast(msg)
|
||||
|
||||
async def broadcast_transport_update(self, state: dict) -> int:
|
||||
"""Broadcast transport state changes."""
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.TRANSPORT_COMMAND,
|
||||
payload=state,
|
||||
)
|
||||
return await self.broadcast(msg)
|
||||
|
||||
async def _handle_client_message(self, client_id: str, msg: WSMessage) -> None:
|
||||
"""Handle an incoming WebSocket message from a client."""
|
||||
ws = self._clients.get(client_id)
|
||||
if not ws:
|
||||
return
|
||||
|
||||
match msg.type:
|
||||
case WSMessageType.PARAMETER_UPDATE:
|
||||
payload = msg.payload
|
||||
param_type = payload.get("param_type", "")
|
||||
value = float(payload.get("value", 0.0))
|
||||
channel = int(payload.get("channel", -1))
|
||||
|
||||
if self._parameter_handler:
|
||||
self._parameter_handler(param_type, value, channel)
|
||||
|
||||
# Re-broadcast to other clients
|
||||
await self.broadcast_parameter_update(param_type, value, channel)
|
||||
|
||||
case WSMessageType.PARAMETER_BATCH:
|
||||
updates = msg.payload.get("updates", [])
|
||||
for update in updates:
|
||||
param_type = update.get("param_type", "")
|
||||
value = float(update.get("value", 0.0))
|
||||
channel = int(update.get("channel", -1))
|
||||
|
||||
if self._parameter_handler:
|
||||
self._parameter_handler(param_type, value, channel)
|
||||
|
||||
# Broadcast the batch as individual updates
|
||||
for update in updates:
|
||||
await self.broadcast_parameter_update(
|
||||
update.get("param_type", ""),
|
||||
float(update.get("value", 0.0)),
|
||||
int(update.get("channel", -1)),
|
||||
)
|
||||
|
||||
case WSMessageType.TRANSPORT_COMMAND:
|
||||
command = msg.payload.get("command", "")
|
||||
if self._transport_handler and command:
|
||||
self._transport_handler(command)
|
||||
await self.broadcast_transport_update(msg.payload)
|
||||
|
||||
case WSMessageType.SUBSCRIBE:
|
||||
# Subscription filtering (future: per-client channel filters)
|
||||
await self._send_json(ws, {
|
||||
"type": "subscribed",
|
||||
"payload": {"filter": msg.payload.get("filter", "all")},
|
||||
})
|
||||
|
||||
case _:
|
||||
await self._send_error(ws, f"Unsupported message type: {msg.type.value}")
|
||||
|
||||
async def _send_json(self, ws: WebSocket, data: dict) -> None:
|
||||
"""Send a JSON payload to a WebSocket client."""
|
||||
try:
|
||||
await ws.send_text(json.dumps(data))
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
pass
|
||||
|
||||
async def _send_error(self, ws: WebSocket, message: str) -> None:
|
||||
"""Send an error message to a WebSocket client."""
|
||||
await self._send_json(ws, {
|
||||
"type": WSMessageType.ERROR.value,
|
||||
"payload": {"message": message},
|
||||
})
|
||||
|
||||
async def _remove_client(self, client_id: str) -> None:
|
||||
"""Remove a disconnected client."""
|
||||
async with self._lock:
|
||||
self._clients.pop(client_id, None)
|
||||
|
||||
@property
|
||||
def connected_clients(self) -> int:
|
||||
return len(self._clients)
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
uptime = time.monotonic() - self._start_time
|
||||
return {
|
||||
"connected_clients": len(self._clients),
|
||||
"total_connections": self._total_connections,
|
||||
"total_messages": self._total_messages,
|
||||
"total_broadcasts": self._total_broadcasts,
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"max_connections": self._max_connections,
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Plugin Manager — scan, register, and manage audio plugins for RPi Mixer.
|
||||
|
||||
Provides:
|
||||
- Plugin scanning engine (LV2, VST3, LADSPA, NAM)
|
||||
- SQLite registry with metadata caching
|
||||
- Install/remove/update plugin bundles
|
||||
- Blacklist for known-broken plugins
|
||||
- Category tagging (amps, reverbs, delays, dynamics, etc.)
|
||||
- NAM model support: download and manage .nam files
|
||||
"""
|
||||
|
||||
from .types import (
|
||||
PluginCategory,
|
||||
PluginFormat,
|
||||
PluginInfo,
|
||||
PluginMeta,
|
||||
PluginPort,
|
||||
PluginStatus,
|
||||
PluginBundle,
|
||||
)
|
||||
from .scanner import (
|
||||
scan_all,
|
||||
scan_format,
|
||||
scan_lv2,
|
||||
scan_vst3,
|
||||
scan_ladspa,
|
||||
scan_nam,
|
||||
)
|
||||
from .registry import (
|
||||
PluginRegistry,
|
||||
)
|
||||
from .blacklist import (
|
||||
PluginBlacklist,
|
||||
BlacklistEntry,
|
||||
BUILTIN_BLACKLIST,
|
||||
)
|
||||
from .categories import (
|
||||
classify,
|
||||
enrich_plugin,
|
||||
enrich_batch,
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_GROUPS,
|
||||
)
|
||||
from .nam import (
|
||||
NAMManager,
|
||||
NAMModelMeta,
|
||||
)
|
||||
from .manager import (
|
||||
PluginManager,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"PluginCategory",
|
||||
"PluginFormat",
|
||||
"PluginInfo",
|
||||
"PluginMeta",
|
||||
"PluginPort",
|
||||
"PluginStatus",
|
||||
"PluginBundle",
|
||||
# Scanner
|
||||
"scan_all",
|
||||
"scan_format",
|
||||
"scan_lv2",
|
||||
"scan_vst3",
|
||||
"scan_ladspa",
|
||||
"scan_nam",
|
||||
# Registry
|
||||
"PluginRegistry",
|
||||
# Blacklist
|
||||
"PluginBlacklist",
|
||||
"BlacklistEntry",
|
||||
"BUILTIN_BLACKLIST",
|
||||
# Categories
|
||||
"classify",
|
||||
"enrich_plugin",
|
||||
"enrich_batch",
|
||||
"CATEGORY_LABELS",
|
||||
"CATEGORY_GROUPS",
|
||||
# NAM
|
||||
"NAMManager",
|
||||
"NAMModelMeta",
|
||||
# Manager
|
||||
"PluginManager",
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Plugin blacklist — known-broken plugins that should not be loaded.
|
||||
|
||||
Maintains a curated list of plugins known to crash, cause xruns, or produce
|
||||
unusable output on Raspberry Pi 4B. The blacklist is pattern-based: each
|
||||
entry matches against plugin URI, name, or bundle path using a glob-like syntax.
|
||||
|
||||
Built-in entries are shipped with the code; users can extend via
|
||||
~/.config/rpi-mixer/blacklist.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BLACKLIST_PATH = Path.home() / ".config" / "rpi-mixer" / "blacklist.json"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BlacklistEntry:
|
||||
"""A single blacklist rule."""
|
||||
pattern: str # URI / name / path pattern (glob)
|
||||
field: str = "uri" # Field to match against: uri, name, path
|
||||
reason: str = "" # Human-readable explanation
|
||||
severity: str = "block" # block (hard) or warn (advisory)
|
||||
added_by: str = "builtin" # builtin or user
|
||||
added_at: float = 0.0 # Unix timestamp
|
||||
|
||||
|
||||
# ── Built-in blacklist ───────────────────────────────────────────────────────
|
||||
|
||||
BUILTIN_BLACKLIST: list[BlacklistEntry] = [
|
||||
# ── Known RPi4B-incompatible plugins ──────────────────────────────────
|
||||
|
||||
# Heavy neural plugins that require x86-only libraries
|
||||
BlacklistEntry(
|
||||
pattern="*NeuralAmpModeler*",
|
||||
field="name",
|
||||
reason="NAM LV2 standard models cause guaranteed xruns on RPi4B. "
|
||||
"Use nano/feather models only. Standard models require 35%+ CPU "
|
||||
"at single instance and are unbounded at 128f buffer.",
|
||||
severity="warn",
|
||||
),
|
||||
BlacklistEntry(
|
||||
pattern="urn:nam:*",
|
||||
field="uri",
|
||||
reason="Auto-scanned NAM models are size-checked at scan time; "
|
||||
"standard models will be flagged rpi4b_known_broken. "
|
||||
"Only nano/feather models are loadable on RPi4B.",
|
||||
severity="warn",
|
||||
),
|
||||
|
||||
# Plugins that depend on x86 SIMD (SSE/AVX)
|
||||
BlacklistEntry(
|
||||
pattern="*Guitarix*",
|
||||
field="name",
|
||||
reason="Guitarix LV2 plugins use hand-optimized x86 SIMD (SSE2/SSE3). "
|
||||
"ARM NEON fallbacks exist but are known to produce incorrect output "
|
||||
"on some builds.",
|
||||
severity="warn",
|
||||
),
|
||||
BlacklistEntry(
|
||||
pattern="*guitarix*",
|
||||
field="path",
|
||||
reason="Guitarix builds may contain x86-optimized code paths.",
|
||||
severity="warn",
|
||||
),
|
||||
|
||||
# Plugins known to crash Carla/JACK
|
||||
BlacklistEntry(
|
||||
pattern="*CarlaRack*",
|
||||
field="name",
|
||||
reason="Carla internal plugins — these are host-internal and should not "
|
||||
"be loaded as standalone plugins.",
|
||||
severity="block",
|
||||
),
|
||||
BlacklistEntry(
|
||||
pattern="*carla*internal*",
|
||||
field="path",
|
||||
reason="Carla internal plugin directory — do not scan.",
|
||||
severity="block",
|
||||
),
|
||||
|
||||
# 32-bit plugins on 64-bit system
|
||||
BlacklistEntry(
|
||||
pattern="*/i386-linux-gnu/*",
|
||||
field="path",
|
||||
reason="32-bit plugin on 64-bit ARM system. Cannot be loaded without "
|
||||
"multiarch bridging (not supported on RPi4B).",
|
||||
severity="block",
|
||||
),
|
||||
BlacklistEntry(
|
||||
pattern="*/lib32/*",
|
||||
field="path",
|
||||
reason="32-bit library directory — incompatible with 64-bit host.",
|
||||
severity="block",
|
||||
),
|
||||
|
||||
# Debug/dev plugins that shouldn't be loaded in production
|
||||
BlacklistEntry(
|
||||
pattern="*lv2#eg-*",
|
||||
field="uri",
|
||||
reason="LV2 example plugins — development reference only.",
|
||||
severity="warn",
|
||||
),
|
||||
BlacklistEntry(
|
||||
pattern="urn:lv2:eg-*",
|
||||
field="uri",
|
||||
reason="LV2 example plugin.",
|
||||
severity="warn",
|
||||
),
|
||||
|
||||
# GUI-only plugins (headless RPi)
|
||||
BlacklistEntry(
|
||||
pattern="*qt5*",
|
||||
field="path",
|
||||
reason="Plugin depends on Qt5 GUI libraries — may fail on headless RPi4B.",
|
||||
severity="warn",
|
||||
),
|
||||
BlacklistEntry(
|
||||
pattern="*qt6*",
|
||||
field="path",
|
||||
reason="Plugin depends on Qt6 GUI libraries — may fail on headless RPi4B.",
|
||||
severity="warn",
|
||||
),
|
||||
|
||||
# Broken known versions
|
||||
BlacklistEntry(
|
||||
pattern="*Calf*VintageDelay*",
|
||||
field="name",
|
||||
reason="Calf Vintage Delay (specific builds) produces NaN output on ARM "
|
||||
"when feedback > 0 due to denormal handling bug.",
|
||||
severity="warn",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PluginBlacklist:
|
||||
"""Manages the blacklist of known-broken plugins.
|
||||
|
||||
Loads built-in entries and overlays user-defined entries from
|
||||
~/.config/rpi-mixer/blacklist.json.
|
||||
"""
|
||||
|
||||
def __init__(self, blacklist_path: str | Path = DEFAULT_BLACKLIST_PATH):
|
||||
self._path = Path(blacklist_path)
|
||||
self._entries: dict[str, BlacklistEntry] = {}
|
||||
self._reload()
|
||||
|
||||
def _reload(self) -> None:
|
||||
"""(Re)load entries from built-in and user sources."""
|
||||
self._entries.clear()
|
||||
|
||||
# Load built-ins
|
||||
for entry in BUILTIN_BLACKLIST:
|
||||
key = f"{entry.field}:{entry.pattern}"
|
||||
self._entries[key] = entry
|
||||
|
||||
# Overlay user entries
|
||||
user_entries = self._load_user_entries()
|
||||
for entry in user_entries:
|
||||
key = f"{entry.field}:{entry.pattern}"
|
||||
self._entries[key] = entry
|
||||
|
||||
logger.debug("Loaded %d blacklist entries", len(self._entries))
|
||||
|
||||
def _load_user_entries(self) -> list[BlacklistEntry]:
|
||||
"""Load user-defined blacklist entries from JSON."""
|
||||
if not self._path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(self._path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load user blacklist: %s", e)
|
||||
return []
|
||||
|
||||
entries: list[BlacklistEntry] = []
|
||||
for item in data.get("entries", []):
|
||||
entries.append(BlacklistEntry(
|
||||
pattern=item.get("pattern", ""),
|
||||
field=item.get("field", "uri"),
|
||||
reason=item.get("reason", ""),
|
||||
severity=item.get("severity", "block"),
|
||||
added_by="user",
|
||||
added_at=item.get("added_at", 0.0),
|
||||
))
|
||||
return entries
|
||||
|
||||
def check(self, uri: str, name: str, path: str = "") -> tuple[bool, list[BlacklistEntry]]:
|
||||
"""Check if a plugin is blacklisted.
|
||||
|
||||
Returns (is_blacklisted, matching_entries).
|
||||
"""
|
||||
field_values = {
|
||||
"uri": uri,
|
||||
"name": name,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
matches: list[BlacklistEntry] = []
|
||||
for entry in self._entries.values():
|
||||
value = field_values.get(entry.field, "")
|
||||
if not value:
|
||||
continue
|
||||
if fnmatch.fnmatch(value, entry.pattern):
|
||||
matches.append(entry)
|
||||
|
||||
return len(matches) > 0, matches
|
||||
|
||||
def is_blocked(self, uri: str, name: str, path: str = "") -> bool:
|
||||
"""Quick check: returns True if the plugin has any 'block' severity match."""
|
||||
blocked, matches = self.check(uri, name, path)
|
||||
if not blocked:
|
||||
return False
|
||||
return any(m.severity == "block" for m in matches)
|
||||
|
||||
def has_warnings(self, uri: str, name: str, path: str = "") -> list[BlacklistEntry]:
|
||||
"""Return advisory warning entries (not hard blocks)."""
|
||||
_, matches = self.check(uri, name, path)
|
||||
return [m for m in matches if m.severity == "warn"]
|
||||
|
||||
def add_user_entry(self, entry: BlacklistEntry) -> None:
|
||||
"""Add a user-defined blacklist entry and persist."""
|
||||
key = f"{entry.field}:{entry.pattern}"
|
||||
entry.added_by = "user"
|
||||
self._entries[key] = entry
|
||||
self._save_user_entries()
|
||||
|
||||
def remove_user_entry(self, pattern: str, field: str = "uri") -> bool:
|
||||
"""Remove a user-defined blacklist entry. Cannot remove built-ins."""
|
||||
key = f"{field}:{pattern}"
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return False
|
||||
if entry.added_by == "builtin":
|
||||
logger.warning("Cannot remove built-in blacklist entry: %s", key)
|
||||
return False
|
||||
del self._entries[key]
|
||||
self._save_user_entries()
|
||||
return True
|
||||
|
||||
def _save_user_entries(self) -> None:
|
||||
"""Persist user-defined entries to JSON."""
|
||||
user_entries = [
|
||||
{
|
||||
"pattern": e.pattern,
|
||||
"field": e.field,
|
||||
"reason": e.reason,
|
||||
"severity": e.severity,
|
||||
"added_at": e.added_at,
|
||||
}
|
||||
for e in self._entries.values()
|
||||
if e.added_by == "user"
|
||||
]
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._path.write_text(json.dumps(
|
||||
{"version": 1, "entries": user_entries},
|
||||
indent=2,
|
||||
))
|
||||
|
||||
def list_all(self) -> list[BlacklistEntry]:
|
||||
"""Return all current blacklist entries."""
|
||||
return sorted(self._entries.values(), key=lambda e: (e.field, e.pattern))
|
||||
|
||||
def list_builtin(self) -> list[BlacklistEntry]:
|
||||
"""Return only built-in entries."""
|
||||
return [e for e in self._entries.values() if e.added_by == "builtin"]
|
||||
|
||||
def list_user(self) -> list[BlacklistEntry]:
|
||||
"""Return only user-defined entries."""
|
||||
return [e for e in self._entries.values() if e.added_by == "user"]
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Category tagging engine — heuristic classification for plugins.
|
||||
|
||||
While the scanner extracts category information from plugin manifests
|
||||
(LV2 class URIs, VST3 subcategories), many plugins provide incomplete
|
||||
or missing category data. This module provides a secondary classification
|
||||
engine that uses keyword heuristics on plugin names, descriptions, and
|
||||
paths to assign categories.
|
||||
|
||||
Also provides the canonical category taxonomy and human-readable labels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from .types import PluginCategory, PluginInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Category metadata ───────────────────────────────────────────────────────
|
||||
|
||||
CATEGORY_LABELS: dict[PluginCategory, str] = {
|
||||
PluginCategory.AMP: "Amplifier",
|
||||
PluginCategory.AMP_BASS: "Bass Amp",
|
||||
PluginCategory.AMP_GUITAR: "Guitar Amp",
|
||||
PluginCategory.CABINET: "Cabinet Sim",
|
||||
PluginCategory.IR_LOADER: "IR Loader",
|
||||
PluginCategory.REVERB: "Reverb",
|
||||
PluginCategory.DELAY: "Delay",
|
||||
PluginCategory.CHORUS: "Chorus",
|
||||
PluginCategory.FLANGER: "Flanger",
|
||||
PluginCategory.PHASER: "Phaser",
|
||||
PluginCategory.TREMOLO: "Tremolo",
|
||||
PluginCategory.VIBRATO: "Vibrato",
|
||||
PluginCategory.COMPRESSOR: "Compressor",
|
||||
PluginCategory.LIMITER: "Limiter",
|
||||
PluginCategory.GATE: "Noise Gate",
|
||||
PluginCategory.EXPANDER: "Expander",
|
||||
PluginCategory.EQ_PARAMETRIC: "Parametric EQ",
|
||||
PluginCategory.EQ_GRAPHIC: "Graphic EQ",
|
||||
PluginCategory.EQ_SHELVING: "Shelving EQ",
|
||||
PluginCategory.FILTER: "Filter",
|
||||
PluginCategory.DISTORTION: "Distortion",
|
||||
PluginCategory.OVERDRIVE: "Overdrive",
|
||||
PluginCategory.FUZZ: "Fuzz",
|
||||
PluginCategory.PITCH_SHIFTER: "Pitch Shifter",
|
||||
PluginCategory.OCTAVER: "Octaver",
|
||||
PluginCategory.WAH: "Wah",
|
||||
PluginCategory.SYNTH: "Synthesizer",
|
||||
PluginCategory.SAMPLER: "Sampler",
|
||||
PluginCategory.DRUM_MACHINE: "Drum Machine",
|
||||
PluginCategory.UTILITY: "Utility",
|
||||
PluginCategory.METER: "Meter",
|
||||
PluginCategory.ANALYZER: "Analyzer",
|
||||
PluginCategory.SPATIAL: "Spatial",
|
||||
PluginCategory.NOISE_REDUCTION: "Noise Reduction",
|
||||
PluginCategory.NAM_PROFILE: "NAM Profile",
|
||||
PluginCategory.OTHER: "Other",
|
||||
PluginCategory.UNKNOWN: "Unknown",
|
||||
}
|
||||
|
||||
CATEGORY_GROUPS: dict[str, list[PluginCategory]] = {
|
||||
"amp_modeling": [
|
||||
PluginCategory.AMP, PluginCategory.AMP_BASS, PluginCategory.AMP_GUITAR,
|
||||
PluginCategory.CABINET, PluginCategory.IR_LOADER, PluginCategory.NAM_PROFILE,
|
||||
],
|
||||
"reverb_delay": [
|
||||
PluginCategory.REVERB, PluginCategory.DELAY,
|
||||
],
|
||||
"modulation": [
|
||||
PluginCategory.CHORUS, PluginCategory.FLANGER, PluginCategory.PHASER,
|
||||
PluginCategory.TREMOLO, PluginCategory.VIBRATO,
|
||||
],
|
||||
"dynamics": [
|
||||
PluginCategory.COMPRESSOR, PluginCategory.LIMITER,
|
||||
PluginCategory.GATE, PluginCategory.EXPANDER,
|
||||
],
|
||||
"eq_filter": [
|
||||
PluginCategory.EQ_PARAMETRIC, PluginCategory.EQ_GRAPHIC,
|
||||
PluginCategory.EQ_SHELVING, PluginCategory.FILTER,
|
||||
],
|
||||
"distortion": [
|
||||
PluginCategory.DISTORTION, PluginCategory.OVERDRIVE, PluginCategory.FUZZ,
|
||||
],
|
||||
"pitch": [
|
||||
PluginCategory.PITCH_SHIFTER, PluginCategory.OCTAVER, PluginCategory.WAH,
|
||||
],
|
||||
"instruments": [
|
||||
PluginCategory.SYNTH, PluginCategory.SAMPLER, PluginCategory.DRUM_MACHINE,
|
||||
],
|
||||
"analysis": [
|
||||
PluginCategory.METER, PluginCategory.ANALYZER,
|
||||
],
|
||||
"utility": [
|
||||
PluginCategory.UTILITY, PluginCategory.SPATIAL, PluginCategory.NOISE_REDUCTION,
|
||||
],
|
||||
}
|
||||
|
||||
# ── Keyword-to-category mapping ─────────────────────────────────────────────
|
||||
|
||||
CATEGORY_KEYWORDS: list[tuple[list[str], PluginCategory]] = [
|
||||
# Amp modeling
|
||||
(["amp", "amplifier", "ampli", "guitar_amp", "bass_amp", "preamp",
|
||||
"tube", "valve", "head", "combo", "gain_amp"], PluginCategory.AMP),
|
||||
(["bass_amp", "bass_preamp"], PluginCategory.AMP_BASS),
|
||||
(["guitar_amp", "guitar_preamp", "geetar"], PluginCategory.AMP_GUITAR),
|
||||
(["cab", "cabinet", "cab_sim", "speaker_sim", "speaker_cab",
|
||||
"ir_loader", "ir_conv", "impulse", "convolution"], PluginCategory.IR_LOADER),
|
||||
|
||||
# Reverb / Delay
|
||||
(["reverb", "rev", "hall", "plate", "room", "spring", "shimmer",
|
||||
"ambience", "early_reflections"], PluginCategory.REVERB),
|
||||
(["delay", "echo", "tape_echo", "dub_delay", "ping_pong",
|
||||
"delay_line", "bucket_brigade", "bdd"], PluginCategory.DELAY),
|
||||
|
||||
# Modulation
|
||||
(["chorus", "ensemble", "choir_effect"], PluginCategory.CHORUS),
|
||||
(["flanger", "flange", "jet"], PluginCategory.FLANGER),
|
||||
(["phaser", "phase_shifter", "phase_90", "small_stone"], PluginCategory.PHASER),
|
||||
(["tremolo", "trem", "tremolo_panner", "harmonic_trem"], PluginCategory.TREMOLO),
|
||||
(["vibrato", "vibe", "univibe"], PluginCategory.VIBRATO),
|
||||
|
||||
# Dynamics
|
||||
(["compressor", "comp", "compression", "dynamics_processor",
|
||||
"leveling_amplifier", "leveller", "1176", "la2a", "vca_comp",
|
||||
"optical_comp", "fet_comp"], PluginCategory.COMPRESSOR),
|
||||
(["limiter", "limit", "maximizer", "maximiser", "brickwall",
|
||||
"clipper", "soft_clip"], PluginCategory.LIMITER),
|
||||
(["gate", "noise_gate", "expander", "downward_expander",
|
||||
"transient_designer", "transient_shaper"], PluginCategory.GATE),
|
||||
(["expander"], PluginCategory.EXPANDER),
|
||||
|
||||
# EQ / Filter
|
||||
(["eq", "equalizer", "equaliser", "parametric", "param_eq",
|
||||
"paragraphic", "band_eq", "tone_control", "tone_stack"], PluginCategory.EQ_PARAMETRIC),
|
||||
(["graphic_eq", "geq", "graphic_eq_31"], PluginCategory.EQ_GRAPHIC),
|
||||
(["shelving", "shelving_eq", "high_shelf", "low_shelf", "tilt_eq"], PluginCategory.EQ_SHELVING),
|
||||
(["filter", "lpf", "hpf", "bpf", "notch", "lowpass", "highpass",
|
||||
"bandpass", "moog_filter", "ladder_filter", "state_variable",
|
||||
"svf", "comb_filter", "allpass"], PluginCategory.FILTER),
|
||||
|
||||
# Distortion
|
||||
(["distortion", "dist", "saturation", "saturator", "exciter",
|
||||
"harmonic_exciter", "enhancer", "crusher", "bit_crusher",
|
||||
"sample_rate_reducer", "degradation", "waveshaper"], PluginCategory.DISTORTION),
|
||||
(["overdrive", "od", "tube_screamer", "ts808", "ts9", "klon",
|
||||
"centaur", "blues_driver", "sd1", "timmy"], PluginCategory.OVERDRIVE),
|
||||
(["fuzz", "fuzz_face", "big_muff", "tone_bender", "super_fuzz",
|
||||
"octave_fuzz", "gated_fuzz"], PluginCategory.FUZZ),
|
||||
|
||||
# Pitch
|
||||
(["pitch_shifter", "pitch_shift", "harmonizer", "harmoniser",
|
||||
"whammy", "pitch_bend", "transpose", "detune",
|
||||
"microshift", "harmony"], PluginCategory.PITCH_SHIFTER),
|
||||
(["octaver", "octave", "octave_down", "octave_up", "sub_octave",
|
||||
"pog", "octron"], PluginCategory.OCTAVER),
|
||||
(["wah", "wah_wah", "auto_wah", "envelope_filter",
|
||||
"cry_baby", "vox_wah", "morley"], PluginCategory.WAH),
|
||||
|
||||
# Instruments
|
||||
(["synth", "synthesizer", "synthesiser", "oscillator", "osc",
|
||||
"wave_table", "fm_synth", "subtractive", "additive",
|
||||
"granular", "physical_modeling", "string_machine"], PluginCategory.SYNTH),
|
||||
(["sampler", "sample_player", "sample_playback", "multi_sample"], PluginCategory.SAMPLER),
|
||||
(["drum", "drum_machine", "beatbox", "drum_synth", "kick",
|
||||
"snare", "hihat", "percussion"], PluginCategory.DRUM_MACHINE),
|
||||
|
||||
# Analysis / Metering
|
||||
(["meter", "vu", "level_meter", "peak_meter", "rms_meter",
|
||||
"loudness", "lufs", "k_meter", "ppm", "correlation",
|
||||
"goniometer", "phase_meter", "vectorscope"], PluginCategory.METER),
|
||||
(["analyzer", "analyser", "spectrum", "spectrogram", "sonogram",
|
||||
"fft", "frequency_analyzer", "tuner", "tune", "oscilloscope"], PluginCategory.ANALYZER),
|
||||
|
||||
# Utility
|
||||
(["utility", "gain", "trim", "volume", "fader", "pan", "balance",
|
||||
"mute", "solo", "phase", "polarity", "invert",
|
||||
"splitter", "merger", "matrix", "router", "dc_offset",
|
||||
"noise_generator", "tone_generator", "sine_wave"], PluginCategory.UTILITY),
|
||||
|
||||
# Spatial
|
||||
(["spatial", "stereo", "width", "stereo_width", "imager",
|
||||
"stereo_imager", "mid_side", "ms", "haas",
|
||||
"panner", "auto_pan", "binaural", "spatializer"], PluginCategory.SPATIAL),
|
||||
|
||||
# Noise Reduction
|
||||
(["noise", "denoiser", "noise_reduction", "nr", "noise_gate_2",
|
||||
"noise_suppression", "de_noise", "hiss", "hum_removal",
|
||||
"declick", "decrackle", "de_esser", "de_ess"], PluginCategory.NOISE_REDUCTION),
|
||||
]
|
||||
|
||||
|
||||
def classify(
|
||||
name: str,
|
||||
description: str = "",
|
||||
uri: str = "",
|
||||
path: str = "",
|
||||
existing_categories: list[PluginCategory] | None = None,
|
||||
) -> list[PluginCategory]:
|
||||
"""Classify a plugin into categories using keyword heuristics.
|
||||
|
||||
If existing_categories are provided and contain anything other than
|
||||
UNKNOWN/OTHER, they are returned as-is (manifest-based categories
|
||||
take priority). Otherwise, heuristics are applied.
|
||||
|
||||
Args:
|
||||
name: Plugin display name.
|
||||
description: Plugin description (optional).
|
||||
uri: Plugin URI (optional, for additional signal).
|
||||
path: Plugin bundle path (optional).
|
||||
existing_categories: Already-known categories from manifest parsing.
|
||||
|
||||
Returns:
|
||||
List of PluginCategory values (may be empty, single, or multiple).
|
||||
"""
|
||||
# If we have high-confidence categories from manifest, use those
|
||||
if existing_categories and not all(
|
||||
c in (PluginCategory.UNKNOWN, PluginCategory.OTHER)
|
||||
for c in existing_categories
|
||||
):
|
||||
return existing_categories
|
||||
|
||||
# Build search corpus
|
||||
corpus = f"{name.lower()} {description.lower()} {uri.lower()} {Path(path).name.lower()}"
|
||||
|
||||
# Split into tokens for better matching
|
||||
tokens = set(corpus.replace("_", " ").replace("-", " ").split())
|
||||
|
||||
matched: list[PluginCategory] = []
|
||||
|
||||
for keywords, category in CATEGORY_KEYWORDS:
|
||||
# Check for keyword presence in the corpus
|
||||
if any(kw in corpus for kw in keywords):
|
||||
matched.append(category)
|
||||
continue
|
||||
# Also check token-level matching for compound words
|
||||
for kw in keywords:
|
||||
kw_tokens = set(kw.split("_"))
|
||||
if kw_tokens and kw_tokens.issubset(tokens):
|
||||
matched.append(category)
|
||||
break
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen: set[PluginCategory] = set()
|
||||
unique: list[PluginCategory] = []
|
||||
for cat in matched:
|
||||
if cat not in seen:
|
||||
seen.add(cat)
|
||||
unique.append(cat)
|
||||
|
||||
# Limit to most specific subcategories
|
||||
# e.g., if AMP and AMP_GUITAR are both matched, prefer AMP_GUITAR
|
||||
unique = _prefer_specific(unique)
|
||||
|
||||
if not unique:
|
||||
unique = [PluginCategory.UNKNOWN]
|
||||
|
||||
return unique
|
||||
|
||||
|
||||
def _prefer_specific(categories: list[PluginCategory]) -> list[PluginCategory]:
|
||||
"""Prefer more specific subcategories over generic ones.
|
||||
|
||||
Example: [AMP, AMP_GUITAR] -> [AMP_GUITAR]
|
||||
"""
|
||||
# Parent-child relationships
|
||||
specificity: dict[PluginCategory, PluginCategory | None] = {
|
||||
PluginCategory.AMP_BASS: PluginCategory.AMP,
|
||||
PluginCategory.AMP_GUITAR: PluginCategory.AMP,
|
||||
PluginCategory.OVERDRIVE: PluginCategory.DISTORTION,
|
||||
PluginCategory.FUZZ: PluginCategory.DISTORTION,
|
||||
PluginCategory.EQ_GRAPHIC: PluginCategory.EQ_PARAMETRIC,
|
||||
PluginCategory.EQ_SHELVING: PluginCategory.EQ_PARAMETRIC,
|
||||
}
|
||||
|
||||
to_remove: set[PluginCategory] = set()
|
||||
for cat in categories:
|
||||
parent = specificity.get(cat)
|
||||
if parent and parent in categories:
|
||||
to_remove.add(parent)
|
||||
|
||||
return [c for c in categories if c not in to_remove]
|
||||
|
||||
|
||||
def enrich_plugin(plugin: PluginInfo) -> PluginInfo:
|
||||
"""Enrich a PluginInfo's categories using heuristic classification.
|
||||
|
||||
If the plugin already has meaningful categories (not UNKNOWN/OTHER),
|
||||
they are left alone. Otherwise, the name, description, URI, and path
|
||||
are used to assign categories.
|
||||
|
||||
Returns the same PluginInfo instance (mutated in place).
|
||||
"""
|
||||
new_cats = classify(
|
||||
name=plugin.meta.name,
|
||||
description=plugin.meta.description,
|
||||
uri=plugin.meta.uri,
|
||||
path=plugin.bundle_path or plugin.library_path,
|
||||
existing_categories=plugin.categories,
|
||||
)
|
||||
|
||||
if new_cats != plugin.categories:
|
||||
logger.debug(
|
||||
"Reclassified %s: %s → %s",
|
||||
plugin.meta.name,
|
||||
plugin.categories,
|
||||
new_cats,
|
||||
)
|
||||
plugin.categories = new_cats
|
||||
|
||||
return plugin
|
||||
|
||||
|
||||
def enrich_batch(plugins: list[PluginInfo]) -> list[PluginInfo]:
|
||||
"""Enrich categories for a batch of plugins."""
|
||||
for plugin in plugins:
|
||||
enrich_plugin(plugin)
|
||||
return plugins
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Plugin manager — install, remove, update plugin bundles.
|
||||
|
||||
Coordinates between the scanner, registry, blacklist, and category
|
||||
classifier to provide a unified plugin lifecycle API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .blacklist import PluginBlacklist
|
||||
from .categories import enrich_plugin
|
||||
from .nam import NAMManager, NAMModelMeta
|
||||
from .registry import PluginRegistry
|
||||
from .scanner import scan_all, scan_format
|
||||
from .types import (
|
||||
PluginBundle,
|
||||
PluginCategory,
|
||||
PluginFormat,
|
||||
PluginInfo,
|
||||
PluginStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Top-level plugin lifecycle manager.
|
||||
|
||||
Provides a unified interface for:
|
||||
- Scanning filesystem for plugins
|
||||
- Registering plugins in the SQLite database
|
||||
- Installing/removing/updating plugin bundles
|
||||
- Blacklist checking
|
||||
- Category enrichment
|
||||
- NAM model management
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: PluginRegistry | None = None,
|
||||
blacklist: PluginBlacklist | None = None,
|
||||
nam_manager: NAMManager | None = None,
|
||||
):
|
||||
self.registry = registry or PluginRegistry()
|
||||
self.blacklist = blacklist or PluginBlacklist()
|
||||
self.nam = nam_manager or NAMManager()
|
||||
|
||||
# ── Scan & Sync ─────────────────────────────────────────────────────────
|
||||
|
||||
def scan(self) -> list[PluginInfo]:
|
||||
"""Run a full filesystem scan and return discovered plugins."""
|
||||
plugins = scan_all()
|
||||
|
||||
# Enrich categories
|
||||
for plugin in plugins:
|
||||
enrich_plugin(plugin)
|
||||
|
||||
# Apply blacklist
|
||||
for plugin in plugins:
|
||||
if self.blacklist.is_blocked(
|
||||
plugin.meta.uri, plugin.meta.name, plugin.bundle_path
|
||||
):
|
||||
plugin.status = PluginStatus.BLACKLISTED
|
||||
|
||||
# Apply size-based NAM filtering
|
||||
for plugin in plugins:
|
||||
if plugin.meta.format == PluginFormat.NAM:
|
||||
if plugin.nam_model_size == "standard":
|
||||
plugin.status = PluginStatus.DISABLED
|
||||
plugin.error_message = (
|
||||
"Standard NAM models are known to cause xruns on RPi4B. "
|
||||
"Use nano or feather models instead."
|
||||
)
|
||||
|
||||
return plugins
|
||||
|
||||
def sync(self) -> dict:
|
||||
"""Scan and synchronize the registry.
|
||||
|
||||
Returns the sync result dict: {inserted, updated, stale}.
|
||||
"""
|
||||
plugins = self.scan()
|
||||
result = self.registry.sync_from_scan(plugins)
|
||||
return result
|
||||
|
||||
# ── Install ─────────────────────────────────────────────────────────────
|
||||
|
||||
def install_bundle(self, bundle: PluginBundle) -> PluginInfo | None:
|
||||
"""Install a plugin from a bundle descriptor.
|
||||
|
||||
Downloads the bundle, extracts it, optionally runs a build script,
|
||||
and registers the resulting plugin.
|
||||
|
||||
Returns the PluginInfo if successful, None on failure.
|
||||
"""
|
||||
logger.info("Installing plugin bundle: %s %s", bundle.name, bundle.version)
|
||||
|
||||
# Determine install path based on format
|
||||
format_install_dirs = {
|
||||
PluginFormat.LV2: Path("/usr/local/lib/lv2"),
|
||||
PluginFormat.VST3: Path("/usr/local/lib/vst3"),
|
||||
PluginFormat.LADSPA: Path("/usr/local/lib/ladspa"),
|
||||
PluginFormat.VST2: Path("/usr/local/lib/vst"),
|
||||
}
|
||||
install_dir = format_install_dirs.get(bundle.format, Path.home() / ".lv2")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="rpi-mixer-install-") as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
archive_path = tmp / f"{bundle.name}.{bundle.source_type}"
|
||||
|
||||
# Download
|
||||
try:
|
||||
self._download_file(bundle.source_url, archive_path)
|
||||
except Exception as e:
|
||||
logger.error("Download failed for %s: %s", bundle.name, e)
|
||||
return None
|
||||
|
||||
# Verify checksum
|
||||
if bundle.checksum_sha256:
|
||||
import hashlib
|
||||
actual = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
||||
if actual.lower() != bundle.checksum_sha256.lower():
|
||||
logger.error("Checksum mismatch for %s", bundle.name)
|
||||
return None
|
||||
|
||||
# Extract
|
||||
extract_dir = tmp / "extracted"
|
||||
extract_dir.mkdir()
|
||||
try:
|
||||
self._extract(archive_path, extract_dir)
|
||||
except Exception as e:
|
||||
logger.error("Extraction failed for %s: %s", bundle.name, e)
|
||||
return None
|
||||
|
||||
# Run build script if specified
|
||||
if bundle.build_script:
|
||||
build_script_path = extract_dir / bundle.build_script
|
||||
if build_script_path.exists():
|
||||
try:
|
||||
subprocess.run(
|
||||
["bash", str(build_script_path)],
|
||||
cwd=str(extract_dir),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("Build failed for %s: %s\n%s", bundle.name, e, e.stderr)
|
||||
return None
|
||||
|
||||
# Copy files to install destination
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if bundle.install_paths:
|
||||
for src_rel, dest_rel in bundle.install_paths.items():
|
||||
src = extract_dir / src_rel
|
||||
dest = install_dir / dest_rel
|
||||
if src.is_dir():
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(src, dest)
|
||||
else:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
else:
|
||||
# Default: copy everything to install dir
|
||||
bundle_name = extract_dir.name
|
||||
dest = install_dir / bundle_name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(extract_dir, dest)
|
||||
|
||||
# After install, re-scan to pick up the new plugin
|
||||
plugins = scan_format(bundle.format)
|
||||
for plugin in plugins:
|
||||
if plugin.meta.name.lower() == bundle.name.lower():
|
||||
enrich_plugin(plugin)
|
||||
self.registry.upsert(plugin)
|
||||
logger.info("Installed and registered: %s", plugin.meta.name)
|
||||
return plugin
|
||||
|
||||
logger.warning("Plugin %s installed but not detected by scan", bundle.name)
|
||||
return None
|
||||
|
||||
def install_local(self, source_path: str | Path, format: PluginFormat) -> PluginInfo | None:
|
||||
"""Install a locally-available plugin by copying it to the appropriate directory."""
|
||||
src = Path(source_path)
|
||||
if not src.exists():
|
||||
logger.error("Source not found: %s", src)
|
||||
return None
|
||||
|
||||
format_install_dirs = {
|
||||
PluginFormat.LV2: Path("/usr/local/lib/lv2"),
|
||||
PluginFormat.VST3: Path("/usr/local/lib/vst3"),
|
||||
PluginFormat.LADSPA: Path("/usr/local/lib/ladspa"),
|
||||
}
|
||||
install_dir = format_install_dirs.get(
|
||||
format, Path.home() / f".{format.value}"
|
||||
)
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if src.is_dir():
|
||||
dest = install_dir / src.name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(src, dest)
|
||||
else:
|
||||
dest = install_dir / src.name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
logger.info("Installed local plugin to %s", dest)
|
||||
|
||||
# Re-scan
|
||||
plugins = scan_format(format)
|
||||
for plugin in plugins:
|
||||
if str(dest) in plugin.bundle_path or plugin.bundle_path == str(dest):
|
||||
enrich_plugin(plugin)
|
||||
self.registry.upsert(plugin)
|
||||
return plugin
|
||||
|
||||
return None
|
||||
|
||||
# ── Remove ──────────────────────────────────────────────────────────────
|
||||
|
||||
def remove(self, uri: str, delete_files: bool = False) -> bool:
|
||||
"""Remove a plugin from the registry.
|
||||
|
||||
If delete_files is True, also removes the plugin files from disk.
|
||||
|
||||
Returns True if the plugin was found and removed.
|
||||
"""
|
||||
plugin = self.registry.get(uri)
|
||||
if plugin is None:
|
||||
logger.warning("Plugin not found in registry: %s", uri)
|
||||
return False
|
||||
|
||||
if delete_files and plugin.bundle_path:
|
||||
bundle = Path(plugin.bundle_path)
|
||||
if bundle.exists():
|
||||
try:
|
||||
if bundle.is_dir():
|
||||
shutil.rmtree(bundle)
|
||||
else:
|
||||
bundle.unlink()
|
||||
logger.info("Deleted plugin files: %s", bundle)
|
||||
except OSError as e:
|
||||
logger.error("Failed to delete plugin files: %s", e)
|
||||
|
||||
return self.registry.delete(uri)
|
||||
|
||||
def remove_by_name(self, name: str, delete_files: bool = False) -> bool:
|
||||
"""Remove a plugin by name."""
|
||||
plugin = self.registry.get_by_name(name)
|
||||
if plugin is None:
|
||||
logger.warning("Plugin not found: %s", name)
|
||||
return False
|
||||
return self.remove(plugin.meta.uri, delete_files)
|
||||
|
||||
# ── Update ──────────────────────────────────────────────────────────────
|
||||
|
||||
def update(self, uri: str) -> PluginInfo | None:
|
||||
"""Refresh a plugin's metadata by re-scanning it.
|
||||
|
||||
Returns the updated PluginInfo, or None if the plugin is gone.
|
||||
"""
|
||||
plugin = self.registry.get(uri)
|
||||
if plugin is None:
|
||||
logger.warning("Plugin not found: %s", uri)
|
||||
return None
|
||||
|
||||
# Re-scan the format
|
||||
plugins = scan_format(plugin.meta.format)
|
||||
for scanned in plugins:
|
||||
if scanned.meta.uri == uri:
|
||||
enrich_plugin(scanned)
|
||||
scanned.installed_at = plugin.installed_at
|
||||
scanned.updated_at = time.time()
|
||||
|
||||
# Re-check blacklist
|
||||
if self.blacklist.is_blocked(
|
||||
scanned.meta.uri, scanned.meta.name, scanned.bundle_path
|
||||
):
|
||||
scanned.status = PluginStatus.BLACKLISTED
|
||||
|
||||
self.registry.upsert(scanned)
|
||||
return scanned
|
||||
|
||||
# Plugin not found by re-scan → mark stale
|
||||
self.registry.update(uri, status=PluginStatus.STALE.value)
|
||||
logger.info("Plugin %s not found on re-scan, marked stale", uri)
|
||||
return None
|
||||
|
||||
def update_all(self) -> dict:
|
||||
"""Re-scan all installed plugins to refresh metadata.
|
||||
|
||||
Returns {updated, stale_uris}.
|
||||
"""
|
||||
updated_count = 0
|
||||
stale_uris: list[str] = []
|
||||
|
||||
for plugin in self.registry.list_all():
|
||||
result = self.update(plugin.meta.uri)
|
||||
if result:
|
||||
updated_count += 1
|
||||
else:
|
||||
stale_uris.append(plugin.meta.uri)
|
||||
|
||||
return {"updated": updated_count, "stale": stale_uris}
|
||||
|
||||
# ── Blacklist management ────────────────────────────────────────────────
|
||||
|
||||
def blacklist_plugin(self, uri: str) -> bool:
|
||||
"""Mark a plugin as blacklisted."""
|
||||
return self.registry.update(uri, status=PluginStatus.BLACKLISTED.value)
|
||||
|
||||
def unblacklist_plugin(self, uri: str) -> bool:
|
||||
"""Restore a blacklisted plugin to active status."""
|
||||
return self.registry.update(uri, status=PluginStatus.ACTIVE.value)
|
||||
|
||||
def enable_plugin(self, uri: str) -> bool:
|
||||
"""Enable a disabled plugin."""
|
||||
return self.registry.update(uri, status=PluginStatus.ACTIVE.value)
|
||||
|
||||
def disable_plugin(self, uri: str) -> bool:
|
||||
"""Disable a plugin without removing it."""
|
||||
return self.registry.update(uri, status=PluginStatus.DISABLED.value)
|
||||
|
||||
# ── NAM model management ────────────────────────────────────────────────
|
||||
|
||||
def nam_download(self, url: str, name: str | None = None) -> PluginInfo | None:
|
||||
"""Download a NAM model and register it."""
|
||||
path = self.nam.download(url, name)
|
||||
if path is None:
|
||||
return None
|
||||
|
||||
meta = self.nam.get_model(path.stem)
|
||||
if meta is None:
|
||||
return None
|
||||
|
||||
info = self.nam.to_plugin_info(meta)
|
||||
enrich_plugin(info)
|
||||
self.registry.upsert(info)
|
||||
return info
|
||||
|
||||
def nam_install_local(self, source: str, name: str | None = None) -> PluginInfo | None:
|
||||
"""Install a local .nam file and register it."""
|
||||
path = self.nam.install_local(source, name)
|
||||
if path is None:
|
||||
return None
|
||||
|
||||
meta = self.nam.get_model(path.stem)
|
||||
if meta is None:
|
||||
return None
|
||||
|
||||
info = self.nam.to_plugin_info(meta)
|
||||
enrich_plugin(info)
|
||||
self.registry.upsert(info)
|
||||
return info
|
||||
|
||||
def nam_remove(self, name: str) -> bool:
|
||||
"""Remove a NAM model by name."""
|
||||
# Remove from registry
|
||||
model_path = self.nam._model_path(name)
|
||||
sha = __import__("hashlib").sha256(str(model_path).encode()).hexdigest()[:16]
|
||||
uri = f"urn:nam:{name}:{sha}"
|
||||
self.registry.delete(uri)
|
||||
|
||||
# Remove from disk
|
||||
return self.nam.remove(name)
|
||||
|
||||
def nam_list(self) -> list[PluginInfo]:
|
||||
"""List all installed NAM models as PluginInfo objects."""
|
||||
return self.registry.list_nam_models()
|
||||
|
||||
# ── Query helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def list_plugins(
|
||||
self,
|
||||
format: PluginFormat | None = None,
|
||||
category: PluginCategory | None = None,
|
||||
loadable_only: bool = False,
|
||||
) -> list[PluginInfo]:
|
||||
"""Query plugins with optional filters."""
|
||||
if format:
|
||||
plugins = self.registry.list_by_format(format)
|
||||
elif category:
|
||||
plugins = self.registry.list_by_category(category)
|
||||
else:
|
||||
plugins = self.registry.list_all()
|
||||
|
||||
if loadable_only:
|
||||
plugins = [p for p in plugins if p.is_loadable]
|
||||
|
||||
return plugins
|
||||
|
||||
def search(self, query: str) -> list[PluginInfo]:
|
||||
"""Search plugins by name, description, or author."""
|
||||
return self.registry.search(query)
|
||||
|
||||
# ── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _download_file(url: str, dest: Path) -> None:
|
||||
"""Download a file from a URL to a local path."""
|
||||
from urllib.request import urlopen, Request
|
||||
req = Request(url, headers={"User-Agent": "rpi-mixer-plugin-mgr/1.0"})
|
||||
with urlopen(req, timeout=300) as response:
|
||||
dest.write_bytes(response.read())
|
||||
|
||||
@staticmethod
|
||||
def _extract(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract an archive (tar.gz, tar.bz2, tar.xz, zip) to a directory."""
|
||||
fname = archive_path.name.lower()
|
||||
if fname.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
|
||||
with tarfile.open(archive_path) as tar:
|
||||
tar.extractall(path=dest_dir)
|
||||
elif fname.endswith(".zip"):
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
zf.extractall(dest_dir)
|
||||
else:
|
||||
# Try tar first, then zip
|
||||
try:
|
||||
with tarfile.open(archive_path) as tar:
|
||||
tar.extractall(path=dest_dir)
|
||||
except tarfile.ReadError:
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
zf.extractall(dest_dir)
|
||||
except zipfile.BadZipFile:
|
||||
raise ValueError(f"Cannot extract {archive_path}: unknown format")
|
||||
@@ -0,0 +1,435 @@
|
||||
"""NAM (Neural Amp Modeler) model management.
|
||||
|
||||
Handles downloading, installing, and managing .nam model files
|
||||
for use with the NAM LV2 plugin on Raspberry Pi 4B.
|
||||
|
||||
Models are categorized by size (nano/feather/standard/custom) and
|
||||
only nano/feather models are guaranteed to run without xruns on RPi4B.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from .types import PluginCategory, PluginFormat, PluginInfo, PluginMeta, PluginStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAM_DIR = Path.home() / ".config" / "rpi-mixer" / "nam"
|
||||
DEFAULT_NAM_LV2_MODEL_DIR = Path.home() / ".lv2" / "nam-models"
|
||||
|
||||
NAM_MODEL_URLS = {
|
||||
# Known good nano/feather models for RPi4B
|
||||
"nam_nano_example": {
|
||||
"url": "https://github.com/sdatkinson/NeuralAmpModelerModelZoo/raw/main/models/nano/example.nam",
|
||||
"name": "Example Nano",
|
||||
"size": "nano",
|
||||
"author": "NAM Community",
|
||||
"description": "Lightweight example profile — suitable for RPi4B",
|
||||
"sha256": "",
|
||||
},
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class NAMModelMeta:
|
||||
"""Metadata for a NAM model file."""
|
||||
name: str
|
||||
path: str # Local path
|
||||
url: str = "" # Source URL
|
||||
size_category: str = "feather" # nano, feather, standard, custom
|
||||
file_size_bytes: int = 0
|
||||
file_size_mb: float = 0.0
|
||||
author: str = ""
|
||||
description: str = ""
|
||||
version: str = ""
|
||||
sha256: str = ""
|
||||
installed_at: float = 0.0
|
||||
rpi4b_compatible: bool = False
|
||||
|
||||
|
||||
class NAMManager:
|
||||
"""Manages NAM model files — download, install, remove, list."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
models_dir: str | Path = DEFAULT_NAM_DIR,
|
||||
lv2_model_dir: str | Path = DEFAULT_NAM_LV2_MODEL_DIR,
|
||||
):
|
||||
self._models_dir = Path(models_dir)
|
||||
self._lv2_model_dir = Path(lv2_model_dir)
|
||||
self._models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── Path helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _model_path(self, name: str) -> Path:
|
||||
"""Resolve the path for a named model file."""
|
||||
safe_name = name.replace(" ", "_").replace("/", "_")
|
||||
if not safe_name.endswith(".nam"):
|
||||
safe_name += ".nam"
|
||||
return self._models_dir / safe_name
|
||||
|
||||
def _link_lv2_path(self, model_path: Path) -> Path:
|
||||
"""Determine where to symlink the model for NAM LV2 plugin access."""
|
||||
self._lv2_model_dir.mkdir(parents=True, exist_ok=True)
|
||||
return self._lv2_model_dir / model_path.name
|
||||
|
||||
# ── Size classification ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _classify_size(file_path_or_bytes: Path | int) -> str:
|
||||
"""Classify a model by its file size.
|
||||
|
||||
Returns one of: nano (< 1MB), feather (1-10MB), standard (10-50MB), custom (>50MB)
|
||||
"""
|
||||
if isinstance(file_path_or_bytes, Path):
|
||||
size_mb = file_path_or_bytes.stat().st_size / (1024 * 1024)
|
||||
else:
|
||||
size_mb = file_path_or_bytes / (1024 * 1024)
|
||||
if size_mb < 1:
|
||||
return "nano"
|
||||
elif size_mb < 10:
|
||||
return "feather"
|
||||
elif size_mb < 50:
|
||||
return "standard"
|
||||
return "custom"
|
||||
|
||||
# ── Download ────────────────────────────────────────────────────────────
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
name: str | None = None,
|
||||
sha256_expected: str = "",
|
||||
timeout: int = 120,
|
||||
) -> Path | None:
|
||||
"""Download a .nam model file from a URL.
|
||||
|
||||
Args:
|
||||
url: Download URL for the .nam file.
|
||||
name: Local name for the model. Derived from URL if not given.
|
||||
sha256_expected: Optional SHA-256 to verify the download.
|
||||
timeout: HTTP timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file, or None on failure.
|
||||
"""
|
||||
if name is None:
|
||||
# Derive name from URL filename
|
||||
name = url.rstrip("/").split("/")[-1]
|
||||
if name.endswith(".nam"):
|
||||
name = name[:-4]
|
||||
|
||||
dest = self._model_path(name)
|
||||
|
||||
logger.info("Downloading NAM model from %s → %s", url, dest)
|
||||
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "rpi-mixer-nam-manager/1.0"})
|
||||
with urlopen(req, timeout=timeout) as response:
|
||||
data = response.read()
|
||||
except (URLError, HTTPError, OSError) as e:
|
||||
logger.error("Failed to download NAM model from %s: %s", url, e)
|
||||
return None
|
||||
|
||||
# Verify SHA-256 if provided
|
||||
if sha256_expected:
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual.lower() != sha256_expected.lower():
|
||||
logger.error(
|
||||
"SHA-256 mismatch for %s: expected %s, got %s",
|
||||
name, sha256_expected, actual,
|
||||
)
|
||||
return None
|
||||
|
||||
# Write the file
|
||||
try:
|
||||
dest.write_bytes(data)
|
||||
except OSError as e:
|
||||
logger.error("Failed to write NAM model to %s: %s", dest, e)
|
||||
return None
|
||||
|
||||
size_cat = self._classify_size(dest)
|
||||
logger.info(
|
||||
"Downloaded NAM model %s (%s, %.1f MB)",
|
||||
name, size_cat, dest.stat().st_size / (1024 * 1024),
|
||||
)
|
||||
|
||||
# Symlink to LV2 model directory if feasible
|
||||
self._link_model(dest)
|
||||
|
||||
return dest
|
||||
|
||||
def download_known(self, model_key: str) -> Path | None:
|
||||
"""Download a known-good model from the built-in catalog."""
|
||||
model_info = NAM_MODEL_URLS.get(model_key)
|
||||
if model_info is None:
|
||||
logger.error("Unknown model key: %s", model_key)
|
||||
return None
|
||||
return self.download(
|
||||
url=model_info["url"],
|
||||
name=model_info["name"],
|
||||
sha256_expected=model_info.get("sha256", ""),
|
||||
)
|
||||
|
||||
def download_from_github(
|
||||
self,
|
||||
repo: str,
|
||||
model_path: str,
|
||||
name: str | None = None,
|
||||
branch: str = "main",
|
||||
) -> Path | None:
|
||||
"""Download a .nam file from a GitHub repository.
|
||||
|
||||
Args:
|
||||
repo: Owner/repo (e.g., "sdatkinson/NeuralAmpModelerModelZoo").
|
||||
model_path: Path to the .nam file within the repo.
|
||||
name: Local name (derived from model_path if not given).
|
||||
branch: Branch or tag (default: main).
|
||||
"""
|
||||
url = f"https://raw.githubusercontent.com/{repo}/{branch}/{model_path}"
|
||||
return self.download(url, name=name)
|
||||
|
||||
# ── Install / Manage local models ───────────────────────────────────────
|
||||
|
||||
def install_local(self, source_path: str | Path, name: str | None = None) -> Path | None:
|
||||
"""Install a local .nam file into the managed models directory.
|
||||
|
||||
Args:
|
||||
source_path: Path to an existing .nam file.
|
||||
name: Name for the installed model (defaults to source filename).
|
||||
|
||||
Returns:
|
||||
Path to the installed file, or None on failure.
|
||||
"""
|
||||
src = Path(source_path)
|
||||
if not src.exists():
|
||||
logger.error("NAM source file not found: %s", src)
|
||||
return None
|
||||
if not src.suffix == ".nam":
|
||||
logger.warning("File does not have .nam extension: %s", src)
|
||||
# Still allow it, just warn
|
||||
|
||||
if name is None:
|
||||
name = src.stem
|
||||
|
||||
dest = self._model_path(name)
|
||||
|
||||
if dest.exists():
|
||||
logger.warning("Model already exists at %s, overwriting", dest)
|
||||
|
||||
try:
|
||||
shutil.copy2(src, dest)
|
||||
except OSError as e:
|
||||
logger.error("Failed to copy NAM model from %s to %s: %s", src, dest, e)
|
||||
return None
|
||||
|
||||
size_cat = self._classify_size(dest)
|
||||
logger.info("Installed NAM model %s (%s, %.1f MB)", name, size_cat, dest.stat().st_size / (1024 * 1024))
|
||||
|
||||
self._link_model(dest)
|
||||
return dest
|
||||
|
||||
def _link_model(self, model_path: Path) -> bool:
|
||||
"""Create a symlink in the LV2 model directory for NAM LV2 access."""
|
||||
if not model_path.exists():
|
||||
return False
|
||||
link_path = self._link_lv2_path(model_path)
|
||||
try:
|
||||
if link_path.exists() or link_path.is_symlink():
|
||||
link_path.unlink()
|
||||
os.symlink(model_path, link_path)
|
||||
logger.debug("Linked %s → %s", model_path, link_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("Failed to symlink NAM model %s: %s", model_path, e)
|
||||
return False
|
||||
|
||||
def remove(self, name: str) -> bool:
|
||||
"""Remove a managed NAM model file by name.
|
||||
|
||||
Also removes the LV2 symlink if it exists.
|
||||
|
||||
Returns True if the model was found and removed.
|
||||
"""
|
||||
model_path = self._model_path(name)
|
||||
if not model_path.exists():
|
||||
logger.warning("NAM model not found: %s", model_path)
|
||||
return False
|
||||
|
||||
# Remove LV2 symlink
|
||||
link_path = self._link_lv2_path(model_path)
|
||||
if link_path.exists() or link_path.is_symlink():
|
||||
try:
|
||||
link_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
model_path.unlink()
|
||||
logger.info("Removed NAM model: %s", name)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error("Failed to remove NAM model %s: %s", model_path, e)
|
||||
return False
|
||||
|
||||
def rename(self, old_name: str, new_name: str) -> bool:
|
||||
"""Rename a managed NAM model."""
|
||||
old_path = self._model_path(old_name)
|
||||
if not old_path.exists():
|
||||
return False
|
||||
new_path = self._model_path(new_name)
|
||||
try:
|
||||
old_path.rename(new_path)
|
||||
logger.info("Renamed NAM model %s → %s", old_name, new_name)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error("Failed to rename NAM model: %s", e)
|
||||
return False
|
||||
|
||||
# ── Listing ─────────────────────────────────────────────────────────────
|
||||
|
||||
def list_models(self) -> list[NAMModelMeta]:
|
||||
"""List all managed NAM model files with metadata."""
|
||||
models: list[NAMModelMeta] = []
|
||||
|
||||
for f in sorted(self._models_dir.glob("*.nam")):
|
||||
size_cat = self._classify_size(f)
|
||||
file_size = f.stat().st_size
|
||||
rpi4b_ok = size_cat in ("nano", "feather")
|
||||
|
||||
models.append(NAMModelMeta(
|
||||
name=f.stem,
|
||||
path=str(f),
|
||||
size_category=size_cat,
|
||||
file_size_bytes=file_size,
|
||||
file_size_mb=file_size / (1024 * 1024),
|
||||
rpi4b_compatible=rpi4b_ok,
|
||||
installed_at=f.stat().st_mtime,
|
||||
))
|
||||
|
||||
return models
|
||||
|
||||
def list_compatible(self) -> list[NAMModelMeta]:
|
||||
"""List only RPi4B-compatible models (nano/feather)."""
|
||||
return [m for m in self.list_models() if m.rpi4b_compatible]
|
||||
|
||||
def count_models(self) -> dict:
|
||||
"""Return model counts by size category."""
|
||||
counts = {"nano": 0, "feather": 0, "standard": 0, "custom": 0}
|
||||
for m in self.list_models():
|
||||
counts[m.size_category] = counts.get(m.size_category, 0) + 1
|
||||
return counts
|
||||
|
||||
def get_model(self, name: str) -> NAMModelMeta | None:
|
||||
"""Get metadata for a specific model."""
|
||||
model_path = self._model_path(name)
|
||||
if not model_path.exists():
|
||||
return None
|
||||
size_cat = self._classify_size(model_path)
|
||||
file_size = model_path.stat().st_size
|
||||
return NAMModelMeta(
|
||||
name=name,
|
||||
path=str(model_path),
|
||||
size_category=size_cat,
|
||||
file_size_bytes=file_size,
|
||||
file_size_mb=file_size / (1024 * 1024),
|
||||
rpi4b_compatible=size_cat in ("nano", "feather"),
|
||||
installed_at=model_path.stat().st_mtime,
|
||||
)
|
||||
|
||||
# ── PluginInfo integration ──────────────────────────────────────────────
|
||||
|
||||
def to_plugin_info(self, model: NAMModelMeta) -> PluginInfo:
|
||||
"""Convert a NAMModelMeta to a PluginInfo for registry ingestion."""
|
||||
sha = hashlib.sha256(model.path.encode()).hexdigest()[:16]
|
||||
return PluginInfo(
|
||||
meta=PluginMeta(
|
||||
name=model.name,
|
||||
uri=f"urn:nam:{model.name}:{sha}",
|
||||
format=PluginFormat.NAM,
|
||||
version=model.version,
|
||||
author=model.author,
|
||||
description=model.description,
|
||||
arch="aarch64",
|
||||
arm_optimized=True,
|
||||
),
|
||||
categories=[PluginCategory.NAM_PROFILE],
|
||||
bundle_path=str(self._models_dir),
|
||||
library_path="",
|
||||
nam_model_path=model.path,
|
||||
nam_model_size=model.size_category,
|
||||
status=PluginStatus.ACTIVE,
|
||||
scanned_at=time.time(),
|
||||
installed_at=model.installed_at,
|
||||
estimated_cpu_pct={
|
||||
"nano": 8.0, "feather": 18.0, "standard": 35.0, "custom": 50.0
|
||||
}.get(model.size_category, 30.0),
|
||||
rpi4b_known_good=model.rpi4b_compatible,
|
||||
rpi4b_known_broken=not model.rpi4b_compatible and model.size_category == "standard",
|
||||
)
|
||||
|
||||
# ── NAM model database ──────────────────────────────────────────────────
|
||||
|
||||
def get_model_db_path(self) -> Path:
|
||||
"""Path to the NAM model metadata database (JSON index)."""
|
||||
return self._models_dir / "models.json"
|
||||
|
||||
def save_model_db(self, models: list[NAMModelMeta]) -> None:
|
||||
"""Save model metadata to the JSON index."""
|
||||
db: dict = {
|
||||
"version": 1,
|
||||
"updated": time.time(),
|
||||
"models": [
|
||||
{
|
||||
"name": m.name,
|
||||
"path": m.path,
|
||||
"url": m.url,
|
||||
"size_category": m.size_category,
|
||||
"file_size_bytes": m.file_size_bytes,
|
||||
"author": m.author,
|
||||
"description": m.description,
|
||||
"version": m.version,
|
||||
"sha256": m.sha256,
|
||||
"rpi4b_compatible": m.rpi4b_compatible,
|
||||
}
|
||||
for m in models
|
||||
],
|
||||
}
|
||||
self.get_model_db_path().write_text(json.dumps(db, indent=2))
|
||||
|
||||
def load_model_db(self) -> list[NAMModelMeta]:
|
||||
"""Load model metadata from the JSON index."""
|
||||
db_path = self.get_model_db_path()
|
||||
if not db_path.exists():
|
||||
return self.list_models()
|
||||
|
||||
try:
|
||||
db = json.loads(db_path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return self.list_models()
|
||||
|
||||
models: list[NAMModelMeta] = []
|
||||
for item in db.get("models", []):
|
||||
models.append(NAMModelMeta(
|
||||
name=item.get("name", ""),
|
||||
path=item.get("path", ""),
|
||||
url=item.get("url", ""),
|
||||
size_category=item.get("size_category", "standard"),
|
||||
file_size_bytes=item.get("file_size_bytes", 0),
|
||||
author=item.get("author", ""),
|
||||
description=item.get("description", ""),
|
||||
version=item.get("version", ""),
|
||||
sha256=item.get("sha256", ""),
|
||||
rpi4b_compatible=item.get("rpi4b_compatible", False),
|
||||
))
|
||||
return models
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Plugin registry database — SQLite-backed persistence for scanned plugins.
|
||||
|
||||
Provides CRUD operations, metadata caching, and querying by category,
|
||||
format, status, and other attributes. Uses a single SQLite database
|
||||
file at ~/.config/rpi-mixer/plugins.db by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
from .types import (
|
||||
PluginCategory,
|
||||
PluginFormat,
|
||||
PluginInfo,
|
||||
PluginStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DB_PATH = Path.home() / ".config" / "rpi-mixer" / "plugins.db"
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
CREATE_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
-- Identity
|
||||
uri TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
format TEXT NOT NULL,
|
||||
version TEXT DEFAULT '',
|
||||
author TEXT DEFAULT '',
|
||||
license TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
homepage TEXT DEFAULT '',
|
||||
project TEXT DEFAULT '',
|
||||
|
||||
-- Platform
|
||||
arch TEXT DEFAULT 'aarch64',
|
||||
os TEXT DEFAULT 'linux',
|
||||
arm_optimized INTEGER DEFAULT 0,
|
||||
|
||||
-- Classification
|
||||
categories TEXT DEFAULT '',
|
||||
audio_inputs INTEGER DEFAULT 0,
|
||||
audio_outputs INTEGER DEFAULT 0,
|
||||
midi_inputs INTEGER DEFAULT 0,
|
||||
midi_outputs INTEGER DEFAULT 0,
|
||||
|
||||
-- Filesystem
|
||||
bundle_path TEXT DEFAULT '',
|
||||
library_path TEXT DEFAULT '',
|
||||
|
||||
-- Status
|
||||
status TEXT DEFAULT 'active',
|
||||
error_message TEXT DEFAULT '',
|
||||
|
||||
-- Timestamps
|
||||
scanned_at REAL DEFAULT 0.0,
|
||||
installed_at REAL DEFAULT 0.0,
|
||||
updated_at REAL DEFAULT 0.0,
|
||||
|
||||
-- Performance
|
||||
estimated_cpu_pct REAL DEFAULT 0.0,
|
||||
estimated_ram_mb REAL DEFAULT 0.0,
|
||||
rpi4b_known_good INTEGER DEFAULT 0,
|
||||
rpi4b_known_broken INTEGER DEFAULT 0,
|
||||
|
||||
-- NAM
|
||||
nam_model_path TEXT DEFAULT '',
|
||||
nam_model_size TEXT DEFAULT '',
|
||||
|
||||
-- Bookkeeping
|
||||
_created_at REAL DEFAULT (strftime('%s', 'now')),
|
||||
_updated_at REAL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_format ON plugins(format);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_scanned ON plugins(scanned_at);
|
||||
"""
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""SQLite-backed plugin registry.
|
||||
|
||||
Thread-safe for reads. Write operations should be serialised externally
|
||||
if used concurrently (SQLite's own locking handles most cases).
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | Path = DEFAULT_DB_PATH):
|
||||
self._db_path = Path(db_path)
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Create tables and indexes if they don't exist."""
|
||||
with self._conn() as conn:
|
||||
conn.executescript(CREATE_TABLE_SQL)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO plugins (uri, name, format, status) "
|
||||
"VALUES ('_schema_version', 'schema', 'meta', 'active')"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE plugins SET version = ? WHERE uri = '_schema_version'",
|
||||
(str(SCHEMA_VERSION),)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@contextmanager
|
||||
def _conn(self) -> Iterator[sqlite3.Connection]:
|
||||
"""Get a database connection. Auto-closes on context exit."""
|
||||
conn = sqlite3.connect(str(self._db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def insert(self, plugin: PluginInfo) -> bool:
|
||||
"""Insert a new plugin into the registry.
|
||||
|
||||
Returns True if inserted, False if a plugin with the same URI already exists.
|
||||
"""
|
||||
data = plugin.to_dict()
|
||||
columns = ", ".join(data.keys())
|
||||
placeholders = ", ".join("?" for _ in data)
|
||||
values = list(data.values())
|
||||
|
||||
with self._conn() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
f"INSERT INTO plugins ({columns}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
logger.debug("Plugin %s already exists in registry", plugin.meta.uri)
|
||||
return False
|
||||
|
||||
def upsert(self, plugin: PluginInfo) -> bool:
|
||||
"""Insert or update a plugin in the registry.
|
||||
|
||||
Returns True if the row was modified.
|
||||
"""
|
||||
data = plugin.to_dict()
|
||||
set_clause = ", ".join(f"{k} = ?" for k in data)
|
||||
values = list(data.values())
|
||||
|
||||
with self._conn() as conn:
|
||||
cursor = conn.execute(
|
||||
f"INSERT OR REPLACE INTO plugins ({', '.join(data.keys())}) "
|
||||
f"VALUES ({', '.join('?' for _ in data)})",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update(self, uri: str, **kwargs: Any) -> bool:
|
||||
"""Update specific fields of a plugin by URI."""
|
||||
if not kwargs:
|
||||
return False
|
||||
|
||||
set_parts = [f"{k} = ?" for k in kwargs]
|
||||
values = list(kwargs.values()) + [uri]
|
||||
|
||||
with self._conn() as conn:
|
||||
cursor = conn.execute(
|
||||
f"UPDATE plugins SET {', '.join(set_parts)}, _updated_at = ? "
|
||||
f"WHERE uri = ?",
|
||||
list(kwargs.values()) + [time.time(), uri],
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete(self, uri: str) -> bool:
|
||||
"""Soft-delete a plugin (marks status=removed)."""
|
||||
return self.update(uri, status=PluginStatus.REMOVED.value)
|
||||
|
||||
def purge(self, uri: str) -> bool:
|
||||
"""Hard-delete a plugin from the registry."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.execute("DELETE FROM plugins WHERE uri = ?", (uri,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# ── Querying ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get(self, uri: str) -> PluginInfo | None:
|
||||
"""Look up a single plugin by URI."""
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM plugins WHERE uri = ?", (uri,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return PluginInfo.from_dict(dict(row))
|
||||
|
||||
def get_by_name(self, name: str) -> PluginInfo | None:
|
||||
"""Look up a plugin by name (case-insensitive)."""
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM plugins WHERE name COLLATE NOCASE = ?",
|
||||
(name,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return PluginInfo.from_dict(dict(row))
|
||||
|
||||
def list_all(self, include_removed: bool = False) -> list[PluginInfo]:
|
||||
"""Return all plugins in the registry."""
|
||||
with self._conn() as conn:
|
||||
if include_removed:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE uri != '_schema_version' "
|
||||
"ORDER BY format, name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE uri != '_schema_version' "
|
||||
"AND status != 'removed' "
|
||||
"ORDER BY format, name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||
|
||||
def list_by_format(self, format: PluginFormat) -> list[PluginInfo]:
|
||||
"""List plugins of a specific format."""
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE format = ? AND status != 'removed' "
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
(format.value,)
|
||||
).fetchall()
|
||||
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||
|
||||
def list_by_category(self, category: PluginCategory) -> list[PluginInfo]:
|
||||
"""List plugins matching a specific category."""
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE categories LIKE ? AND status != 'removed' "
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
(f"%{category.value}%",)
|
||||
).fetchall()
|
||||
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||
|
||||
def list_by_status(self, status: PluginStatus) -> list[PluginInfo]:
|
||||
"""List plugins with a specific status."""
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE status = ? ORDER BY name COLLATE NOCASE",
|
||||
(status.value,)
|
||||
).fetchall()
|
||||
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||
|
||||
def list_loadable(self) -> list[PluginInfo]:
|
||||
"""Return only plugins that are active (loadable)."""
|
||||
return self.list_by_status(PluginStatus.ACTIVE)
|
||||
|
||||
def list_blacklisted(self) -> list[PluginInfo]:
|
||||
"""Return blacklisted plugins."""
|
||||
return self.list_by_status(PluginStatus.BLACKLISTED)
|
||||
|
||||
def list_stale(self) -> list[PluginInfo]:
|
||||
"""Return stale plugins (files missing)."""
|
||||
# Stale plugins are those with status 'stale' or those whose
|
||||
# bundle_path doesn't exist on disk
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE status = 'stale' "
|
||||
"ORDER BY name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||
|
||||
def list_rpi4b_verified(self) -> list[PluginInfo]:
|
||||
"""List plugins verified working on RPi4B."""
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE rpi4b_known_good = 1 "
|
||||
"AND status != 'removed' ORDER BY name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||
|
||||
def list_nam_models(self) -> list[PluginInfo]:
|
||||
"""List NAM model plugins."""
|
||||
return self.list_by_format(PluginFormat.NAM)
|
||||
|
||||
def search(self, query: str) -> list[PluginInfo]:
|
||||
"""Full-text search across plugin names, descriptions, and authors."""
|
||||
with self._conn() as conn:
|
||||
like = f"%{query}%"
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM plugins WHERE "
|
||||
"(name LIKE ? OR description LIKE ? OR author LIKE ? OR project LIKE ?)"
|
||||
"AND status != 'removed' AND uri != '_schema_version' "
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
(like, like, like, like)
|
||||
).fetchall()
|
||||
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||
|
||||
def count(self) -> int:
|
||||
"""Return total number of active plugins."""
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM plugins "
|
||||
"WHERE status != 'removed' AND uri != '_schema_version'"
|
||||
).fetchone()
|
||||
return row["c"] if row else 0
|
||||
|
||||
def count_by_format(self) -> dict[str, int]:
|
||||
"""Return plugin count per format."""
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT format, COUNT(*) as c FROM plugins "
|
||||
"WHERE status != 'removed' AND uri != '_schema_version' "
|
||||
"GROUP BY format"
|
||||
).fetchall()
|
||||
return {r["format"]: r["c"] for r in rows}
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return aggregate registry statistics."""
|
||||
counts = self.count_by_format()
|
||||
return {
|
||||
"total": sum(counts.values()),
|
||||
"by_format": counts,
|
||||
"blacklisted": len(self.list_blacklisted()),
|
||||
"stale": len(self.list_stale()),
|
||||
"rpi4b_verified": len(self.list_rpi4b_verified()),
|
||||
"nam_models": len(self.list_nam_models()),
|
||||
}
|
||||
|
||||
# ── Batch operations ─────────────────────────────────────────────────────
|
||||
|
||||
def sync_from_scan(self, scanned: list[PluginInfo]) -> dict:
|
||||
"""Synchronise the registry with a scan result.
|
||||
|
||||
- New plugins are inserted.
|
||||
- Existing plugins are updated (metadata refreshed).
|
||||
- Plugins no longer present are marked stale.
|
||||
|
||||
Returns a dict with counts for {inserted, updated, stale}.
|
||||
"""
|
||||
existing_uris = set()
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT uri FROM plugins WHERE uri != '_schema_version'"
|
||||
).fetchall()
|
||||
existing_uris = {r["uri"] for r in rows}
|
||||
|
||||
scanned_uris = {p.meta.uri for p in scanned}
|
||||
inserted = 0
|
||||
updated = 0
|
||||
|
||||
for plugin in scanned:
|
||||
if plugin.meta.uri in existing_uris:
|
||||
if self.upsert(plugin):
|
||||
updated += 1
|
||||
else:
|
||||
if self.insert(plugin):
|
||||
inserted += 1
|
||||
|
||||
# Mark plugins no longer found as stale
|
||||
stale_uris = existing_uris - scanned_uris
|
||||
stale_count = 0
|
||||
for uri in stale_uris:
|
||||
if self.update(uri, status=PluginStatus.STALE.value):
|
||||
stale_count += 1
|
||||
|
||||
logger.info(
|
||||
"Registry sync: %d inserted, %d updated, %d marked stale",
|
||||
inserted, updated, stale_count,
|
||||
)
|
||||
return {"inserted": inserted, "updated": updated, "stale": stale_count}
|
||||
|
||||
def vacuum(self) -> None:
|
||||
"""Compact and optimize the database."""
|
||||
with self._conn() as conn:
|
||||
conn.execute("PRAGMA optimize")
|
||||
conn.execute("VACUUM")
|
||||
|
||||
def close(self) -> None:
|
||||
"""No-op — connections are managed per-operation."""
|
||||
pass
|
||||
@@ -0,0 +1,825 @@
|
||||
"""Plugin scanning engine — LV2 manifest parsing and VST3 descriptor reading.
|
||||
|
||||
Scans the filesystem for installed plugins, parses their metadata, and
|
||||
returns a list of PluginInfo objects suitable for registry ingestion.
|
||||
|
||||
LV2: Parses manifest.ttl (Turtle/RDF) for plugin metadata.
|
||||
VST3: Reads moduleinfo.json descriptors (VST3 SDK >= 3.7.0).
|
||||
LADSPA: Parses .so ELF metadata and ladspa.rdf descriptors.
|
||||
NAM: Scans .nam files and reads their embedded JSON metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from .types import (
|
||||
PluginCategory,
|
||||
PluginFormat,
|
||||
PluginInfo,
|
||||
PluginMeta,
|
||||
PluginPort,
|
||||
PluginStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default scan paths for Raspberry Pi OS (RPi OS Lite 64-bit)
|
||||
DEFAULT_LV2_PATHS = [
|
||||
"/usr/lib/lv2",
|
||||
"/usr/local/lib/lv2",
|
||||
"/usr/lib/aarch64-linux-gnu/lv2",
|
||||
str(Path.home() / ".lv2"),
|
||||
]
|
||||
|
||||
DEFAULT_VST3_PATHS = [
|
||||
"/usr/lib/vst3",
|
||||
"/usr/local/lib/vst3",
|
||||
str(Path.home() / ".vst3"),
|
||||
]
|
||||
|
||||
DEFAULT_LADSPA_PATHS = [
|
||||
"/usr/lib/ladspa",
|
||||
"/usr/local/lib/ladspa",
|
||||
str(Path.home() / ".ladspa"),
|
||||
]
|
||||
|
||||
DEFAULT_NAM_PATHS = [
|
||||
str(Path.home() / ".config" / "rpi-mixer" / "nam"),
|
||||
str(Path.home() / ".lv2" / "nam-models"),
|
||||
"/usr/share/nam",
|
||||
]
|
||||
|
||||
CACHE_TTL_SECONDS = 3600 # Re-scan after 1 hour
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# LV2 Scanner
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Minimal Turtle/RDF parser for LV2 manifest.ttl.
|
||||
# LV2 manifests use a simple RDF/Turtle subset — we don't need a full parser.
|
||||
|
||||
_LV2_CLASS_MAP: dict[str, PluginCategory] = {
|
||||
"http://lv2plug.in/ns/lv2core#DelayPlugin": PluginCategory.DELAY,
|
||||
"http://lv2plug.in/ns/lv2core#ReverbPlugin": PluginCategory.REVERB,
|
||||
"http://lv2plug.in/ns/lv2core#ChorusPlugin": PluginCategory.CHORUS,
|
||||
"http://lv2plug.in/ns/lv2core#FlangerPlugin": PluginCategory.FLANGER,
|
||||
"http://lv2plug.in/ns/lv2core#PhaserPlugin": PluginCategory.PHASER,
|
||||
"http://lv2plug.in/ns/lv2core#CompressorPlugin": PluginCategory.COMPRESSOR,
|
||||
"http://lv2plug.in/ns/lv2core#LimiterPlugin": PluginCategory.LIMITER,
|
||||
"http://lv2plug.in/ns/lv2core#GatePlugin": PluginCategory.GATE,
|
||||
"http://lv2plug.in/ns/lv2core#ExpanderPlugin": PluginCategory.EXPANDER,
|
||||
"http://lv2plug.in/ns/lv2core#EQPlugin": PluginCategory.EQ_PARAMETRIC,
|
||||
"http://lv2plug.in/ns/lv2core#FilterPlugin": PluginCategory.FILTER,
|
||||
"http://lv2plug.in/ns/lv2core#DistortionPlugin": PluginCategory.DISTORTION,
|
||||
"http://lv2plug.in/ns/lv2core#WaveshaperPlugin": PluginCategory.DISTORTION,
|
||||
"http://lv2plug.in/ns/lv2core#SimulatorPlugin": PluginCategory.AMP,
|
||||
"http://lv2plug.in/ns/lv2core#SpatialPlugin": PluginCategory.SPATIAL,
|
||||
"http://lv2plug.in/ns/lv2core#SpectralPlugin": PluginCategory.ANALYZER,
|
||||
"http://lv2plug.in/ns/lv2core#PitchPlugin": PluginCategory.PITCH_SHIFTER,
|
||||
"http://lv2plug.in/ns/lv2core#AnalyserPlugin": PluginCategory.ANALYZER,
|
||||
"http://lv2plug.in/ns/lv2core#MeterPlugin": PluginCategory.METER,
|
||||
"http://lv2plug.in/ns/lv2core#UtilityPlugin": PluginCategory.UTILITY,
|
||||
"http://lv2plug.in/ns/lv2core#InstrumentPlugin": PluginCategory.SYNTH,
|
||||
"http://lv2plug.in/ns/lv2core#GeneratorPlugin": PluginCategory.SYNTH,
|
||||
"http://lv2plug.in/ns/lv2core#OscillatorPlugin": PluginCategory.SYNTH,
|
||||
}
|
||||
|
||||
# Category keywords for best-effort matching when class URI isn't in the map
|
||||
_CATEGORY_KEYWORDS: list[tuple[list[str], PluginCategory]] = [
|
||||
(["amp", "amplifier", "guitar_amp", "bass_amp", "tube"], PluginCategory.AMP),
|
||||
(["cab", "cabinet", "ir_loader", "impulse_response", "convolution"], PluginCategory.IR_LOADER),
|
||||
(["reverb", "hall", "plate", "room", "spring"], PluginCategory.REVERB),
|
||||
(["delay", "echo", "tape_echo"], PluginCategory.DELAY),
|
||||
(["chorus", "ensemble"], PluginCategory.CHORUS),
|
||||
(["flanger", "flange"], PluginCategory.FLANGER),
|
||||
(["phaser", "phase"], PluginCategory.PHASER),
|
||||
(["tremolo"], PluginCategory.TREMOLO),
|
||||
(["vibrato"], PluginCategory.VIBRATO),
|
||||
(["compressor", "comp", "dynamics", "leveler"], PluginCategory.COMPRESSOR),
|
||||
(["limiter", "limit", "maximizer"], PluginCategory.LIMITER),
|
||||
(["gate", "noise_gate", "expander"], PluginCategory.GATE),
|
||||
(["eq", "equalizer", "equaliser", "parametric", "graphic", "shelving", "tone"], PluginCategory.EQ_PARAMETRIC),
|
||||
(["filter", "lpf", "hpf", "bpf", "lowpass", "highpass", "bandpass"], PluginCategory.FILTER),
|
||||
(["distortion", "dist", "overdrive", "fuzz", "saturation", "crunch"], PluginCategory.DISTORTION),
|
||||
(["overdrive", "od"], PluginCategory.OVERDRIVE),
|
||||
(["fuzz"], PluginCategory.FUZZ),
|
||||
(["pitch", "shifter", "harmonizer", "harmoniser"], PluginCategory.PITCH_SHIFTER),
|
||||
(["octaver", "octave", "sub_octave"], PluginCategory.OCTAVER),
|
||||
(["wah", "auto_wah", "envelope_filter"], PluginCategory.WAH),
|
||||
(["synth", "synthesizer", "oscillator", "generator", "soundfont"], PluginCategory.SYNTH),
|
||||
(["sampler", "sample_player", "drum"], PluginCategory.SAMPLER),
|
||||
(["meter", "vu", "level", "loudness", "peak"], PluginCategory.METER),
|
||||
(["analyzer", "analyser", "spectrum", "spectrogram", "fft", "tuner"], PluginCategory.ANALYZER),
|
||||
(["utility", "gain", "volume", "pan", "balance", "mute", "phase", "polarity"], PluginCategory.UTILITY),
|
||||
(["spatial", "stereo", "width", "imager", "panner"], PluginCategory.SPATIAL),
|
||||
(["noise", "denoiser", "nr", "reduction"], PluginCategory.NOISE_REDUCTION),
|
||||
]
|
||||
|
||||
|
||||
def _hash_str(s: str) -> str:
|
||||
"""Short SHA-256 hash for use as a synthetic URI."""
|
||||
return hashlib.sha256(s.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _parse_turtle_statement(line: str) -> tuple[str, str, str] | None:
|
||||
"""Parse a simple RDF/Turtle triple (subject predicate object .).
|
||||
|
||||
Returns (subject, predicate, object) or None for comments/blank lines.
|
||||
Handles the subset used by LV2 manifests — not full Turtle compliance.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
|
||||
# Remove trailing '.' and optional ';' at end
|
||||
line = line.rstrip(" .;")
|
||||
|
||||
# Split on whitespace, respecting angle brackets and quotes
|
||||
# Simplified: find three tokens
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
|
||||
subj, pred, obj = parts[0], parts[1], parts[2]
|
||||
|
||||
# Strip angle brackets from URIs
|
||||
subj = subj.strip("<>")
|
||||
pred = pred.strip("<>")
|
||||
obj = obj.strip("<>").strip('"')
|
||||
|
||||
return subj, pred, obj
|
||||
|
||||
|
||||
def _parse_lv2_manifest(manifest_path: Path) -> list[dict]:
|
||||
"""Parse an LV2 manifest.ttl file and return a list of plugin descriptors.
|
||||
|
||||
Each descriptor is a dict containing all the parsed RDF triples.
|
||||
"""
|
||||
if not manifest_path.exists():
|
||||
return []
|
||||
|
||||
plugins: list[dict] = []
|
||||
current: dict | None = None
|
||||
|
||||
try:
|
||||
text = manifest_path.read_text(errors="replace")
|
||||
except OSError as e:
|
||||
logger.warning("Cannot read LV2 manifest %s: %s", manifest_path, e)
|
||||
return []
|
||||
|
||||
for line in text.splitlines():
|
||||
stmt = _parse_turtle_statement(line)
|
||||
if stmt is None:
|
||||
continue
|
||||
subj, pred, obj = stmt
|
||||
|
||||
# LV2: a plugin is defined as <uri> a lv2:Plugin
|
||||
is_plugin_def = (
|
||||
"lv2core#Plugin" in pred or "lv2core#Plugin" in obj
|
||||
) and (
|
||||
pred.endswith("#a") or pred.endswith("rdf-syntax-ns#type")
|
||||
or "type" in pred.lower()
|
||||
)
|
||||
|
||||
if is_plugin_def and not current:
|
||||
current = {"uri": subj, "rdf_type": obj, "name": "", "class_uri": ""}
|
||||
plugins.append(current)
|
||||
elif subj and current:
|
||||
# Assign to current plugin
|
||||
if pred.endswith("#name") or pred.endswith("doap#name"):
|
||||
current["name"] = obj
|
||||
elif pred.endswith("#binary") or pred.endswith("lv2#binary"):
|
||||
current["binary"] = obj
|
||||
elif pred.endswith("#class") or pred.endswith("rdf#type"):
|
||||
current["class_uri"] = obj
|
||||
elif "doap#license" in pred:
|
||||
current["license"] = obj
|
||||
elif "doap#homepage" in pred:
|
||||
current["homepage"] = obj
|
||||
elif "doap#shortdesc" in pred or "rdfs#comment" in pred:
|
||||
current["description"] = obj
|
||||
elif "doap#maintainer" in pred:
|
||||
current["author"] = obj
|
||||
elif "lv2#minorVersion" in pred:
|
||||
current.setdefault("version_parts", [0, 0])
|
||||
current["version_parts"][1] = int(obj) if obj.isdigit() else 0
|
||||
elif "lv2#microVersion" in pred:
|
||||
pass # Tracked in version parts
|
||||
elif pred.endswith("#project") or "lv2#project" in pred:
|
||||
current["project"] = obj
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def _parse_lv2_ttl_port(port_path: Path) -> list[PluginPort]:
|
||||
"""Parse an LV2 plugin's .ttl file for port definitions."""
|
||||
ports: list[PluginPort] = []
|
||||
if not port_path.exists():
|
||||
return ports
|
||||
|
||||
try:
|
||||
text = port_path.read_text(errors="replace")
|
||||
except OSError:
|
||||
return ports
|
||||
|
||||
port_index = 0
|
||||
current_port: dict | None = None
|
||||
|
||||
for line in text.splitlines():
|
||||
stmt = _parse_turtle_statement(line)
|
||||
if stmt is None:
|
||||
continue
|
||||
subj, pred, obj = stmt
|
||||
|
||||
# Detect port definition
|
||||
if pred.endswith("#port") and "lv2:Port" not in subj:
|
||||
# This is a port index assignment
|
||||
# Usually: <plugin_uri> lv2:port [ ... ] — too complex for simple parser
|
||||
# Instead, look for lv2:index
|
||||
continue
|
||||
elif pred.endswith("#index"):
|
||||
current_port = {"index": int(obj) if obj.lstrip("-").isdigit() else port_index}
|
||||
port_index += 1
|
||||
elif pred.endswith("#symbol") and current_port:
|
||||
current_port["symbol"] = obj
|
||||
elif pred.endswith("#name") and current_port:
|
||||
current_port["name"] = obj
|
||||
elif "Port#Input" in obj and current_port:
|
||||
current_port["direction"] = "input"
|
||||
current_port.setdefault("port_type", "audio")
|
||||
elif "Port#Output" in obj and current_port:
|
||||
current_port["direction"] = "output"
|
||||
current_port.setdefault("port_type", "audio")
|
||||
elif "ControlPort" in obj or "lv2#ControlPort" in obj:
|
||||
if current_port:
|
||||
current_port["port_type"] = "control"
|
||||
elif "AudioPort" in obj:
|
||||
if current_port:
|
||||
current_port["port_type"] = "audio"
|
||||
elif pred.endswith("#minimum") and current_port:
|
||||
try: current_port["minimum"] = float(obj)
|
||||
except ValueError: pass
|
||||
elif pred.endswith("#maximum") and current_port:
|
||||
try: current_port["maximum"] = float(obj)
|
||||
except ValueError: pass
|
||||
elif pred.endswith("#default") and current_port:
|
||||
try: current_port["default"] = float(obj)
|
||||
except ValueError: pass
|
||||
|
||||
# When we encounter a new index after having a complete port, push it
|
||||
if current_port and "symbol" in current_port and "name" in current_port:
|
||||
ports.append(PluginPort(
|
||||
index=current_port.get("index", len(ports)),
|
||||
symbol=current_port.get("symbol", ""),
|
||||
name=current_port.get("name", ""),
|
||||
direction=current_port.get("direction", "input"),
|
||||
port_type=current_port.get("port_type", "control"),
|
||||
default=current_port.get("default", 0.0),
|
||||
minimum=current_port.get("minimum", 0.0),
|
||||
maximum=current_port.get("maximum", 1.0),
|
||||
))
|
||||
current_port = None
|
||||
|
||||
return ports
|
||||
|
||||
|
||||
def _classify_lv2_plugin(class_uri: str, uri: str, name: str) -> list[PluginCategory]:
|
||||
"""Determine plugin categories from LV2 class URI and heuristics."""
|
||||
categories: list[PluginCategory] = []
|
||||
|
||||
# Exact class URI match
|
||||
for cls_uri, cat in _LV2_CLASS_MAP.items():
|
||||
if cls_uri in class_uri or cls_uri in uri:
|
||||
categories.append(cat)
|
||||
break # Only one primary class
|
||||
|
||||
# Keyword heuristics on URI + name
|
||||
search_text = f"{uri.lower()} {name.lower()}"
|
||||
for keywords, cat in _CATEGORY_KEYWORDS:
|
||||
if any(kw in search_text for kw in keywords):
|
||||
if cat not in categories:
|
||||
categories.append(cat)
|
||||
|
||||
if not categories:
|
||||
categories.append(PluginCategory.UNKNOWN)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def scan_lv2(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||
"""Scan for LV2 plugins across the given paths.
|
||||
|
||||
For each bundle directory, parses manifest.ttl to extract plugin URIs,
|
||||
names, and metadata. Port information is extracted from the plugin's
|
||||
.ttl file when available.
|
||||
"""
|
||||
if paths is None:
|
||||
paths = DEFAULT_LV2_PATHS
|
||||
|
||||
results: list[PluginInfo] = []
|
||||
now = time.time()
|
||||
|
||||
for base_path in paths:
|
||||
base = Path(base_path)
|
||||
if not base.is_dir():
|
||||
logger.debug("LV2 path not found, skipping: %s", base)
|
||||
continue
|
||||
|
||||
for bundle_dir in base.iterdir():
|
||||
if not bundle_dir.is_dir():
|
||||
continue
|
||||
|
||||
manifest = bundle_dir / "manifest.ttl"
|
||||
if not manifest.exists():
|
||||
continue
|
||||
|
||||
plugins = _parse_lv2_manifest(manifest)
|
||||
if not plugins:
|
||||
continue
|
||||
|
||||
for pdata in plugins:
|
||||
uri = pdata.get("uri", "")
|
||||
name = pdata.get("name", bundle_dir.name)
|
||||
|
||||
if not uri:
|
||||
uri = f"urn:lv2:{bundle_dir.name}:{_hash_str(name)}"
|
||||
|
||||
# Look for the plugin's main .ttl file (usually <name>.ttl)
|
||||
ttl_file = None
|
||||
for candidate in bundle_dir.glob("*.ttl"):
|
||||
if candidate.name != "manifest.ttl":
|
||||
ttl_file = candidate
|
||||
break
|
||||
|
||||
ports = _parse_lv2_ttl_port(ttl_file) if ttl_file else []
|
||||
audio_in = sum(1 for p in ports if p.port_type == "audio" and p.direction == "input")
|
||||
audio_out = sum(1 for p in ports if p.port_type == "audio" and p.direction == "output")
|
||||
control_ports = [p for p in ports if p.port_type == "control"]
|
||||
|
||||
categories = _classify_lv2_plugin(
|
||||
pdata.get("class_uri", ""), uri, name
|
||||
)
|
||||
|
||||
# Look for the binary .so
|
||||
bin_rel = pdata.get("binary", "")
|
||||
library_path = str(bundle_dir / bin_rel) if bin_rel else ""
|
||||
|
||||
# Version parsing
|
||||
version = pdata.get("version", "")
|
||||
if not version and "version_parts" in pdata:
|
||||
parts = pdata["version_parts"]
|
||||
version = f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else ""
|
||||
|
||||
info = PluginInfo(
|
||||
meta=PluginMeta(
|
||||
name=name,
|
||||
uri=uri,
|
||||
format=PluginFormat.LV2,
|
||||
version=version,
|
||||
author=pdata.get("author", ""),
|
||||
license=pdata.get("license", ""),
|
||||
description=pdata.get("description", ""),
|
||||
homepage=pdata.get("homepage", ""),
|
||||
project=pdata.get("project", ""),
|
||||
arch="aarch64",
|
||||
arm_optimized=False,
|
||||
),
|
||||
categories=categories,
|
||||
audio_inputs=audio_in,
|
||||
audio_outputs=audio_out,
|
||||
control_ports=control_ports,
|
||||
bundle_path=str(bundle_dir),
|
||||
library_path=library_path,
|
||||
status=PluginStatus.ACTIVE,
|
||||
scanned_at=now,
|
||||
)
|
||||
results.append(info)
|
||||
|
||||
logger.info("LV2 scan found %d plugins across %d paths", len(results), len(paths))
|
||||
return results
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# VST3 Scanner
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_vst3_moduleinfo(json_path: Path) -> dict | None:
|
||||
"""Parse a VST3 moduleinfo.json file."""
|
||||
if not json_path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(json_path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to parse VST3 moduleinfo %s: %s", json_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_vst3_snapshot(snapshot_path: Path) -> dict | None:
|
||||
"""Parse a VST3 snapshot JSON file (module image)."""
|
||||
if not snapshot_path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(snapshot_path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to parse VST3 snapshot %s: %s", snapshot_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _vst3_subcategories_to_categories(subcategories: list[str]) -> list[PluginCategory]:
|
||||
"""Map VST3 subcategory strings to PluginCategory values."""
|
||||
vst3_map: dict[str, PluginCategory] = {
|
||||
"Fx": PluginCategory.OTHER,
|
||||
"Fx|Delay": PluginCategory.DELAY,
|
||||
"Fx|Reverb": PluginCategory.REVERB,
|
||||
"Fx|Chorus": PluginCategory.CHORUS,
|
||||
"Fx|Flanger": PluginCategory.FLANGER,
|
||||
"Fx|Phaser": PluginCategory.PHASER,
|
||||
"Fx|Tremolo": PluginCategory.TREMOLO,
|
||||
"Fx|Compressor": PluginCategory.COMPRESSOR,
|
||||
"Fx|Limiter": PluginCategory.LIMITER,
|
||||
"Fx|Gate": PluginCategory.GATE,
|
||||
"Fx|EQ": PluginCategory.EQ_PARAMETRIC,
|
||||
"Fx|Filter": PluginCategory.FILTER,
|
||||
"Fx|Distortion": PluginCategory.DISTORTION,
|
||||
"Fx|PitchShift": PluginCategory.PITCH_SHIFTER,
|
||||
"Fx|Wah": PluginCategory.WAH,
|
||||
"Instrument": PluginCategory.SYNTH,
|
||||
"Instrument|Synth": PluginCategory.SYNTH,
|
||||
"Instrument|Sampler": PluginCategory.SAMPLER,
|
||||
"Instrument|Drum": PluginCategory.DRUM_MACHINE,
|
||||
"Analyzer": PluginCategory.ANALYZER,
|
||||
"Spatial": PluginCategory.SPATIAL,
|
||||
"Generator": PluginCategory.SYNTH,
|
||||
}
|
||||
|
||||
categories: list[PluginCategory] = []
|
||||
for sc in subcategories:
|
||||
if sc in vst3_map:
|
||||
categories.append(vst3_map[sc])
|
||||
|
||||
if not categories:
|
||||
categories.append(PluginCategory.UNKNOWN)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def _parse_vst3_bundle(bundle_path: Path) -> list[PluginInfo]:
|
||||
"""Parse a VST3 bundle directory (.vst3 folder)."""
|
||||
results: list[PluginInfo] = []
|
||||
now = time.time()
|
||||
|
||||
bundle_name = bundle_path.name
|
||||
if bundle_name.endswith(".vst3"):
|
||||
bundle_name = bundle_name[:-5]
|
||||
|
||||
# Find moduleinfo.json (VST3 SDK 3.7.0+)
|
||||
moduleinfo = None
|
||||
# Try common locations within the bundle
|
||||
for pattern in ["**/moduleinfo.json", "Contents/Resources/moduleinfo.json"]:
|
||||
for mi_path in bundle_path.glob(pattern):
|
||||
moduleinfo = _parse_vst3_moduleinfo(mi_path)
|
||||
if moduleinfo:
|
||||
break
|
||||
if moduleinfo:
|
||||
break
|
||||
|
||||
if not moduleinfo:
|
||||
logger.debug("No moduleinfo.json in %s, skipping", bundle_path)
|
||||
return results
|
||||
|
||||
# Find the shared library
|
||||
lib_path = ""
|
||||
arch_dir = "aarch64-linux" # RPi4B architecture
|
||||
for arch_candidate in [arch_dir, "x86_64-linux", "arm64-linux", "i386-linux"]:
|
||||
lib_dir = bundle_path / "Contents" / arch_candidate
|
||||
if lib_dir.is_dir():
|
||||
for so in lib_dir.glob("*.so"):
|
||||
lib_path = str(so)
|
||||
break
|
||||
if lib_path:
|
||||
break
|
||||
|
||||
if not lib_path:
|
||||
# Fallback: search for any .so in the bundle
|
||||
for so in bundle_path.rglob("*.so"):
|
||||
lib_path = str(so)
|
||||
break
|
||||
|
||||
# Extract factory info
|
||||
factory_info = moduleinfo.get("Factory Info", {}) if isinstance(moduleinfo, dict) else {}
|
||||
classes = moduleinfo.get("Classes", []) if isinstance(moduleinfo, dict) else []
|
||||
|
||||
for cls in classes:
|
||||
cid = cls.get("CID", "") or cls.get("cid", "")
|
||||
name = cls.get("Name", "") or cls.get("name", "") or bundle_name
|
||||
category_str = cls.get("Category", "") or cls.get("category", "")
|
||||
subcategories: list[str] = cls.get("Sub Categories", []) or cls.get("sub_categories", [])
|
||||
vendor = cls.get("Vendor", "") or cls.get("vendor", "") or factory_info.get("Vendor", "")
|
||||
version = cls.get("Version", "") or cls.get("version", "") or factory_info.get("Version", "")
|
||||
class_flags = cls.get("Class Flags", 0) or cls.get("class_flags", 0)
|
||||
is_instrument = bool(class_flags & 1) # kSimpleMode flag indicates instrument
|
||||
|
||||
# Parse ports from class info
|
||||
audio_inputs = 0
|
||||
audio_outputs = 0
|
||||
midi_inputs = 0
|
||||
midi_outputs = 0
|
||||
buses = cls.get("Buses", []) or cls.get("buses", [])
|
||||
for bus in buses:
|
||||
btype = bus.get("Type", "") or bus.get("type", "")
|
||||
direction = bus.get("Direction", "") or bus.get("direction", "")
|
||||
count = bus.get("Bus Count", 1) or bus.get("bus_count", 1)
|
||||
if btype == "Audio" or btype == "kAudio":
|
||||
if direction in ("Input", "kInput"):
|
||||
audio_inputs += count
|
||||
elif direction in ("Output", "kOutput"):
|
||||
audio_outputs += count
|
||||
elif btype == "Event" or btype == "kEvent":
|
||||
if direction in ("Input", "kInput"):
|
||||
midi_inputs += count
|
||||
elif direction in ("Output", "kOutput"):
|
||||
midi_outputs += count
|
||||
|
||||
uri = cid or f"urn:vst3:{bundle_name}:{_hash_str(name)}"
|
||||
categories = _vst3_subcategories_to_categories(subcategories)
|
||||
if not categories and is_instrument:
|
||||
categories = [PluginCategory.SYNTH]
|
||||
|
||||
# Determine ARM optimization
|
||||
arm_optimized = arch_dir in lib_path or "aarch64" in lib_path
|
||||
|
||||
info = PluginInfo(
|
||||
meta=PluginMeta(
|
||||
name=name,
|
||||
uri=uri,
|
||||
format=PluginFormat.VST3,
|
||||
version=version,
|
||||
author=vendor,
|
||||
license="",
|
||||
description=cls.get("Description", "") or cls.get("description", ""),
|
||||
homepage=cls.get("URL", "") or cls.get("url", ""),
|
||||
project=bundle_name,
|
||||
arch="aarch64",
|
||||
arm_optimized=arm_optimized,
|
||||
),
|
||||
categories=categories,
|
||||
audio_inputs=audio_inputs,
|
||||
audio_outputs=audio_outputs,
|
||||
midi_inputs=midi_inputs,
|
||||
midi_outputs=midi_outputs,
|
||||
bundle_path=str(bundle_path),
|
||||
library_path=lib_path,
|
||||
status=PluginStatus.ACTIVE,
|
||||
scanned_at=now,
|
||||
)
|
||||
results.append(info)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def scan_vst3(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||
"""Scan for VST3 plugins across the given paths."""
|
||||
if paths is None:
|
||||
paths = DEFAULT_VST3_PATHS
|
||||
|
||||
results: list[PluginInfo] = []
|
||||
|
||||
for base_path in paths:
|
||||
base = Path(base_path)
|
||||
if not base.is_dir():
|
||||
logger.debug("VST3 path not found, skipping: %s", base)
|
||||
continue
|
||||
|
||||
for bundle_dir in base.iterdir():
|
||||
if not bundle_dir.is_dir():
|
||||
continue
|
||||
if not bundle_dir.name.endswith(".vst3"):
|
||||
continue
|
||||
results.extend(_parse_vst3_bundle(bundle_dir))
|
||||
|
||||
logger.info("VST3 scan found %d plugins across %d paths", len(results), len(paths))
|
||||
return results
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# LADSPA Scanner
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_ladspa_rdf(rdf_path: Path) -> list[dict]:
|
||||
"""Parse a LADSPA RDF descriptor file."""
|
||||
if not rdf_path.exists():
|
||||
return []
|
||||
results: list[dict] = []
|
||||
current: dict | None = None
|
||||
try:
|
||||
text = rdf_path.read_text(errors="replace")
|
||||
except OSError:
|
||||
return results
|
||||
|
||||
for line in text.splitlines():
|
||||
stmt = _parse_turtle_statement(line)
|
||||
if stmt is None:
|
||||
continue
|
||||
subj, pred, obj = stmt
|
||||
|
||||
# LADSPA RDF uses ladspa:uniqueID patterns
|
||||
if pred.endswith("#a") and "ladspa#" in obj:
|
||||
current = {"uri": subj, "name": "", "id": 0}
|
||||
results.append(current)
|
||||
elif current:
|
||||
if pred.endswith("#label") or "rdfs#label" in pred:
|
||||
current["name"] = obj
|
||||
elif pred.endswith("#uniqueID"):
|
||||
try: current["id"] = int(obj)
|
||||
except ValueError: pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def scan_ladspa(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||
"""Scan for LADSPA plugins."""
|
||||
if paths is None:
|
||||
paths = DEFAULT_LADSPA_PATHS
|
||||
|
||||
results: list[PluginInfo] = []
|
||||
now = time.time()
|
||||
|
||||
for base_path in paths:
|
||||
base = Path(base_path)
|
||||
if not base.is_dir():
|
||||
continue
|
||||
|
||||
for so_file in base.rglob("*.so"):
|
||||
lib_path = str(so_file)
|
||||
name = so_file.stem.lstrip("lib")
|
||||
uri = f"urn:ladspa:{name}:{_hash_str(lib_path)}"
|
||||
|
||||
info = PluginInfo(
|
||||
meta=PluginMeta(
|
||||
name=name,
|
||||
uri=uri,
|
||||
format=PluginFormat.LADSPA,
|
||||
arch="aarch64",
|
||||
),
|
||||
categories=[PluginCategory.OTHER],
|
||||
bundle_path=str(base),
|
||||
library_path=lib_path,
|
||||
status=PluginStatus.ACTIVE,
|
||||
scanned_at=now,
|
||||
)
|
||||
results.append(info)
|
||||
|
||||
logger.info("LADSPA scan found %d plugins", len(results))
|
||||
return results
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# NAM Scanner
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_nam_file(nam_path: Path) -> dict | None:
|
||||
"""Parse a .nam file for embedded metadata.
|
||||
|
||||
NAM files store JSON config at the end of the binary blob.
|
||||
"""
|
||||
if not nam_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = nam_path.read_bytes()
|
||||
# NAM files store metadata as JSON at the end
|
||||
# Look for the JSON delimiter pattern
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
# Find JSON-like content at end
|
||||
brace_start = text.rfind('{"')
|
||||
if brace_start >= 0:
|
||||
json_str = text[brace_start:]
|
||||
# Find matching end brace
|
||||
depth = 0
|
||||
end = 0
|
||||
for i, ch in enumerate(json_str):
|
||||
if ch == '{': depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
if end > 0:
|
||||
return json.loads(json_str[:end])
|
||||
return {}
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def scan_nam(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||
"""Scan for .nam Neural Amp Modeler files."""
|
||||
if paths is None:
|
||||
paths = DEFAULT_NAM_PATHS
|
||||
|
||||
results: list[PluginInfo] = []
|
||||
now = time.time()
|
||||
|
||||
for base_path in paths:
|
||||
base = Path(base_path)
|
||||
if not base.is_dir():
|
||||
continue
|
||||
|
||||
for nam_file in base.rglob("*.nam"):
|
||||
nam_path = str(nam_file)
|
||||
metadata = _parse_nam_file(nam_file)
|
||||
name = metadata.get("name", nam_file.stem) if metadata else nam_file.stem
|
||||
|
||||
# Determine model size
|
||||
file_size_mb = nam_file.stat().st_size / (1024 * 1024)
|
||||
if file_size_mb < 1:
|
||||
model_size = "nano"
|
||||
elif file_size_mb < 10:
|
||||
model_size = "feather"
|
||||
elif file_size_mb < 50:
|
||||
model_size = "standard"
|
||||
else:
|
||||
model_size = "custom"
|
||||
|
||||
uri = f"urn:nam:{nam_file.stem}:{_hash_str(nam_path)}"
|
||||
|
||||
# Estimate CPU based on model size
|
||||
cpu_est = {"nano": 8.0, "feather": 18.0, "standard": 35.0, "custom": 50.0}.get(model_size, 30.0)
|
||||
|
||||
# Known good on RPi4B: nano and feather models
|
||||
rpi4b_good = model_size in ("nano", "feather")
|
||||
|
||||
info = PluginInfo(
|
||||
meta=PluginMeta(
|
||||
name=name,
|
||||
uri=uri,
|
||||
format=PluginFormat.NAM,
|
||||
version=metadata.get("version", "") if metadata else "",
|
||||
author=metadata.get("author", "") if metadata else "",
|
||||
description=metadata.get("description", "") if metadata else "",
|
||||
arch="aarch64",
|
||||
arm_optimized=True,
|
||||
),
|
||||
categories=[PluginCategory.NAM_PROFILE],
|
||||
bundle_path=str(nam_file.parent),
|
||||
library_path="", # NAM files don't have a .so
|
||||
status=PluginStatus.ACTIVE,
|
||||
scanned_at=now,
|
||||
estimated_cpu_pct=cpu_est,
|
||||
rpi4b_known_good=rpi4b_good,
|
||||
rpi4b_known_broken=(model_size == "standard"),
|
||||
nam_model_path=nam_path,
|
||||
nam_model_size=model_size,
|
||||
)
|
||||
results.append(info)
|
||||
|
||||
logger.info("NAM scan found %d models across %d paths", len(results), len(paths))
|
||||
return results
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Unified scanner
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def scan_all(
|
||||
lv2_paths: list[str] | None = None,
|
||||
vst3_paths: list[str] | None = None,
|
||||
ladspa_paths: list[str] | None = None,
|
||||
nam_paths: list[str] | None = None,
|
||||
) -> list[PluginInfo]:
|
||||
"""Run all scanners and return a unified list of PluginInfo objects."""
|
||||
results: list[PluginInfo] = []
|
||||
results.extend(scan_lv2(lv2_paths))
|
||||
results.extend(scan_vst3(vst3_paths))
|
||||
results.extend(scan_ladspa(ladspa_paths))
|
||||
results.extend(scan_nam(nam_paths))
|
||||
|
||||
# Sort by format, then name
|
||||
results.sort(key=lambda p: (p.meta.format.value, p.meta.name.lower()))
|
||||
return results
|
||||
|
||||
|
||||
def scan_format(
|
||||
format: PluginFormat,
|
||||
paths: list[str] | None = None,
|
||||
) -> list[PluginInfo]:
|
||||
"""Scan for plugins of a specific format only."""
|
||||
scanners = {
|
||||
PluginFormat.LV2: scan_lv2,
|
||||
PluginFormat.VST3: scan_vst3,
|
||||
PluginFormat.LADSPA: scan_ladspa,
|
||||
PluginFormat.NAM: scan_nam,
|
||||
}
|
||||
scanner = scanners.get(format)
|
||||
if scanner is None:
|
||||
logger.error("Unsupported format for scanning: %s", format)
|
||||
return []
|
||||
return scanner(paths)
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Plugin types, enums, and data classes for the RPi Mixer Plugin Manager.
|
||||
|
||||
Defines the canonical plugin representation: format, category, metadata,
|
||||
and the plugin registry entry model used throughout the plugin subsystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Plugin format enum ──────────────────────────────────────────────────────
|
||||
|
||||
class PluginFormat(enum.StrEnum):
|
||||
"""Supported plugin formats."""
|
||||
LV2 = "lv2"
|
||||
VST3 = "vst3"
|
||||
VST2 = "vst2"
|
||||
LADSPA = "ladspa"
|
||||
DSSI = "dssi"
|
||||
NAM = "nam" # Neural Amp Modeler (.nam files loaded via NAM LV2)
|
||||
|
||||
|
||||
# ── Plugin category enum ────────────────────────────────────────────────────
|
||||
|
||||
class PluginCategory(enum.StrEnum):
|
||||
"""Semantic categories for plugin classification.
|
||||
|
||||
Categories are derived from LV2 class URIs, VST3 subcategories,
|
||||
and best-effort heuristics for formats that lack standard taxonomies.
|
||||
"""
|
||||
AMP = "amp"
|
||||
AMP_BASS = "amp_bass"
|
||||
AMP_GUITAR = "amp_guitar"
|
||||
CABINET = "cabinet"
|
||||
IR_LOADER = "ir_loader"
|
||||
REVERB = "reverb"
|
||||
DELAY = "delay"
|
||||
CHORUS = "chorus"
|
||||
FLANGER = "flanger"
|
||||
PHASER = "phaser"
|
||||
TREMOLO = "tremolo"
|
||||
VIBRATO = "vibrato"
|
||||
COMPRESSOR = "compressor"
|
||||
LIMITER = "limiter"
|
||||
GATE = "gate"
|
||||
EXPANDER = "expander"
|
||||
EQ_PARAMETRIC = "eq_parametric"
|
||||
EQ_GRAPHIC = "eq_graphic"
|
||||
EQ_SHELVING = "eq_shelving"
|
||||
FILTER = "filter"
|
||||
DISTORTION = "distortion"
|
||||
OVERDRIVE = "overdrive"
|
||||
FUZZ = "fuzz"
|
||||
PITCH_SHIFTER = "pitch_shifter"
|
||||
OCTAVER = "octaver"
|
||||
WAH = "wah"
|
||||
SYNTH = "synth"
|
||||
SAMPLER = "sampler"
|
||||
DRUM_MACHINE = "drum_machine"
|
||||
UTILITY = "utility"
|
||||
METER = "meter"
|
||||
ANALYZER = "analyzer"
|
||||
SPATIAL = "spatial"
|
||||
NOISE_REDUCTION = "noise_reduction"
|
||||
NAM_PROFILE = "nam_profile" # NAM capture/profile
|
||||
OTHER = "other"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# ── Plugin status enum ──────────────────────────────────────────────────────
|
||||
|
||||
class PluginStatus(enum.StrEnum):
|
||||
"""Lifecycle status of a plugin in the registry."""
|
||||
ACTIVE = "active" # Installed and loadable
|
||||
DISABLED = "disabled" # Installed but manually disabled
|
||||
BLACKLISTED = "blacklisted" # Known-broken, blocked from loading
|
||||
STALE = "stale" # Files missing but registry entry exists
|
||||
REMOVED = "removed" # Soft-deleted (retained for history)
|
||||
ERROR = "error" # Scan/load failed with an error
|
||||
|
||||
|
||||
# ── Data classes ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PluginMeta:
|
||||
"""Core plugin identity and metadata."""
|
||||
name: str
|
||||
uri: str # Unique identifier (LV2 URI, VST3 UID, or file hash)
|
||||
format: PluginFormat
|
||||
version: str = ""
|
||||
author: str = ""
|
||||
license: str = ""
|
||||
description: str = ""
|
||||
homepage: str = ""
|
||||
project: str = "" # LV2 project or VST3 bundle name
|
||||
|
||||
# Platform info
|
||||
arch: str = "" # e.g. arm64, x86_64
|
||||
os: str = "linux"
|
||||
arm_optimized: bool = False # Has ARM/NEON flags
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PluginPort:
|
||||
"""A single audio/CV/control port on a plugin."""
|
||||
index: int
|
||||
symbol: str
|
||||
name: str
|
||||
direction: str = "input" # input, output
|
||||
port_type: str = "control" # audio, control, cv, midi
|
||||
default: float = 0.0
|
||||
minimum: float = 0.0
|
||||
maximum: float = 1.0
|
||||
enumeration: list[tuple[float, str]] = field(default_factory=list) # (value, label)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PluginInfo:
|
||||
"""Full plugin descriptor — the registry's canonical entry."""
|
||||
meta: PluginMeta
|
||||
|
||||
# Categories
|
||||
categories: list[PluginCategory] = field(default_factory=list)
|
||||
|
||||
# Ports
|
||||
audio_inputs: int = 0
|
||||
audio_outputs: int = 0
|
||||
midi_inputs: int = 0
|
||||
midi_outputs: int = 0
|
||||
control_ports: list[PluginPort] = field(default_factory=list)
|
||||
|
||||
# Filesystem
|
||||
bundle_path: str = "" # Absolute path to the plugin bundle
|
||||
library_path: str = "" # Absolute path to the shared object (.so)
|
||||
|
||||
# Status
|
||||
status: PluginStatus = PluginStatus.ACTIVE
|
||||
error_message: str = ""
|
||||
|
||||
# Timestamps
|
||||
scanned_at: float = 0.0 # Unix timestamp of last successful scan
|
||||
installed_at: float = 0.0 # Unix timestamp of installation
|
||||
updated_at: float = 0.0 # Unix timestamp of last update
|
||||
|
||||
# Resource estimation for RPi4B
|
||||
estimated_cpu_pct: float = 0.0 # Rough CPU % on RPi4B (0 = unknown)
|
||||
estimated_ram_mb: float = 0.0 # Rough RAM usage in MB (0 = unknown)
|
||||
rpi4b_known_good: bool = False # Verified working on RPi4B
|
||||
rpi4b_known_broken: bool = False # Known to break on RPi4B
|
||||
|
||||
# NAM-specific
|
||||
nam_model_path: str = "" # Path to .nam model file (if NAM plugin)
|
||||
nam_model_size: str = "" # nano, feather, standard, custom
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize to a flat dict for registry storage."""
|
||||
return {
|
||||
"uri": self.meta.uri,
|
||||
"name": self.meta.name,
|
||||
"format": self.meta.format.value,
|
||||
"version": self.meta.version,
|
||||
"author": self.meta.author,
|
||||
"license": self.meta.license,
|
||||
"description": self.meta.description,
|
||||
"homepage": self.meta.homepage,
|
||||
"project": self.meta.project,
|
||||
"arch": self.meta.arch,
|
||||
"os": self.meta.os,
|
||||
"arm_optimized": int(self.meta.arm_optimized),
|
||||
"categories": ",".join(c.value for c in self.categories),
|
||||
"audio_inputs": self.audio_inputs,
|
||||
"audio_outputs": self.audio_outputs,
|
||||
"midi_inputs": self.midi_inputs,
|
||||
"midi_outputs": self.midi_outputs,
|
||||
"bundle_path": self.bundle_path,
|
||||
"library_path": self.library_path,
|
||||
"status": self.status.value,
|
||||
"error_message": self.error_message,
|
||||
"scanned_at": self.scanned_at,
|
||||
"installed_at": self.installed_at,
|
||||
"updated_at": self.updated_at,
|
||||
"estimated_cpu_pct": self.estimated_cpu_pct,
|
||||
"estimated_ram_mb": self.estimated_ram_mb,
|
||||
"rpi4b_known_good": int(self.rpi4b_known_good),
|
||||
"rpi4b_known_broken": int(self.rpi4b_known_broken),
|
||||
"nam_model_path": self.nam_model_path,
|
||||
"nam_model_size": self.nam_model_size,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> PluginInfo:
|
||||
"""Deserialize from a flat dict (registry row)."""
|
||||
meta = PluginMeta(
|
||||
name=d.get("name", ""),
|
||||
uri=d.get("uri", ""),
|
||||
format=PluginFormat(d.get("format", "lv2")),
|
||||
version=d.get("version", ""),
|
||||
author=d.get("author", ""),
|
||||
license=d.get("license", ""),
|
||||
description=d.get("description", ""),
|
||||
homepage=d.get("homepage", ""),
|
||||
project=d.get("project", ""),
|
||||
arch=d.get("arch", ""),
|
||||
os=d.get("os", "linux"),
|
||||
arm_optimized=bool(d.get("arm_optimized", 0)),
|
||||
)
|
||||
cats_raw = d.get("categories", "")
|
||||
categories = [PluginCategory(c) for c in cats_raw.split(",") if c] if cats_raw else []
|
||||
return cls(
|
||||
meta=meta,
|
||||
categories=categories,
|
||||
audio_inputs=d.get("audio_inputs", 0),
|
||||
audio_outputs=d.get("audio_outputs", 0),
|
||||
midi_inputs=d.get("midi_inputs", 0),
|
||||
midi_outputs=d.get("midi_outputs", 0),
|
||||
bundle_path=d.get("bundle_path", ""),
|
||||
library_path=d.get("library_path", ""),
|
||||
status=PluginStatus(d.get("status", "active")),
|
||||
error_message=d.get("error_message", ""),
|
||||
scanned_at=d.get("scanned_at", 0.0),
|
||||
installed_at=d.get("installed_at", 0.0),
|
||||
updated_at=d.get("updated_at", 0.0),
|
||||
estimated_cpu_pct=d.get("estimated_cpu_pct", 0.0),
|
||||
estimated_ram_mb=d.get("estimated_ram_mb", 0.0),
|
||||
rpi4b_known_good=bool(d.get("rpi4b_known_good", 0)),
|
||||
rpi4b_known_broken=bool(d.get("rpi4b_known_broken", 0)),
|
||||
nam_model_path=d.get("nam_model_path", ""),
|
||||
nam_model_size=d.get("nam_model_size", ""),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_loadable(self) -> bool:
|
||||
"""True if this plugin should be loadable at runtime."""
|
||||
return self.status == PluginStatus.ACTIVE
|
||||
|
||||
@property
|
||||
def is_virtual_instrument(self) -> bool:
|
||||
"""Heuristic: true if this looks like an instrument (not an effect)."""
|
||||
inst_cats = {
|
||||
PluginCategory.SYNTH, PluginCategory.SAMPLER,
|
||||
PluginCategory.DRUM_MACHINE,
|
||||
}
|
||||
return bool(set(self.categories) & inst_cats) or self.audio_inputs == 0
|
||||
|
||||
@property
|
||||
def is_effect(self) -> bool:
|
||||
"""Heuristic: true if this looks like an effect (has audio I/O)."""
|
||||
return self.audio_inputs > 0 and self.audio_outputs > 0
|
||||
|
||||
@property
|
||||
def nam_model_uri(self) -> str | None:
|
||||
"""Return the .nam file URI if this is a NAM plugin with a model."""
|
||||
if self.meta.format == PluginFormat.NAM and self.nam_model_path:
|
||||
return self.nam_model_path
|
||||
return None
|
||||
|
||||
|
||||
# ── Bundle types ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PluginBundle:
|
||||
"""A downloadable/installable plugin bundle."""
|
||||
name: str
|
||||
uri: str
|
||||
format: PluginFormat
|
||||
version: str
|
||||
source_url: str # Download URL
|
||||
source_type: str = "tar.gz" # tar.gz, zip, deb, git
|
||||
checksum_sha256: str = ""
|
||||
size_bytes: int = 0
|
||||
dependencies: list[str] = field(default_factory=list)
|
||||
build_script: str = "" # Path to build script relative to extract root
|
||||
install_paths: dict[str, str] = field(default_factory=dict) # src -> dest mapping
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Touchscreen UI for the Raspberry Pi RT Audio Mixer.
|
||||
|
||||
Provides a Kivy-based multi-touch mixer control surface for
|
||||
Raspberry Pi official 7" and Waveshare 5" touch displays.
|
||||
|
||||
Modules:
|
||||
app — Kivy App entry point + ScreenManager
|
||||
ipc — REST + OSC client for mixer engine communication
|
||||
theme — DPI-aware colors, fonts, sizes
|
||||
screens — Mixer surface, routing matrix, plugin chain, settings
|
||||
widgets — Faders, knobs, meters, buttons
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
"""Kivy application entry point for the touchscreen mixer UI.
|
||||
|
||||
Provides a ScreenManager with four screens:
|
||||
- Mixer surface (faders, meters, mute/solo)
|
||||
- Routing matrix
|
||||
- Plugin chain
|
||||
- Settings (brightness, timeout, DPI)
|
||||
|
||||
Designed for Raspberry Pi 5"/7" touch displays.
|
||||
Runs fullscreen at native resolution with DPI-aware layout.
|
||||
|
||||
Usage:
|
||||
python3 main_touch.py # connect to localhost:8000
|
||||
python3 main_touch.py --host 192.168.1.10 # remote mixer
|
||||
KIVY_DPI=220 python3 main_touch.py # override DPI
|
||||
|
||||
Environment:
|
||||
MIXER_HOST — mixer server hostname (default 127.0.0.1)
|
||||
MIXER_PORT — REST API port (default 8000)
|
||||
MIXER_OSC_PORT — OSC server port (default 9001)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from kivy.config import Config
|
||||
from kivy.core.window import Window
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.screenmanager import ScreenManager, Screen, FadeTransition, SlideTransition
|
||||
from kivy.metrics import dp
|
||||
|
||||
from .ipc import MixerClient
|
||||
from .theme import Colors, Fonts, Sizes
|
||||
from .screens.mixer import MixerScreen
|
||||
from .screens.routing import RoutingScreen
|
||||
from .screens.plugins import PluginScreen
|
||||
from .screens.settings import SettingsScreen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Touch-optimized Kivy config ──────────────────────────────────────────────
|
||||
|
||||
def _configure_kivy():
|
||||
"""Apply touch-optimized Kivy settings before window creation."""
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Config.set("kivy", "window_icon", "")
|
||||
Config.set("kivy", "exit_on_escape", 0) # No accidental exit
|
||||
Config.set("graphics", "fullscreen", "auto")
|
||||
Config.set("graphics", "show_cursor", 0) # Hide cursor on touchscreen
|
||||
Config.set("graphics", "resizable", 0) # Fixed size for embedded
|
||||
|
||||
# Allow environment overrides
|
||||
if os.environ.get("KIVY_SHOW_CURSOR"):
|
||||
Config.set("graphics", "show_cursor", 1)
|
||||
|
||||
|
||||
# ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TabBar(BoxLayout):
|
||||
"""Bottom tab bar for screen switching.
|
||||
|
||||
Four tabs: Mixer, Routing, Plugins, Settings.
|
||||
Touch-optimized: large hit targets, clear active indicator.
|
||||
"""
|
||||
|
||||
screen_manager = ObjectProperty(None)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.orientation = "horizontal"
|
||||
self.size_hint = (1, None)
|
||||
self.height = Sizes.TAB_BAR_HEIGHT
|
||||
self.spacing = 0
|
||||
|
||||
self._tabs: dict[str, Button] = {}
|
||||
self._active_tab = "mixer"
|
||||
|
||||
tabs = [
|
||||
("mixer", "MIX"),
|
||||
("routing", "RTG"),
|
||||
("plugins", "PLG"),
|
||||
("settings", "SET"),
|
||||
]
|
||||
for name, label in tabs:
|
||||
btn = Button(
|
||||
text=label,
|
||||
background_normal="",
|
||||
background_color=Colors.BG_HEADER,
|
||||
color=Colors.ACCENT if name == "mixer" else Colors.TEXT_MUTED,
|
||||
font_size=Fonts.SMALL,
|
||||
bold=True,
|
||||
)
|
||||
btn.bind(on_press=self._make_switch(name))
|
||||
self._tabs[name] = btn
|
||||
self.add_widget(btn)
|
||||
|
||||
def _make_switch(self, screen_name: str):
|
||||
def cb(instance):
|
||||
if self.screen_manager:
|
||||
self.screen_manager.current = screen_name
|
||||
self._set_active(screen_name)
|
||||
return cb
|
||||
|
||||
def _set_active(self, name: str):
|
||||
self._active_tab = name
|
||||
for tab_name, btn in self._tabs.items():
|
||||
if tab_name == name:
|
||||
btn.color = Colors.ACCENT
|
||||
btn.font_size = Fonts.BODY
|
||||
else:
|
||||
btn.color = Colors.TEXT_MUTED
|
||||
btn.font_size = Fonts.SMALL
|
||||
|
||||
def switch_to(self, name: str):
|
||||
"""External switch (e.g., from gesture)."""
|
||||
self._set_active(name)
|
||||
if self.screen_manager:
|
||||
self.screen_manager.current = name
|
||||
|
||||
|
||||
# ── Root widget ──────────────────────────────────────────────────────────────
|
||||
|
||||
class MixerRoot(BoxLayout):
|
||||
"""Root layout: header + screen area + tab bar."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.orientation = "vertical"
|
||||
|
||||
# Status bar (top)
|
||||
self._status_bar = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.HEADER_HEIGHT,
|
||||
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||
)
|
||||
self._status_bar.bg_color = Colors.BG_HEADER
|
||||
|
||||
self._status_label = Label(
|
||||
text="● Connected",
|
||||
color=Colors.ACTIVE,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(None, 1),
|
||||
width=dp(120),
|
||||
halign="left",
|
||||
)
|
||||
self._status_label.bind(size=self._status_label.setter("text_size"))
|
||||
|
||||
self._time_label = Label(
|
||||
text="",
|
||||
color=Colors.TEXT_MUTED,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(1, 1),
|
||||
halign="right",
|
||||
)
|
||||
|
||||
self._status_bar.add_widget(self._status_label)
|
||||
self._status_bar.add_widget(self._time_label)
|
||||
|
||||
# Screen manager (center)
|
||||
self.screen_manager = ScreenManager(transition=FadeTransition(duration=0.2))
|
||||
|
||||
# Screens
|
||||
self.mixer_screen = MixerScreen(name="mixer")
|
||||
self.routing_screen = RoutingScreen(name="routing")
|
||||
self.plugin_screen = PluginScreen(name="plugins")
|
||||
self.settings_screen = SettingsScreen(name="settings")
|
||||
|
||||
self.screen_manager.add_widget(self.mixer_screen)
|
||||
self.screen_manager.add_widget(self.routing_screen)
|
||||
self.screen_manager.add_widget(self.plugin_screen)
|
||||
self.screen_manager.add_widget(self.settings_screen)
|
||||
|
||||
# Tab bar (bottom)
|
||||
self.tab_bar = TabBar(screen_manager=self.screen_manager)
|
||||
|
||||
self.add_widget(self._status_bar)
|
||||
self.add_widget(self.screen_manager)
|
||||
self.add_widget(self.tab_bar)
|
||||
|
||||
def set_client(self, client: MixerClient):
|
||||
"""Inject the mixer client into all screens."""
|
||||
self.mixer_screen.client = client
|
||||
self.routing_screen.client = client
|
||||
self.plugin_screen.client = client
|
||||
self.settings_screen.client = client
|
||||
|
||||
def update_status(self, connected: bool, info: str = ""):
|
||||
"""Update the status bar."""
|
||||
if connected:
|
||||
self._status_label.text = "● Connected" + (f" {info}" if info else "")
|
||||
self._status_label.color = Colors.ACTIVE
|
||||
else:
|
||||
self._status_label.text = "○ Disconnected"
|
||||
self._status_label.color = Colors.MUTE_ON
|
||||
|
||||
|
||||
# ── Kivy App ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class MixerApp(App):
|
||||
"""Touchscreen mixer application.
|
||||
|
||||
Args to build():
|
||||
host: Mixer server hostname.
|
||||
rest_port: REST API port.
|
||||
osc_port: OSC server port.
|
||||
api_key: API key for authentication.
|
||||
"""
|
||||
|
||||
client: MixerClient = None # type: ignore
|
||||
|
||||
def __init__(self, host="127.0.0.1", rest_port=8000, osc_port=9001, api_key="", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._host = host
|
||||
self._rest_port = rest_port
|
||||
self._osc_port = osc_port
|
||||
self._api_key = api_key
|
||||
|
||||
def build(self):
|
||||
"""Build the UI and return the root widget."""
|
||||
_configure_kivy()
|
||||
|
||||
# Set window properties
|
||||
Window.clearcolor = Colors.BG_DARK
|
||||
Window.bind(on_key_down=self._on_key_down)
|
||||
|
||||
self.client = MixerClient(
|
||||
host=self._host,
|
||||
rest_port=self._rest_port,
|
||||
osc_port=self._osc_port,
|
||||
api_key=self._api_key,
|
||||
)
|
||||
|
||||
self.root_widget = MixerRoot()
|
||||
self.root_widget.set_client(self.client)
|
||||
|
||||
# Periodic health check
|
||||
Clock.schedule_interval(self._health_check, 5.0)
|
||||
|
||||
# Update clock
|
||||
Clock.schedule_interval(self._update_clock, 1.0)
|
||||
|
||||
return self.root_widget
|
||||
|
||||
def _health_check(self, dt):
|
||||
"""Check mixer server connectivity."""
|
||||
self.client.fetch_state(
|
||||
on_success=lambda state: self.root_widget.update_status(True),
|
||||
on_error=lambda e: self.root_widget.update_status(False, str(e)[:20]),
|
||||
)
|
||||
|
||||
def _update_clock(self, dt):
|
||||
"""Update the time display."""
|
||||
import time
|
||||
t = time.localtime()
|
||||
self.root_widget._time_label.text = time.strftime("%H:%M:%S", t)
|
||||
|
||||
def _on_key_down(self, window, key, scancode, codepoint, modifier):
|
||||
"""Handle keyboard shortcuts."""
|
||||
# ESC to toggle between screens
|
||||
if key == 27: # ESC
|
||||
sm = self.root_widget.screen_manager
|
||||
screens = sm.screen_names
|
||||
idx = screens.index(sm.current)
|
||||
next_idx = (idx + 1) % len(screens)
|
||||
sm.current = screens[next_idx]
|
||||
self.root_widget.tab_bar.switch_to(screens[next_idx])
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_stop(self):
|
||||
"""Cleanup on app exit."""
|
||||
if self.client:
|
||||
self.client.close()
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
"""IPC client for the touchscreen UI.
|
||||
|
||||
Communicates with the mixer engine via:
|
||||
- REST API (GET /state, PUT /channel/{id}/parameter, etc.)
|
||||
- OSC UDP (real-time parameter control, optional fallback)
|
||||
|
||||
Designed to run locally on the Raspberry Pi alongside the mixer server.
|
||||
All calls are non-blocking via Kivy's UrlRequest; OSC runs on a
|
||||
background thread with Kivy clock callbacks for thread safety.
|
||||
|
||||
Usage:
|
||||
from src.ui.ipc import MixerClient
|
||||
|
||||
client = MixerClient(host="127.0.0.1", rest_port=8000)
|
||||
client.fetch_state(on_success=handle_state)
|
||||
client.set_parameter("volume", 0.75, channel=3)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import Kivy UrlRequest — works on any thread, callbacks fire on main
|
||||
try:
|
||||
from kivy.network.urlrequest import UrlRequest
|
||||
KIVY_AVAILABLE = True
|
||||
except ImportError:
|
||||
KIVY_AVAILABLE = False
|
||||
UrlRequest = None # type: ignore
|
||||
|
||||
|
||||
# ── OSC helpers (pure Python, no deps beyond stdlib) ─────────────────────────
|
||||
|
||||
def _pad_4(s: bytes) -> bytes:
|
||||
rem = len(s) % 4
|
||||
return s + b"\x00" * (4 - rem) if rem else s
|
||||
|
||||
|
||||
def _osc_string(s: str) -> bytes:
|
||||
return _pad_4(s.encode("utf-8") + b"\x00")
|
||||
|
||||
|
||||
def _osc_float(v: float) -> bytes:
|
||||
import struct
|
||||
return struct.pack(">f", v)
|
||||
|
||||
|
||||
def encode_osc(address: str, *args) -> bytes:
|
||||
"""Encode an OSC 1.0 message. Handles float, int, string."""
|
||||
import struct
|
||||
|
||||
type_tag = ","
|
||||
for arg in args:
|
||||
if isinstance(arg, float):
|
||||
type_tag += "f"
|
||||
elif isinstance(arg, int):
|
||||
type_tag += "i"
|
||||
elif isinstance(arg, str):
|
||||
type_tag += "s"
|
||||
else:
|
||||
type_tag += "s"
|
||||
|
||||
packet = _osc_string(address) + _osc_string(type_tag)
|
||||
for arg in args:
|
||||
if isinstance(arg, float):
|
||||
packet += struct.pack(">f", arg)
|
||||
elif isinstance(arg, int):
|
||||
packet += struct.pack(">i", arg)
|
||||
elif isinstance(arg, str):
|
||||
packet += _osc_string(str(arg))
|
||||
return packet
|
||||
|
||||
|
||||
# ── Mixer IPC client ─────────────────────────────────────────────────────────
|
||||
|
||||
class MixerClient:
|
||||
"""Non-blocking IPC client for the mixer engine.
|
||||
|
||||
Uses Kivy's UrlRequest for REST calls (non-blocking, main-thread
|
||||
callbacks) and raw UDP for OSC (background thread, Kivy clock
|
||||
for thread-safe callbacks).
|
||||
|
||||
Attributes:
|
||||
host: Mixer server hostname (default 127.0.0.1).
|
||||
rest_port: REST API port (default 8000).
|
||||
osc_port: OSC server port (default 9001).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
rest_port: int = 8000,
|
||||
osc_port: int = 9001,
|
||||
api_key: str = "",
|
||||
):
|
||||
self.host = host
|
||||
self.rest_port = rest_port
|
||||
self.osc_port = osc_port
|
||||
self.api_key = api_key
|
||||
self._base_url = f"http://{host}:{rest_port}"
|
||||
self._osc_sock: Optional[socket.socket] = None
|
||||
self._osc_lock = threading.Lock()
|
||||
self._pending_requests: set[UrlRequest] = set() if KIVY_AVAILABLE else set() # type: ignore
|
||||
|
||||
# ── REST API ─────────────────────────────────────────────────────────
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}{path}"
|
||||
|
||||
def _headers(self) -> dict:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
h["X-API-Key"] = self.api_key
|
||||
return h
|
||||
|
||||
def fetch_state(
|
||||
self,
|
||||
on_success: Callable[[dict], None],
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
on_failure: Optional[Callable[[UrlRequest], None]] = None, # type: ignore
|
||||
) -> None:
|
||||
"""GET /state — fetch full mixer state.
|
||||
|
||||
Args:
|
||||
on_success: Called with the parsed JSON dict on 2xx.
|
||||
on_error: Called with an error string on non-2xx.
|
||||
on_failure: Called with the UrlRequest on connection failure.
|
||||
"""
|
||||
if not KIVY_AVAILABLE:
|
||||
_sync_fetch(self._url("/state"), on_success, on_error)
|
||||
return
|
||||
|
||||
def _ok(req, result):
|
||||
on_success(result)
|
||||
|
||||
def _err(req, error):
|
||||
msg = f"REST error: {error}"
|
||||
logger.warning(msg)
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
|
||||
def _fail(req):
|
||||
msg = f"REST failure: {req.error}"
|
||||
logger.warning(msg)
|
||||
if on_failure:
|
||||
on_failure(req)
|
||||
elif on_error:
|
||||
on_error(msg)
|
||||
|
||||
UrlRequest(
|
||||
self._url("/state"),
|
||||
on_success=_ok,
|
||||
on_error=_err,
|
||||
on_failure=_fail,
|
||||
req_headers=self._headers(),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
def fetch_channels(
|
||||
self,
|
||||
on_success: Callable[[list], None],
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""GET /channels — list all channel states."""
|
||||
self._simple_get("/channels", on_success, on_error, expect_list=True)
|
||||
|
||||
def fetch_routing(
|
||||
self,
|
||||
on_success: Callable[[dict], None],
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""GET /routing — fetch routing matrix state."""
|
||||
self._simple_get("/routing", on_success, on_error)
|
||||
|
||||
def fetch_plugins(
|
||||
self,
|
||||
on_success: Callable[[list], None],
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""GET /plugins — list all plugins."""
|
||||
self._simple_get("/plugins", on_success, on_error, expect_list=True)
|
||||
|
||||
def set_parameter(
|
||||
self,
|
||||
param_type: str,
|
||||
value: float,
|
||||
channel: int = -1,
|
||||
on_success: Optional[Callable[[dict], None]] = None,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""PUT /channels/{id}/parameter — set a mixer parameter.
|
||||
|
||||
Args:
|
||||
param_type: e.g., 'volume', 'mute', 'pan', 'solo'.
|
||||
value: New value.
|
||||
channel: Channel index (0-based), -1 for master.
|
||||
"""
|
||||
body = json.dumps({"param_type": param_type, "value": value})
|
||||
url = (
|
||||
self._url(f"/channels/{channel}/parameter")
|
||||
if channel >= 0
|
||||
else self._url("/mixes/parameter")
|
||||
)
|
||||
self._send_put(url, body, on_success, on_error)
|
||||
|
||||
def set_master_parameter(
|
||||
self,
|
||||
param_type: str,
|
||||
value: float,
|
||||
on_success: Optional[Callable[[dict], None]] = None,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""PUT /mixes/parameter — set master parameter."""
|
||||
body = json.dumps({"param_type": param_type, "value": value})
|
||||
self._send_put(self._url("/mixes/parameter"), body, on_success, on_error)
|
||||
|
||||
def transport_command(
|
||||
self,
|
||||
command: str,
|
||||
on_success: Optional[Callable[[dict], None]] = None,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""PUT /transport/command — play/stop/record/loop."""
|
||||
body = json.dumps({"command": command})
|
||||
self._send_put(self._url("/transport/command"), body, on_success, on_error)
|
||||
|
||||
def _simple_get(
|
||||
self,
|
||||
path: str,
|
||||
on_success: Callable,
|
||||
on_error: Optional[Callable[[str], None]],
|
||||
expect_list: bool = False,
|
||||
) -> None:
|
||||
if not KIVY_AVAILABLE:
|
||||
result = _sync_fetch(self._url(path), on_success, on_error)
|
||||
return
|
||||
|
||||
def _ok(req, result):
|
||||
data = result if not expect_list else result
|
||||
on_success(data)
|
||||
|
||||
def _err(req, error):
|
||||
msg = f"REST GET {path}: {error}"
|
||||
logger.warning(msg)
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
|
||||
def _fail(req):
|
||||
msg = f"REST GET {path} failure: {req.error}"
|
||||
logger.warning(msg)
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
|
||||
UrlRequest(
|
||||
self._url(path),
|
||||
on_success=_ok,
|
||||
on_error=_err,
|
||||
on_failure=_fail,
|
||||
req_headers=self._headers(),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
def _send_put(
|
||||
self,
|
||||
url: str,
|
||||
body: str,
|
||||
on_success: Optional[Callable[[dict], None]],
|
||||
on_error: Optional[Callable[[str], None]],
|
||||
) -> None:
|
||||
if not KIVY_AVAILABLE:
|
||||
_sync_put(url, body, on_success, on_error)
|
||||
return
|
||||
|
||||
def _ok(req, result):
|
||||
if on_success:
|
||||
on_success(result)
|
||||
|
||||
def _err(req, error):
|
||||
msg = f"REST PUT error: {error}"
|
||||
logger.warning(msg)
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
|
||||
def _fail(req):
|
||||
msg = f"REST PUT failure: {req.error}"
|
||||
logger.warning(msg)
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
|
||||
UrlRequest(
|
||||
url,
|
||||
method="PUT",
|
||||
req_body=body,
|
||||
on_success=_ok,
|
||||
on_error=_err,
|
||||
on_failure=_fail,
|
||||
req_headers=self._headers(),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# ── OSC (optional raw UDP channel) ────────────────────────────────────
|
||||
|
||||
def osc_send(self, address: str, *args) -> bool:
|
||||
"""Send a raw OSC message to the mixer OSC server (port 9001).
|
||||
|
||||
Use for real-time parameter changes where REST latency
|
||||
is too high. Fire-and-forget — no response expected.
|
||||
|
||||
Returns:
|
||||
True if the packet was queued to the socket.
|
||||
"""
|
||||
try:
|
||||
packet = encode_osc(address, *args)
|
||||
with self._osc_lock:
|
||||
if self._osc_sock is None:
|
||||
self._osc_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._osc_sock.settimeout(0.1)
|
||||
self._osc_sock.sendto(packet, (self.host, self.osc_port))
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("OSC send failed: %s", e)
|
||||
return False
|
||||
|
||||
def osc_set_channel_param(self, channel: int, param: str, value: float) -> bool:
|
||||
"""Send a channel parameter change via OSC.
|
||||
|
||||
Example:
|
||||
client.osc_set_channel_param(3, "volume", -6.0)
|
||||
# Sends: /mixer/channel/3/volume -6.0
|
||||
"""
|
||||
return self.osc_send(f"/mixer/channel/{channel}/{param}", float(value))
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_all(self) -> None:
|
||||
"""Cancel all pending REST requests."""
|
||||
for req in list(self._pending_requests):
|
||||
try:
|
||||
req.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._pending_requests.clear()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close OSC socket and cancel requests."""
|
||||
self.cancel_all()
|
||||
with self._osc_lock:
|
||||
if self._osc_sock:
|
||||
try:
|
||||
self._osc_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._osc_sock = None
|
||||
|
||||
|
||||
# ── Synchronous fallback (for testing without Kivy) ─────────────────────────
|
||||
|
||||
def _sync_fetch(
|
||||
url: str,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Fallback synchronous GET using urllib."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if on_success:
|
||||
on_success(data)
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
msg = f"HTTP {e.code}: {e.reason}"
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
return None
|
||||
|
||||
|
||||
def _sync_put(
|
||||
url: str,
|
||||
body: str,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""Fallback synchronous PUT using urllib."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
try:
|
||||
data = body.encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
if on_success:
|
||||
on_success(result)
|
||||
except urllib.error.HTTPError as e:
|
||||
msg = f"HTTP {e.code}: {e.reason}"
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if on_error:
|
||||
on_error(msg)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Touchscreen mixer screens."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Main mixer surface screen — faders, mute/solo, pan per channel.
|
||||
|
||||
This is the primary screen showing all channel strips in a
|
||||
horizontally scrollable layout with faders, meters, mute/solo
|
||||
buttons, and pan knobs at the top.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.scrollview import ScrollView
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.metrics import dp
|
||||
|
||||
from ..theme import Colors, Fonts, Sizes
|
||||
from ..widgets.fader import FaderWidget
|
||||
from ..widgets.knob import KnobWidget
|
||||
|
||||
|
||||
class ChannelStrip(BoxLayout):
|
||||
"""One channel strip: fader + mute/solo + pan knob.
|
||||
|
||||
Layout (top to bottom):
|
||||
pan knob
|
||||
mute button
|
||||
solo button
|
||||
fader (with meter)
|
||||
channel label
|
||||
"""
|
||||
|
||||
def __init__(self, channel_index: int, label: str = "", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.orientation = "vertical"
|
||||
self.size_hint = (None, 1)
|
||||
self.width = Sizes.FADER_WIDTH + dp(16)
|
||||
self.spacing = dp(4)
|
||||
self.padding = [dp(6), dp(4), dp(6), dp(4)]
|
||||
|
||||
self.channel_index = channel_index
|
||||
color = Colors.FADER_COLORS[channel_index % len(Colors.FADER_COLORS)]
|
||||
|
||||
# Pan knob
|
||||
self.pan_knob = KnobWidget(
|
||||
label="PAN",
|
||||
min_val=-1.0,
|
||||
max_val=1.0,
|
||||
default_val=0.0,
|
||||
color=list(color),
|
||||
parameter_name="pan",
|
||||
size_hint=(None, None),
|
||||
size=(Sizes.KNOB_SIZE + dp(16), Sizes.KNOB_SIZE + dp(24)),
|
||||
)
|
||||
|
||||
# Mute/Solo buttons row
|
||||
btn_row = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.MUTE_SOLO_BUTTON,
|
||||
spacing=dp(4),
|
||||
)
|
||||
|
||||
self.mute_btn = Button(
|
||||
text="M",
|
||||
size_hint=(1, 1),
|
||||
background_normal="",
|
||||
background_color=(0.15, 0.15, 0.22, 1),
|
||||
color=Colors.TEXT_SECONDARY,
|
||||
font_size=Fonts.SMALL,
|
||||
bold=True,
|
||||
)
|
||||
self.mute_btn.bind(on_press=self._toggle_mute)
|
||||
|
||||
self.solo_btn = Button(
|
||||
text="S",
|
||||
size_hint=(1, 1),
|
||||
background_normal="",
|
||||
background_color=(0.15, 0.15, 0.22, 1),
|
||||
color=Colors.TEXT_SECONDARY,
|
||||
font_size=Fonts.SMALL,
|
||||
bold=True,
|
||||
)
|
||||
self.solo_btn.bind(on_press=self._toggle_solo)
|
||||
|
||||
btn_row.add_widget(self.mute_btn)
|
||||
btn_row.add_widget(self.solo_btn)
|
||||
|
||||
# Fader
|
||||
self.fader = FaderWidget(
|
||||
label=label or f"CH {channel_index + 1}",
|
||||
color=list(color),
|
||||
channel_index=channel_index,
|
||||
)
|
||||
|
||||
# Channel label
|
||||
self.ch_label = Label(
|
||||
text=label or f"CH{channel_index + 1}",
|
||||
color=Colors.TEXT_SECONDARY,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(1, None),
|
||||
height=dp(20),
|
||||
halign="center",
|
||||
)
|
||||
|
||||
self.add_widget(self.pan_knob)
|
||||
self.add_widget(btn_row)
|
||||
self.add_widget(self.fader)
|
||||
self.add_widget(self.ch_label)
|
||||
|
||||
# State
|
||||
self.muted = False
|
||||
self.soloed = False
|
||||
|
||||
def _toggle_mute(self, instance):
|
||||
self.muted = not self.muted
|
||||
self.fader.mute = self.muted
|
||||
self.mute_btn.background_color = Colors.MUTE_ON if self.muted else (0.15, 0.15, 0.22, 1)
|
||||
self.mute_btn.color = (1, 1, 1, 1) if self.muted else Colors.TEXT_SECONDARY
|
||||
|
||||
def _toggle_solo(self, instance):
|
||||
self.soloed = not self.soloed
|
||||
self.fader.solo = self.soloed
|
||||
self.solo_btn.background_color = Colors.SOLO_ON if self.soloed else (0.15, 0.15, 0.22, 1)
|
||||
self.solo_btn.color = (0, 0, 0, 1) if self.soloed else Colors.TEXT_SECONDARY
|
||||
|
||||
def update_state(self, channel_data: dict) -> None:
|
||||
"""Update widget state from mixer state dict.
|
||||
|
||||
Args:
|
||||
channel_data: Dict from ChannelSchema or similar:
|
||||
{volume_db, pan, mute, solo, ...}
|
||||
"""
|
||||
self.fader.value = channel_data.get("volume_db", 0.0)
|
||||
self.muted = channel_data.get("mute", False)
|
||||
self.soloed = channel_data.get("solo", False)
|
||||
self.fader.mute = self.muted
|
||||
self.fader.solo = self.soloed
|
||||
self.pan_knob.value = channel_data.get("pan", 0.0)
|
||||
|
||||
self.mute_btn.background_color = Colors.MUTE_ON if self.muted else (0.15, 0.15, 0.22, 1)
|
||||
self.mute_btn.color = (1, 1, 1, 1) if self.muted else Colors.TEXT_SECONDARY
|
||||
self.solo_btn.background_color = Colors.SOLO_ON if self.soloed else (0.15, 0.15, 0.22, 1)
|
||||
self.solo_btn.color = (0, 0, 0, 1) if self.soloed else Colors.TEXT_SECONDARY
|
||||
|
||||
|
||||
class MixerScreen(Screen):
|
||||
"""Main mixer surface with scrollable channel strips.
|
||||
|
||||
Shows the master fader on the right side.
|
||||
"""
|
||||
|
||||
client = ObjectProperty(None)
|
||||
strips: list[ChannelStrip] = []
|
||||
_refresh_event = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.name = "mixer"
|
||||
|
||||
root = BoxLayout(orientation="vertical")
|
||||
self._root = root
|
||||
|
||||
# ── Header ──
|
||||
header = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.HEADER_HEIGHT,
|
||||
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||
)
|
||||
header.bg_color = Colors.BG_HEADER
|
||||
title = Label(
|
||||
text="MIXER",
|
||||
color=Colors.ACCENT,
|
||||
font_size=Fonts.HEADER,
|
||||
bold=True,
|
||||
size_hint=(None, 1),
|
||||
width=dp(120),
|
||||
halign="left",
|
||||
)
|
||||
title.bind(size=title.setter("text_size"))
|
||||
self._clock_label = Label(
|
||||
text="",
|
||||
color=Colors.TEXT_MUTED,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(1, 1),
|
||||
halign="right",
|
||||
)
|
||||
header.add_widget(title)
|
||||
header.add_widget(self._clock_label)
|
||||
|
||||
# ── Master fader (right side, fixed) ──
|
||||
body = BoxLayout(orientation="horizontal")
|
||||
self._body = body
|
||||
|
||||
# Scrollable channel strips
|
||||
self._scroll = ScrollView(
|
||||
size_hint=(1, 1),
|
||||
do_scroll_x=True,
|
||||
do_scroll_y=False,
|
||||
bar_width=dp(4),
|
||||
scroll_type=["bars", "content"],
|
||||
)
|
||||
self._strip_container = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(None, 1),
|
||||
spacing=Sizes.PAD_SM,
|
||||
padding=[Sizes.PAD_SM, Sizes.PAD_SM],
|
||||
)
|
||||
self._strip_container.bind(minimum_width=self._strip_container.setter("width"))
|
||||
self._scroll.add_widget(self._strip_container)
|
||||
|
||||
# Master fader (right side)
|
||||
self._master_fader = FaderWidget(
|
||||
label="MASTER",
|
||||
color=[1.0, 0.84, 0.0, 1],
|
||||
channel_index=-1,
|
||||
size_hint=(None, 1),
|
||||
width=Sizes.FADER_WIDTH + dp(20),
|
||||
size=(Sizes.FADER_WIDTH + dp(20), Sizes.FADER_HEIGHT),
|
||||
min_val=-60.0,
|
||||
max_val=12.0,
|
||||
value=0.0,
|
||||
)
|
||||
self._master_fader.on_value_changed = self._on_master_fader_change
|
||||
|
||||
body.add_widget(self._scroll)
|
||||
body.add_widget(self._master_fader)
|
||||
|
||||
root.add_widget(header)
|
||||
root.add_widget(body)
|
||||
self.add_widget(root)
|
||||
|
||||
# Build 16 channels
|
||||
self._build_channels(16)
|
||||
|
||||
def _build_channels(self, count: int) -> None:
|
||||
for i in range(count):
|
||||
strip = ChannelStrip(i)
|
||||
strip.fader.on_value_changed = self._on_channel_fader_change
|
||||
strip.pan_knob.on_value_changed = self._on_channel_pan_change
|
||||
strip.mute_btn.bind(on_press=self._make_mute_callback(i))
|
||||
strip.solo_btn.bind(on_press=self._make_solo_callback(i))
|
||||
self._strip_container.add_widget(strip)
|
||||
self.strips.append(strip)
|
||||
|
||||
def on_enter(self, *args):
|
||||
"""Start polling when screen becomes active."""
|
||||
self._refresh_state()
|
||||
self._refresh_event = Clock.schedule_interval(
|
||||
lambda dt: self._refresh_state(), 0.1
|
||||
)
|
||||
|
||||
def on_leave(self, *args):
|
||||
"""Stop polling when leaving screen."""
|
||||
if self._refresh_event:
|
||||
self._refresh_event.cancel()
|
||||
self._refresh_event = None
|
||||
|
||||
def _refresh_state(self) -> None:
|
||||
"""Fetch full mixer state and update all widgets."""
|
||||
if not self.client:
|
||||
return
|
||||
self.client.fetch_state(
|
||||
on_success=self._on_state_received,
|
||||
on_error=lambda e: None, # silently ignore temporary errors
|
||||
)
|
||||
|
||||
def _on_state_received(self, state: dict) -> None:
|
||||
"""Update all strips from full state."""
|
||||
channels = state.get("channels", [])
|
||||
for i, ch_data in enumerate(channels):
|
||||
if i < len(self.strips):
|
||||
self.strips[i].update_state(ch_data)
|
||||
|
||||
# Master
|
||||
master = state.get("master", {})
|
||||
self._master_fader.value = master.get("volume_db", 0.0)
|
||||
|
||||
# ── Callbacks ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_channel_fader_change(self, channel: int, value: float):
|
||||
if self.client:
|
||||
self.client.set_parameter("volume", value, channel=channel)
|
||||
|
||||
def _on_channel_pan_change(self, param_name: str, value: float):
|
||||
# param_name is 'pan' — need channel context
|
||||
# Handled via the strip's pan_knob callback
|
||||
if self.client:
|
||||
# Find which strip this knob belongs to
|
||||
for i, strip in enumerate(self.strips):
|
||||
if strip.pan_knob.parameter_name == param_name:
|
||||
self.client.set_parameter("pan", value, channel=i)
|
||||
break
|
||||
|
||||
def _make_mute_callback(self, ch: int):
|
||||
def cb(instance):
|
||||
strip = self.strips[ch]
|
||||
if self.client:
|
||||
self.client.set_parameter("mute", 1.0 if strip.muted else 0.0, channel=ch)
|
||||
return cb
|
||||
|
||||
def _make_solo_callback(self, ch: int):
|
||||
def cb(instance):
|
||||
strip = self.strips[ch]
|
||||
if self.client:
|
||||
self.client.set_parameter("solo", 1.0 if strip.soloed else 0.0, channel=ch)
|
||||
return cb
|
||||
|
||||
def _on_master_fader_change(self, channel: int, value: float):
|
||||
if self.client:
|
||||
self.client.set_master_parameter("master_volume", value)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Plugin chain screen — per-channel plugin list with bypass.
|
||||
|
||||
Shows each channel's plugin chain: Gate → EQ → Compressor (or
|
||||
custom chains like NAM → IR for guitar channels). Each plugin
|
||||
slot shows name, bypass toggle, and tap-for-detail.
|
||||
|
||||
Touch-optimized: large slots, swipe between channels, clear
|
||||
bypass indicators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.scrollview import ScrollView
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.metrics import dp
|
||||
|
||||
from ..theme import Colors, Fonts, Sizes
|
||||
|
||||
|
||||
class PluginSlot(BoxLayout):
|
||||
"""One plugin in the chain — name, bypass toggle, tap to edit."""
|
||||
|
||||
def __init__(self, plugin_id: int, name: str, role: str, active: bool = True, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.orientation = "horizontal"
|
||||
self.size_hint = (1, None)
|
||||
self.height = Sizes.PLUGIN_SLOT_HEIGHT
|
||||
self.spacing = Sizes.PAD_SM
|
||||
self.padding = [Sizes.PAD_SM, Sizes.PAD_XS]
|
||||
self.plugin_id = plugin_id
|
||||
self.role = role
|
||||
self._active = active
|
||||
|
||||
# Role indicator (colored strip)
|
||||
role_colors = {
|
||||
"gate": (0.4, 0.5, 1.0, 1),
|
||||
"eq": (0.3, 0.9, 0.6, 1),
|
||||
"comp": (1.0, 0.5, 0.3, 1),
|
||||
"gain": (0.4, 0.8, 0.8, 1),
|
||||
"nam": (1.0, 0.3, 0.8, 1),
|
||||
"ir": (0.8, 0.5, 1.0, 1),
|
||||
"reverb": (0.5, 0.7, 1.0, 1),
|
||||
"delay": (0.5, 0.5, 1.0, 1),
|
||||
"limiter": (1.0, 0.3, 0.3, 1),
|
||||
}
|
||||
self._role_color = role_colors.get(role, (0.5, 0.5, 0.5, 1))
|
||||
|
||||
# Role indicator
|
||||
self._indicator = Label(
|
||||
text=role.upper()[:3],
|
||||
color=(0, 0, 0, 1),
|
||||
font_size=Fonts.TINY,
|
||||
bold=True,
|
||||
size_hint=(None, 1),
|
||||
width=dp(32),
|
||||
halign="center",
|
||||
)
|
||||
self._indicator.bind(size=self._indicator.setter("text_size"))
|
||||
self._update_indicator()
|
||||
|
||||
# Name
|
||||
self._name_label = Label(
|
||||
text=name,
|
||||
color=Colors.TEXT_PRIMARY,
|
||||
font_size=Fonts.BODY,
|
||||
size_hint=(1, 1),
|
||||
halign="left",
|
||||
)
|
||||
self._name_label.bind(size=self._name_label.setter("text_size"))
|
||||
|
||||
# Bypass button
|
||||
self._bypass_btn = Button(
|
||||
text="ON",
|
||||
size_hint=(None, 1),
|
||||
width=dp(48),
|
||||
background_normal="",
|
||||
background_color=Colors.ACTIVE,
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=Fonts.SMALL,
|
||||
bold=True,
|
||||
)
|
||||
self._bypass_btn.bind(on_press=self._toggle_bypass)
|
||||
|
||||
self.add_widget(self._indicator)
|
||||
self.add_widget(self._name_label)
|
||||
self.add_widget(self._bypass_btn)
|
||||
|
||||
def _update_indicator(self):
|
||||
if self._active:
|
||||
self._indicator.color = (1, 1, 1, 1)
|
||||
self._indicator.canvas.before.clear()
|
||||
else:
|
||||
self._indicator.color = Colors.TEXT_MUTED
|
||||
|
||||
def _toggle_bypass(self, instance):
|
||||
self._active = not self._active
|
||||
if self._active:
|
||||
self._bypass_btn.text = "ON"
|
||||
self._bypass_btn.background_color = Colors.ACTIVE
|
||||
else:
|
||||
self._bypass_btn.text = "BYP"
|
||||
self._bypass_btn.background_color = Colors.BYPASS_ON
|
||||
self._update_indicator()
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
@active.setter
|
||||
def active(self, value: bool):
|
||||
self._active = value
|
||||
if value:
|
||||
self._bypass_btn.text = "ON"
|
||||
self._bypass_btn.background_color = Colors.ACTIVE
|
||||
else:
|
||||
self._bypass_btn.text = "BYP"
|
||||
self._bypass_btn.background_color = Colors.BYPASS_ON
|
||||
self._update_indicator()
|
||||
|
||||
|
||||
class PluginScreen(Screen):
|
||||
"""Plugin chain view — scrollable list of channels with plugins."""
|
||||
|
||||
client = ObjectProperty(None)
|
||||
_refresh_event = None
|
||||
_channel_strips: dict[int, list[PluginSlot]] = {}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.name = "plugins"
|
||||
|
||||
root = BoxLayout(orientation="vertical")
|
||||
|
||||
# Header
|
||||
header = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.HEADER_HEIGHT,
|
||||
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||
)
|
||||
header.bg_color = Colors.BG_HEADER
|
||||
title = Label(
|
||||
text="PLUGINS",
|
||||
color=Colors.ACCENT,
|
||||
font_size=Fonts.HEADER,
|
||||
bold=True,
|
||||
size_hint=(None, 1),
|
||||
width=dp(150),
|
||||
halign="left",
|
||||
)
|
||||
title.bind(size=title.setter("text_size"))
|
||||
header.add_widget(title)
|
||||
|
||||
# Scrollable plugin list
|
||||
self._scroll = ScrollView(
|
||||
size_hint=(1, 1),
|
||||
do_scroll_x=False,
|
||||
do_scroll_y=True,
|
||||
bar_width=dp(4),
|
||||
)
|
||||
self._container = BoxLayout(
|
||||
orientation="vertical",
|
||||
size_hint=(1, None),
|
||||
spacing=Sizes.PLUGIN_SLOT_GAP,
|
||||
padding=[Sizes.PAD_SM, Sizes.PAD_SM],
|
||||
)
|
||||
self._container.bind(minimum_height=self._container.setter("height"))
|
||||
self._scroll.add_widget(self._container)
|
||||
|
||||
root.add_widget(header)
|
||||
root.add_widget(self._scroll)
|
||||
self.add_widget(root)
|
||||
|
||||
def on_enter(self, *args):
|
||||
self._refresh_event = Clock.schedule_interval(
|
||||
lambda dt: self._fetch_plugins(), 0.5
|
||||
)
|
||||
|
||||
def on_leave(self, *args):
|
||||
if self._refresh_event:
|
||||
self._refresh_event.cancel()
|
||||
self._refresh_event = None
|
||||
|
||||
def _fetch_plugins(self) -> None:
|
||||
if not self.client:
|
||||
return
|
||||
self.client.fetch_plugins(
|
||||
on_success=self._on_plugins_received,
|
||||
on_error=lambda e: None,
|
||||
)
|
||||
|
||||
def _on_plugins_received(self, plugins: list) -> None:
|
||||
"""Build plugin slots from plugin list."""
|
||||
self._container.clear_widgets()
|
||||
self._channel_strips.clear()
|
||||
|
||||
# Group plugins by channel
|
||||
by_channel: dict[int, list[dict]] = {}
|
||||
for p in plugins:
|
||||
ch = p.get("channel", -1)
|
||||
by_channel.setdefault(ch, []).append(p)
|
||||
|
||||
for ch_index in sorted(by_channel.keys()):
|
||||
ch_name = f"Channel {ch_index + 1}" if ch_index >= 0 else "Master/Global"
|
||||
ch_label = Label(
|
||||
text=f"── {ch_name} ──",
|
||||
color=Colors.TEXT_MUTED,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(1, None),
|
||||
height=dp(24),
|
||||
halign="center",
|
||||
)
|
||||
ch_label.bind(size=ch_label.setter("text_size"))
|
||||
self._container.add_widget(ch_label)
|
||||
|
||||
slots = []
|
||||
for p in by_channel[ch_index]:
|
||||
slot = PluginSlot(
|
||||
plugin_id=p.get("plugin_id", 0),
|
||||
name=p.get("name", f"Plugin {p.get('plugin_id', '?')}"),
|
||||
role=p.get("role", "unknown"),
|
||||
active=p.get("active", True),
|
||||
)
|
||||
slots.append(slot)
|
||||
self._container.add_widget(slot)
|
||||
|
||||
self._channel_strips[ch_index] = slots
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Routing matrix screen — visual patchbay for audio routing.
|
||||
|
||||
Shows a grid of sources (rows) vs destinations (columns) with
|
||||
toggle buttons for each connection. Supports scrolling for
|
||||
large matrices.
|
||||
|
||||
Touch-optimized: large grid cells, pinch-zoom for dense matrices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.scrollview import ScrollView
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.metrics import dp
|
||||
|
||||
from ..theme import Colors, Fonts, Sizes
|
||||
|
||||
|
||||
class RoutingCell(Button):
|
||||
"""A single routing grid cell — tap to toggle connection."""
|
||||
|
||||
def __init__(self, source: str, dest: str, active: bool = False, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.source = source
|
||||
self.dest = dest
|
||||
self.is_active = active
|
||||
self.background_normal = ""
|
||||
self._update_color()
|
||||
|
||||
def _update_color(self):
|
||||
self.background_color = Colors.ROUTE_ACTIVE if self.is_active else Colors.ROUTE_INACTIVE
|
||||
|
||||
def on_press(self):
|
||||
self.is_active = not self.is_active
|
||||
self._update_color()
|
||||
|
||||
|
||||
class RoutingScreen(Screen):
|
||||
"""Routing matrix with scrollable grid.
|
||||
|
||||
Sources on left (rows), destinations on top (columns).
|
||||
Each cell toggles a connection between source→dest.
|
||||
"""
|
||||
|
||||
client = ObjectProperty(None)
|
||||
_cells: dict[tuple[str, str], RoutingCell] = {}
|
||||
_refresh_event = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.name = "routing"
|
||||
|
||||
root = BoxLayout(orientation="vertical")
|
||||
|
||||
# Header
|
||||
header = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.HEADER_HEIGHT,
|
||||
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||
)
|
||||
header.bg_color = Colors.BG_HEADER
|
||||
title = Label(
|
||||
text="ROUTING",
|
||||
color=Colors.ACCENT,
|
||||
font_size=Fonts.HEADER,
|
||||
bold=True,
|
||||
size_hint=(None, 1),
|
||||
width=dp(140),
|
||||
halign="left",
|
||||
)
|
||||
title.bind(size=title.setter("text_size"))
|
||||
header.add_widget(title)
|
||||
|
||||
# Grid container
|
||||
self._grid_container = BoxLayout(orientation="vertical", padding=[Sizes.PAD_SM])
|
||||
self._grid_header = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.ROUTE_LABEL_HEIGHT,
|
||||
)
|
||||
self._grid_body_scroll = ScrollView(
|
||||
size_hint=(1, 1),
|
||||
do_scroll_x=True,
|
||||
do_scroll_y=True,
|
||||
bar_width=dp(4),
|
||||
)
|
||||
self._grid_body = GridLayout(
|
||||
cols=1,
|
||||
size_hint=(None, None),
|
||||
spacing=Sizes.CELL_GAP,
|
||||
)
|
||||
self._grid_body.bind(minimum_height=self._grid_body.setter("height"))
|
||||
self._grid_body.bind(minimum_width=self._grid_body.setter("width"))
|
||||
self._grid_body_scroll.add_widget(self._grid_body)
|
||||
|
||||
self._grid_container.add_widget(self._grid_header)
|
||||
self._grid_container.add_widget(self._grid_body_scroll)
|
||||
|
||||
root.add_widget(header)
|
||||
root.add_widget(self._grid_container)
|
||||
self.add_widget(root)
|
||||
|
||||
self._default_sources = [
|
||||
"Ch1 In", "Ch2 In", "Ch3 In", "Ch4 In",
|
||||
"Ch5 In", "Ch6 In", "Ch7 In", "Ch8 In",
|
||||
"USB 1/2", "USB 3/4", "USB 5/6", "USB 7/8",
|
||||
]
|
||||
self._default_dests = [
|
||||
"Master L/R", "Aux A", "Aux B", "Sub 1", "Sub 2",
|
||||
"Monitor L/R",
|
||||
]
|
||||
|
||||
def on_enter(self, *args):
|
||||
self._build_grid(self._default_sources, self._default_dests)
|
||||
self._refresh_event = Clock.schedule_interval(
|
||||
lambda dt: self._fetch_routing(), 0.5
|
||||
)
|
||||
|
||||
def on_leave(self, *args):
|
||||
if self._refresh_event:
|
||||
self._refresh_event.cancel()
|
||||
self._refresh_event = None
|
||||
|
||||
def _build_grid(self, sources: list[str], dests: list[str]) -> None:
|
||||
"""Build the routing grid from source/destination lists."""
|
||||
self._grid_header.clear_widgets()
|
||||
self._grid_body.clear_widgets()
|
||||
self._cells.clear()
|
||||
|
||||
# Header row: empty corner cell + destination labels
|
||||
corner = Label(
|
||||
text="SRC \\ DST",
|
||||
color=Colors.TEXT_MUTED,
|
||||
font_size=Fonts.TINY,
|
||||
size_hint=(None, 1),
|
||||
width=Sizes.ROUTE_LABEL_WIDTH,
|
||||
)
|
||||
self._grid_header.add_widget(corner)
|
||||
for dest in dests:
|
||||
lbl = Label(
|
||||
text=dest,
|
||||
color=Colors.TEXT_SECONDARY,
|
||||
font_size=Fonts.TINY,
|
||||
size_hint=(None, 1),
|
||||
width=Sizes.CELL_SIZE,
|
||||
halign="center",
|
||||
)
|
||||
lbl.bind(size=lbl.setter("text_size"))
|
||||
self._grid_header.add_widget(lbl)
|
||||
|
||||
# Body: one row per source
|
||||
self._grid_body.cols = len(dests) + 1 # +1 for label column
|
||||
for src in sources:
|
||||
# Source label
|
||||
row_label = Label(
|
||||
text=src,
|
||||
color=Colors.TEXT_SECONDARY,
|
||||
font_size=Fonts.TINY,
|
||||
size_hint=(None, None),
|
||||
size=(Sizes.ROUTE_LABEL_WIDTH, Sizes.ROUTE_LABEL_HEIGHT),
|
||||
halign="right",
|
||||
)
|
||||
row_label.bind(size=row_label.setter("text_size"))
|
||||
self._grid_body.add_widget(row_label)
|
||||
|
||||
for dest in dests:
|
||||
cell = RoutingCell(
|
||||
source=src,
|
||||
dest=dest,
|
||||
active=False,
|
||||
size_hint=(None, None),
|
||||
size=(Sizes.CELL_SIZE, Sizes.CELL_SIZE),
|
||||
)
|
||||
cell.bind(on_press=self._make_cell_callback(src, dest))
|
||||
self._cells[(src, dest)] = cell
|
||||
self._grid_body.add_widget(cell)
|
||||
|
||||
def _make_cell_callback(self, src: str, dest: str):
|
||||
def cb(instance):
|
||||
if self.client:
|
||||
# Send routing change via REST
|
||||
self.client.set_parameter(
|
||||
"routing",
|
||||
1.0 if instance.is_active else 0.0,
|
||||
channel=-1,
|
||||
)
|
||||
return cb
|
||||
|
||||
def _fetch_routing(self) -> None:
|
||||
if not self.client:
|
||||
return
|
||||
self.client.fetch_routing(
|
||||
on_success=self._on_routing_received,
|
||||
on_error=lambda e: None,
|
||||
)
|
||||
|
||||
def _on_routing_received(self, routing: dict) -> None:
|
||||
"""Update cell states from routing data."""
|
||||
edges = routing.get("edges", [])
|
||||
# Reset all cells
|
||||
for cell in self._cells.values():
|
||||
cell.is_active = False
|
||||
cell._update_color()
|
||||
|
||||
# Activate connected edges
|
||||
for edge in edges:
|
||||
src = edge.get("source", "")
|
||||
dest = edge.get("dest", "")
|
||||
is_active = edge.get("is_active", True)
|
||||
key = (src, dest)
|
||||
if key in self._cells:
|
||||
self._cells[key].is_active = is_active
|
||||
self._cells[key]._update_color()
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Settings screen — brightness, screen timeout, DPI config.
|
||||
|
||||
Controls for the RPi display backlight, screen dim timeout,
|
||||
and DPI scaling override.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.uix.slider import Slider
|
||||
from kivy.metrics import dp
|
||||
|
||||
from ..theme import Colors, Fonts, Sizes
|
||||
|
||||
|
||||
class SettingsScreen(Screen):
|
||||
"""Display and system settings."""
|
||||
|
||||
client = ObjectProperty(None)
|
||||
brightness = 100
|
||||
timeout = 0 # seconds, 0 = never
|
||||
dpi_scale = 1.0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.name = "settings"
|
||||
|
||||
root = BoxLayout(orientation="vertical")
|
||||
|
||||
# Header
|
||||
header = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.HEADER_HEIGHT,
|
||||
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||
)
|
||||
header.bg_color = Colors.BG_HEADER
|
||||
title = Label(
|
||||
text="SETTINGS",
|
||||
color=Colors.ACCENT,
|
||||
font_size=Fonts.HEADER,
|
||||
bold=True,
|
||||
size_hint=(None, 1),
|
||||
width=dp(150),
|
||||
halign="left",
|
||||
)
|
||||
title.bind(size=title.setter("text_size"))
|
||||
header.add_widget(title)
|
||||
|
||||
# Content
|
||||
content = BoxLayout(
|
||||
orientation="vertical",
|
||||
spacing=Sizes.PAD_LG,
|
||||
padding=[Sizes.PAD_LG, Sizes.PAD_LG],
|
||||
)
|
||||
|
||||
# ── Brightness ──
|
||||
brightness_section = BoxLayout(
|
||||
orientation="vertical",
|
||||
size_hint=(1, None),
|
||||
height=dp(80),
|
||||
spacing=Sizes.PAD_XS,
|
||||
)
|
||||
bright_label = Label(
|
||||
text="Brightness: 100%",
|
||||
color=Colors.TEXT_PRIMARY,
|
||||
font_size=Fonts.BODY,
|
||||
size_hint=(1, None),
|
||||
height=dp(24),
|
||||
halign="left",
|
||||
)
|
||||
bright_label.bind(size=bright_label.setter("text_size"))
|
||||
|
||||
self._bright_slider = Slider(
|
||||
min=10,
|
||||
max=100,
|
||||
value=100,
|
||||
step=5,
|
||||
size_hint=(1, None),
|
||||
height=dp(40),
|
||||
cursor_size=(dp(28), dp(28)),
|
||||
)
|
||||
self._bright_slider.bind(value=self._on_brightness_change)
|
||||
self._bright_label = bright_label
|
||||
|
||||
brightness_section.add_widget(bright_label)
|
||||
brightness_section.add_widget(self._bright_slider)
|
||||
|
||||
# ── Screen timeout ──
|
||||
timeout_section = BoxLayout(
|
||||
orientation="vertical",
|
||||
size_hint=(1, None),
|
||||
height=dp(80),
|
||||
spacing=Sizes.PAD_XS,
|
||||
)
|
||||
self._timeout_label = Label(
|
||||
text="Screen Timeout: Never",
|
||||
color=Colors.TEXT_PRIMARY,
|
||||
font_size=Fonts.BODY,
|
||||
size_hint=(1, None),
|
||||
height=dp(24),
|
||||
halign="left",
|
||||
)
|
||||
self._timeout_label.bind(size=self._timeout_label.setter("text_size"))
|
||||
|
||||
timeout_buttons = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.BUTTON_HEIGHT,
|
||||
spacing=Sizes.PAD_SM,
|
||||
)
|
||||
timeouts = [
|
||||
("30s", 30),
|
||||
("1m", 60),
|
||||
("5m", 300),
|
||||
("15m", 900),
|
||||
("Never", 0),
|
||||
]
|
||||
for label, secs in timeouts:
|
||||
btn = Button(
|
||||
text=label,
|
||||
background_normal="",
|
||||
background_color=(0.15, 0.15, 0.22, 1),
|
||||
color=Colors.ACCENT if secs == 0 else Colors.TEXT_SECONDARY,
|
||||
font_size=Fonts.SMALL,
|
||||
)
|
||||
btn.bind(on_press=self._make_timeout_callback(secs))
|
||||
timeout_buttons.add_widget(btn)
|
||||
|
||||
timeout_section.add_widget(self._timeout_label)
|
||||
timeout_section.add_widget(timeout_buttons)
|
||||
|
||||
# ── DPI scale override ──
|
||||
dpi_section = BoxLayout(
|
||||
orientation="vertical",
|
||||
size_hint=(1, None),
|
||||
height=dp(80),
|
||||
spacing=Sizes.PAD_XS,
|
||||
)
|
||||
self._dpi_label = Label(
|
||||
text="UI Scale: 1.0x (auto)",
|
||||
color=Colors.TEXT_PRIMARY,
|
||||
font_size=Fonts.BODY,
|
||||
size_hint=(1, None),
|
||||
height=dp(24),
|
||||
halign="left",
|
||||
)
|
||||
self._dpi_label.bind(size=self._dpi_label.setter("text_size"))
|
||||
|
||||
dpi_buttons = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.BUTTON_HEIGHT,
|
||||
spacing=Sizes.PAD_SM,
|
||||
)
|
||||
scales = [("0.75x", 0.75), ("1.0x", 1.0), ("1.25x", 1.25), ("1.5x", 1.5)]
|
||||
for label, scale in scales:
|
||||
btn = Button(
|
||||
text=label,
|
||||
background_normal="",
|
||||
background_color=(0.15, 0.15, 0.22, 1),
|
||||
color=Colors.ACCENT if scale == 1.0 else Colors.TEXT_SECONDARY,
|
||||
font_size=Fonts.SMALL,
|
||||
)
|
||||
btn.bind(on_press=self._make_dpi_callback(scale))
|
||||
dpi_buttons.add_widget(btn)
|
||||
|
||||
dpi_section.add_widget(self._dpi_label)
|
||||
dpi_section.add_widget(dpi_buttons)
|
||||
|
||||
# ── System info ──
|
||||
info_section = BoxLayout(
|
||||
orientation="vertical",
|
||||
size_hint=(1, None),
|
||||
height=dp(60),
|
||||
spacing=Sizes.PAD_XS,
|
||||
)
|
||||
info_label = Label(
|
||||
text="Raspberry Pi 4B | Kivy 2.3\nMixer Engine v1.0",
|
||||
color=Colors.TEXT_MUTED,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(1, None),
|
||||
height=dp(36),
|
||||
halign="center",
|
||||
)
|
||||
info_label.bind(size=info_label.setter("text_size"))
|
||||
info_section.add_widget(info_label)
|
||||
|
||||
content.add_widget(brightness_section)
|
||||
content.add_widget(timeout_section)
|
||||
content.add_widget(dpi_section)
|
||||
content.add_widget(info_section)
|
||||
|
||||
root.add_widget(header)
|
||||
root.add_widget(content)
|
||||
self.add_widget(root)
|
||||
|
||||
# ── Callbacks ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_brightness_change(self, instance, value):
|
||||
self.brightness = int(value)
|
||||
self._bright_label.text = f"Brightness: {self.brightness}%"
|
||||
# Set actual backlight
|
||||
self._set_backlight(self.brightness)
|
||||
|
||||
def _make_timeout_callback(self, secs: int):
|
||||
def cb(instance):
|
||||
self.timeout = secs
|
||||
if secs == 0:
|
||||
self._timeout_label.text = "Screen Timeout: Never"
|
||||
elif secs < 60:
|
||||
self._timeout_label.text = f"Screen Timeout: {secs}s"
|
||||
else:
|
||||
self._timeout_label.text = f"Screen Timeout: {secs // 60}m"
|
||||
self._set_screen_timeout(secs)
|
||||
return cb
|
||||
|
||||
def _make_dpi_callback(self, scale: float):
|
||||
def cb(instance):
|
||||
self.dpi_scale = scale
|
||||
self._dpi_label.text = f"UI Scale: {scale}x"
|
||||
# In production, this would reconfigure Kivy's DPI
|
||||
# For now, informational
|
||||
return cb
|
||||
|
||||
# ── Backlight control ────────────────────────────────────────────────
|
||||
|
||||
def _set_backlight(self, percent: int) -> None:
|
||||
"""Set the display backlight brightness.
|
||||
|
||||
On Raspberry Pi, writes to /sys/class/backlight/*/brightness.
|
||||
This is a no-op on non-RPi systems.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
|
||||
try:
|
||||
backlight_dirs = glob.glob("/sys/class/backlight/*/brightness")
|
||||
if backlight_dirs:
|
||||
max_file = backlight_dirs[0].replace("brightness", "max_brightness")
|
||||
with open(max_file) as f:
|
||||
max_bright = int(f.read().strip())
|
||||
value = int(percent / 100.0 * max_bright)
|
||||
with open(backlight_dirs[0], "w") as f:
|
||||
f.write(str(value))
|
||||
except (OSError, PermissionError):
|
||||
pass # Non-root or non-RPi — silently skip
|
||||
|
||||
def _set_screen_timeout(self, seconds: int) -> None:
|
||||
"""Set screen blanking timeout.
|
||||
|
||||
Uses xset on X11, or writes to console blank sysfs on framebuffer.
|
||||
"""
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
try:
|
||||
# Try X11
|
||||
subprocess.run(
|
||||
["xset", "dpms", str(seconds), str(seconds), str(seconds)],
|
||||
capture_output=True, timeout=2,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also try console blanking
|
||||
try:
|
||||
with open("/sys/module/kernel/parameters/consoleblank", "w") as f:
|
||||
f.write(str(seconds))
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"""DPI-aware theme for the touchscreen mixer UI.
|
||||
|
||||
Dark stage/studio palette designed for small touch displays.
|
||||
All sizes in dp (density-independent pixels) so the UI
|
||||
auto-scales across 5" (720×720) and 7" (800×480) displays.
|
||||
|
||||
Usage:
|
||||
from src.ui.theme import Colors, Fonts, Sizes, dp, sp
|
||||
"""
|
||||
|
||||
from kivy.metrics import dp, sp # noqa: F401 — re-exported
|
||||
|
||||
|
||||
class Colors:
|
||||
"""Dark stage/studio palette."""
|
||||
|
||||
# Backgrounds
|
||||
BG_DARK = (0.10, 0.10, 0.18, 1) # #1a1a2e — deepest bg
|
||||
BG_SURFACE = (0.13, 0.13, 0.24, 1) # #21213d — cards/panels
|
||||
BG_HEADER = (0.08, 0.08, 0.14, 1) # #141424 — top bar
|
||||
|
||||
# Accent
|
||||
ACCENT = (1.0, 0.42, 0.21, 1) # #ff6b35 — orange
|
||||
ACCENT_DIM = (0.7, 0.29, 0.15, 1) # dimmed accent
|
||||
|
||||
# Faders (per channel group — 8 groups of 2)
|
||||
FADER_COLORS = [
|
||||
(0.31, 0.82, 0.78, 1), # teal
|
||||
(0.98, 0.73, 0.27, 1), # amber
|
||||
(0.55, 0.67, 1.0, 1), # blue
|
||||
(0.56, 0.93, 0.56, 1), # green
|
||||
(0.93, 0.51, 0.93, 1), # orchid
|
||||
(1.0, 0.65, 0.0, 1), # orange
|
||||
(0.40, 0.80, 1.0, 1), # sky blue
|
||||
(1.0, 0.41, 0.71, 1), # hot pink
|
||||
]
|
||||
|
||||
# Meter
|
||||
METER_GREEN = (0.0, 0.9, 0.3, 1)
|
||||
METER_YELLOW = (1.0, 0.84, 0.0, 1)
|
||||
METER_RED = (1.0, 0.2, 0.2, 1)
|
||||
|
||||
# Text
|
||||
TEXT_PRIMARY = (0.93, 0.93, 0.93, 1) # #eee — main text
|
||||
TEXT_SECONDARY = (0.6, 0.6, 0.7, 1) # dim text
|
||||
TEXT_MUTED = (0.35, 0.35, 0.45, 1) # very dim
|
||||
|
||||
# States
|
||||
MUTE_ON = (1.0, 0.2, 0.2, 1) # red when muted
|
||||
SOLO_ON = (1.0, 0.84, 0.0, 1) # yellow when soloed
|
||||
BYPASS_ON = (0.5, 0.5, 0.5, 1) # dim when bypassed
|
||||
ACTIVE = (0.0, 0.8, 0.4, 1) # green active indicator
|
||||
|
||||
# Routing
|
||||
ROUTE_ACTIVE = (0.0, 0.7, 0.9, 1) # cyan for active routes
|
||||
ROUTE_INACTIVE = (0.15, 0.15, 0.25, 1) # dim for inactive
|
||||
|
||||
|
||||
class Fonts:
|
||||
"""Font sizes in sp (scale-independent pixels)."""
|
||||
|
||||
HEADER = sp(16)
|
||||
TITLE = sp(14)
|
||||
BODY = sp(12)
|
||||
SMALL = sp(10)
|
||||
TINY = sp(8)
|
||||
METER = sp(7)
|
||||
|
||||
|
||||
class Sizes:
|
||||
"""UI element sizes in dp."""
|
||||
|
||||
# Screen
|
||||
HEADER_HEIGHT = dp(44)
|
||||
TAB_BAR_HEIGHT = dp(48)
|
||||
|
||||
# Fader
|
||||
FADER_WIDTH = dp(52)
|
||||
FADER_HEIGHT = dp(220)
|
||||
FADER_THUMB_SIZE = dp(40)
|
||||
FADER_TRACK_WIDTH = dp(8)
|
||||
|
||||
# Meter
|
||||
METER_WIDTH = dp(10)
|
||||
METER_HEIGHT = dp(160)
|
||||
|
||||
# Knob
|
||||
KNOB_SIZE = dp(48)
|
||||
KNOB_TRACK_WIDTH = dp(3)
|
||||
|
||||
# Buttons
|
||||
BUTTON_HEIGHT = dp(44)
|
||||
BUTTON_MIN_WIDTH = dp(64)
|
||||
SMALL_BUTTON = dp(36)
|
||||
MUTE_SOLO_BUTTON = dp(36)
|
||||
|
||||
# Touch targets (minimum 44dp for usability)
|
||||
TOUCH_MIN = dp(44)
|
||||
|
||||
# Padding
|
||||
PAD_XS = dp(4)
|
||||
PAD_SM = dp(8)
|
||||
PAD_MD = dp(12)
|
||||
PAD_LG = dp(16)
|
||||
PAD_XL = dp(24)
|
||||
|
||||
# Routing matrix
|
||||
CELL_SIZE = dp(36)
|
||||
CELL_GAP = dp(2)
|
||||
ROUTE_LABEL_WIDTH = dp(80)
|
||||
ROUTE_LABEL_HEIGHT = dp(28)
|
||||
|
||||
# Plugin chain
|
||||
PLUGIN_SLOT_HEIGHT = dp(52)
|
||||
PLUGIN_SLOT_GAP = dp(4)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Touchscreen mixer widgets — fader, knob, meter, header."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Vertical fader widget with integrated level meter.
|
||||
|
||||
Touch-optimized: large hit target, precise drag control, visual
|
||||
feedback on touch. Displays dB scale and channel label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import (
|
||||
NumericProperty, StringProperty, ListProperty, BooleanProperty,
|
||||
ObjectProperty,
|
||||
)
|
||||
from kivy.uix.widget import Widget
|
||||
from kivy.graphics import Color, Rectangle, RoundedRectangle, Line
|
||||
from kivy.metrics import dp
|
||||
|
||||
from ..theme import Colors, Fonts, Sizes
|
||||
|
||||
|
||||
class FaderWidget(Widget):
|
||||
"""Vertical fader with integrated stereo meter.
|
||||
|
||||
Usage in KV:
|
||||
FaderWidget:
|
||||
value: 0.0 # -60 to +12 dB
|
||||
min_val: -60.0
|
||||
max_val: 12.0
|
||||
meter_left: 0.0 # 0.0-1.0
|
||||
meter_right: 0.0
|
||||
label: "CH 1"
|
||||
color: (0.31, 0.82, 0.78, 1)
|
||||
on_value_changed: app.on_fader_change(*args)
|
||||
"""
|
||||
|
||||
value = NumericProperty(0.0) # current value in native units (dB)
|
||||
min_val = NumericProperty(-60.0)
|
||||
max_val = NumericProperty(12.0)
|
||||
meter_left = NumericProperty(0.0) # 0.0-1.0
|
||||
meter_right = NumericProperty(0.0)
|
||||
label = StringProperty("")
|
||||
color = ListProperty([0.31, 0.82, 0.78, 1])
|
||||
mute = BooleanProperty(False)
|
||||
solo = BooleanProperty(False)
|
||||
meter_peak_left = NumericProperty(0.0)
|
||||
meter_peak_right = NumericProperty(0.0)
|
||||
|
||||
# Callback: f(channel_index, value)
|
||||
on_value_changed = ObjectProperty(None)
|
||||
channel_index = NumericProperty(0)
|
||||
|
||||
_dragging = False
|
||||
_meter_decay = 0.95 # peak hold decay per frame
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.size_hint = (None, None)
|
||||
self.size = (Sizes.FADER_WIDTH, Sizes.FADER_HEIGHT)
|
||||
Clock.schedule_interval(self._decay_meters, 0.05)
|
||||
self.bind(
|
||||
value=self._on_value_change,
|
||||
meter_left=self._update_canvas,
|
||||
meter_right=self._update_canvas,
|
||||
mute=self._update_canvas,
|
||||
solo=self._update_canvas,
|
||||
size=self._update_canvas,
|
||||
pos=self._update_canvas,
|
||||
)
|
||||
self._update_canvas()
|
||||
|
||||
# ── Normalized value (0.0–1.0) ─────────────────────────────────────
|
||||
|
||||
@property
|
||||
def normalized(self) -> float:
|
||||
if self.max_val <= self.min_val:
|
||||
return 0.0
|
||||
return (self.value - self.min_val) / (self.max_val - self.min_val)
|
||||
|
||||
def value_to_y(self) -> float:
|
||||
"""Map normalized value to y position within the widget."""
|
||||
track_top = self.height - dp(12)
|
||||
track_bottom = dp(40)
|
||||
track_height = track_top - track_bottom
|
||||
return track_bottom + self.normalized * track_height
|
||||
|
||||
def y_to_value(self, y: float) -> float:
|
||||
"""Map y position to native value."""
|
||||
track_top = self.height - dp(12)
|
||||
track_bottom = dp(40)
|
||||
track_height = track_top - track_bottom
|
||||
norm = max(0.0, min(1.0, (y - track_bottom) / track_height))
|
||||
return self.min_val + norm * (self.max_val - self.min_val)
|
||||
|
||||
# ── Touch handling ───────────────────────────────────────────────────
|
||||
|
||||
def on_touch_down(self, touch):
|
||||
if not self.collide_point(*touch.pos):
|
||||
return False
|
||||
# Accept touch anywhere within the widget bounds
|
||||
if touch.is_mouse_scrolling:
|
||||
return False
|
||||
touch.grab(self)
|
||||
self._dragging = True
|
||||
self.value = self.y_to_value(touch.y)
|
||||
return True
|
||||
|
||||
def on_touch_move(self, touch):
|
||||
if touch.grab_current is not self:
|
||||
return False
|
||||
self.value = self.y_to_value(touch.y)
|
||||
return True
|
||||
|
||||
def on_touch_up(self, touch):
|
||||
if touch.grab_current is not self:
|
||||
return False
|
||||
touch.ungrab(self)
|
||||
self._dragging = False
|
||||
return True
|
||||
|
||||
# ── Drawing ──────────────────────────────────────────────────────────
|
||||
|
||||
def _update_canvas(self, *args):
|
||||
self.canvas.clear()
|
||||
w, h = self.size
|
||||
x, y = self.pos
|
||||
|
||||
# Track area
|
||||
track_x = x + (w - Sizes.FADER_TRACK_WIDTH) / 2
|
||||
track_top = y + h - dp(12)
|
||||
track_bottom = y + dp(40)
|
||||
track_height = track_top - track_bottom
|
||||
|
||||
thumb_y = y + self.value_to_y()
|
||||
|
||||
with self.canvas:
|
||||
# ── Track background ──
|
||||
Color(*Colors.BG_SURFACE)
|
||||
RoundedRectangle(
|
||||
pos=(track_x, track_bottom),
|
||||
size=(Sizes.FADER_TRACK_WIDTH, track_height),
|
||||
radius=[dp(4)],
|
||||
)
|
||||
|
||||
# ── Track fill (from bottom to thumb) ──
|
||||
fill_height = thumb_y - track_bottom
|
||||
if fill_height > 0:
|
||||
if self.mute:
|
||||
Color(*Colors.MUTE_ON)
|
||||
elif self.solo:
|
||||
Color(*Colors.SOLO_ON)
|
||||
else:
|
||||
Color(*self.color)
|
||||
RoundedRectangle(
|
||||
pos=(track_x, track_bottom),
|
||||
size=(Sizes.FADER_TRACK_WIDTH, fill_height),
|
||||
radius=[dp(4)],
|
||||
)
|
||||
|
||||
# ── Thumb ──
|
||||
if self.solo:
|
||||
Color(*Colors.SOLO_ON)
|
||||
elif self.mute:
|
||||
Color(*Colors.MUTE_ON)
|
||||
else:
|
||||
Color(*self.color)
|
||||
thumb_hw = Sizes.FADER_THUMB_SIZE / 2
|
||||
RoundedRectangle(
|
||||
pos=(x + w / 2 - thumb_hw, thumb_y - thumb_hw),
|
||||
size=(Sizes.FADER_THUMB_SIZE, Sizes.FADER_THUMB_SIZE),
|
||||
radius=[dp(4)],
|
||||
)
|
||||
|
||||
# ── dB value label ──
|
||||
Color(*Colors.TEXT_SECONDARY)
|
||||
|
||||
# ── Meter bars (left + right of fader track) ──
|
||||
meter_x_l = track_x - dp(14)
|
||||
meter_x_r = track_x + Sizes.FADER_TRACK_WIDTH + dp(4)
|
||||
meter_w = Sizes.METER_WIDTH
|
||||
self._draw_meter_bar(meter_x_l, track_bottom, meter_w, track_height, self.meter_left)
|
||||
self._draw_meter_bar(meter_x_r, track_bottom, meter_w, track_height, self.meter_right)
|
||||
|
||||
def _draw_meter_bar(self, mx: float, my: float, mw: float, mh: float, level: float):
|
||||
"""Draw one vertical meter bar."""
|
||||
level = max(0.0, min(1.0, level))
|
||||
# Green → yellow at 0.7, → red at 0.9
|
||||
if level < 0.7:
|
||||
Color(*Colors.METER_GREEN)
|
||||
elif level < 0.9:
|
||||
Color(*Colors.METER_YELLOW)
|
||||
else:
|
||||
Color(*Colors.METER_RED)
|
||||
|
||||
fill_h = level * mh
|
||||
# Background
|
||||
Color(0.1, 0.1, 0.15, 1)
|
||||
RoundedRectangle(pos=(mx, my), size=(mw, mh), radius=[dp(2)])
|
||||
# Fill
|
||||
if level < 0.7:
|
||||
Color(*Colors.METER_GREEN)
|
||||
elif level < 0.9:
|
||||
Color(*Colors.METER_YELLOW)
|
||||
else:
|
||||
Color(*Colors.METER_RED)
|
||||
RoundedRectangle(pos=(mx, my), size=(mw, fill_h), radius=[dp(2)])
|
||||
|
||||
# ── Meter decay ────────────────────────────────────────────────────
|
||||
|
||||
def _decay_meters(self, dt):
|
||||
"""Apply peak-hold decay to meters."""
|
||||
self.meter_peak_left = max(self.meter_left, self.meter_peak_left * self._meter_decay)
|
||||
self.meter_peak_right = max(self.meter_right, self.meter_peak_right * self._meter_decay)
|
||||
|
||||
def _on_value_change(self, instance, value):
|
||||
if self.on_value_changed:
|
||||
self.on_value_changed(self.channel_index, value)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Rotary knob widget — for pan, EQ parameters, etc.
|
||||
|
||||
Touch-optimized: large hit target, rotary drag gesture.
|
||||
Displays current value as an arc and numeric label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from kivy.properties import NumericProperty, StringProperty, ListProperty, ObjectProperty
|
||||
from kivy.uix.widget import Widget
|
||||
from kivy.graphics import Color, Ellipse, Line, PushMatrix, PopMatrix, Rotate
|
||||
from kivy.metrics import dp
|
||||
|
||||
from ..theme import Colors, Fonts, Sizes
|
||||
|
||||
|
||||
class KnobWidget(Widget):
|
||||
"""Rotary knob with arc indicator.
|
||||
|
||||
Usage in KV:
|
||||
KnobWidget:
|
||||
value: 0.0 # current value
|
||||
min_val: -1.0 # L for pan
|
||||
max_val: 1.0 # R for pan
|
||||
default_val: 0.0
|
||||
label: "PAN"
|
||||
on_value_changed: app.on_knob_change(*args)
|
||||
"""
|
||||
|
||||
value = NumericProperty(0.0)
|
||||
min_val = NumericProperty(0.0)
|
||||
max_val = NumericProperty(1.0)
|
||||
default_val = NumericProperty(0.0)
|
||||
label = StringProperty("")
|
||||
color = ListProperty([0.31, 0.82, 0.78, 1])
|
||||
on_value_changed = ObjectProperty(None)
|
||||
parameter_name = StringProperty("") # e.g., "pan", "gain"
|
||||
|
||||
_dragging = False
|
||||
_angle = 0.0 # current angle in degrees
|
||||
|
||||
ARC_START = 225 # degrees — bottom-left
|
||||
ARC_SWEEP = 270 # degrees — bottom-right
|
||||
ARC_MIN = ARC_START
|
||||
ARC_MAX = ARC_START + ARC_SWEEP
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.size_hint = (None, None)
|
||||
self.size = (Sizes.KNOB_SIZE + dp(16), Sizes.KNOB_SIZE + dp(24))
|
||||
self.bind(
|
||||
value=self._update_angle,
|
||||
size=self._draw,
|
||||
pos=self._draw,
|
||||
)
|
||||
self._update_angle()
|
||||
|
||||
@property
|
||||
def normalized(self) -> float:
|
||||
if self.max_val <= self.min_val:
|
||||
return 0.0
|
||||
return (self.value - self.min_val) / (self.max_val - self.min_val)
|
||||
|
||||
def _update_angle(self, *args):
|
||||
self._angle = self.ARC_MIN + self.normalized * self.ARC_SWEEP
|
||||
self._draw()
|
||||
|
||||
# ── Touch handling ───────────────────────────────────────────────────
|
||||
|
||||
def on_touch_down(self, touch):
|
||||
if not self.collide_point(*touch.pos):
|
||||
return False
|
||||
touch.grab(self)
|
||||
self._dragging = True
|
||||
self._drag_start = touch.y
|
||||
self._drag_value = self.value
|
||||
return True
|
||||
|
||||
def on_touch_move(self, touch):
|
||||
if touch.grab_current is not self or not self._dragging:
|
||||
return False
|
||||
dy = touch.y - self._drag_start
|
||||
# Sensitivity: 200dp of vertical drag = full range
|
||||
sensitivity = dp(200)
|
||||
delta = (dy / sensitivity) * (self.max_val - self.min_val)
|
||||
self.value = max(self.min_val, min(self.max_val, self._drag_value + delta))
|
||||
return True
|
||||
|
||||
def on_touch_up(self, touch):
|
||||
if touch.grab_current is not self:
|
||||
return False
|
||||
touch.ungrab(self)
|
||||
self._dragging = False
|
||||
if self.on_value_changed:
|
||||
self.on_value_changed(self.parameter_name, self.value)
|
||||
return True
|
||||
|
||||
# ── Drawing ──────────────────────────────────────────────────────────
|
||||
|
||||
def _draw(self, *args):
|
||||
self.canvas.clear()
|
||||
w, h = self.size
|
||||
cx = self.x + w / 2
|
||||
cy = self.y + h / 2
|
||||
knob_r = Sizes.KNOB_SIZE / 2
|
||||
arc_r = knob_r - dp(4)
|
||||
|
||||
with self.canvas:
|
||||
# ── Arc background ──
|
||||
Color(*Colors.BG_SURFACE)
|
||||
Line(
|
||||
circle=(cx, cy, arc_r, self.ARC_MIN, self.ARC_MAX),
|
||||
width=Sizes.KNOB_TRACK_WIDTH * 2,
|
||||
)
|
||||
|
||||
# ── Arc fill ──
|
||||
Color(*self.color)
|
||||
if self.normalized > 0.001:
|
||||
Line(
|
||||
circle=(cx, cy, arc_r, self.ARC_MIN, self._angle),
|
||||
width=Sizes.KNOB_TRACK_WIDTH,
|
||||
)
|
||||
|
||||
# ── Knob body ──
|
||||
Color(0.2, 0.2, 0.3, 1)
|
||||
Ellipse(
|
||||
pos=(cx - knob_r + dp(2), cy - knob_r + dp(2)),
|
||||
size=(knob_r * 2 - dp(4), knob_r * 2 - dp(4)),
|
||||
)
|
||||
|
||||
# ── Indicator line ──
|
||||
Color(*self.color)
|
||||
angle_rad = math.radians(self._angle)
|
||||
indicator_len = knob_r * 0.7
|
||||
ix = cx + math.cos(angle_rad) * indicator_len
|
||||
iy = cy + math.sin(angle_rad) * indicator_len
|
||||
Line(points=[cx, cy, ix, iy], width=dp(2))
|
||||
Reference in New Issue
Block a user