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

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