Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
Lint & Validate / lint (push) Has been cancelled
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning) P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support) P2-R3: Plugin manager, categories, blacklist, NAM model support P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation) P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store) P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter) P5-R1: Touchscreen UI evaluation + main entry point docs: Audio stack, Carla integration, MIDI support, UI evaluation tests: Full test suite (292 passing)
This commit is contained in:
@@ -25,13 +25,13 @@ RPi4B-based multi-channel real-time audio mixer with custom PREEMPT_RT kernel.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Phase 1: Research & Planning (in progress)**
|
**Phase 1-4: Core audio stack + MIDI controller support (in progress)**
|
||||||
- [x] P1-R1: System Research — USB audio, PREEMPT_RT kernel analysis
|
- [x] P1-R1: System Research — USB audio, PREEMPT_RT kernel analysis
|
||||||
- [x] P1-R2: Base OS Selection — RPiOS Lite 64-bit + custom PREEMPT_RT
|
- [x] P1-R2: Base OS Selection — RPiOS Lite 64-bit + custom PREEMPT_RT
|
||||||
- [ ] P1-R3: Project Infrastructure (current)
|
- [x] P2-R1: ALSA + JACK2 Low-Latency Config
|
||||||
- [ ] P2: Kernel Build — custom PREEMPT_RT kernel compilation
|
- [x] P4-R1: MIDI Controller Support — Binding + Learn Mode
|
||||||
- [ ] P3: Audio Engine — JACK/PipeWire integration, DSP pipeline
|
- [ ] P3: Audio Engine — DSP pipeline, mixing engine
|
||||||
- [ ] P4: Hardware Integration — USB interface, GPIO control surface
|
- [ ] P4: Hardware Integration — GPIO control surface
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# /etc/udev/rules.d/99-midi.rules
|
||||||
|
# USB MIDI device hotplug rules for Raspberry Pi RT Audio Mixer
|
||||||
|
#
|
||||||
|
# These rules:
|
||||||
|
# 1. Give the 'audio' group read/write access to USB MIDI devices
|
||||||
|
# 2. Create a persistent symlink for known controllers
|
||||||
|
# 3. Trigger systemd service reload on hotplug
|
||||||
|
# 4. Set nice/rtprio for MIDI processing threads
|
||||||
|
#
|
||||||
|
# Install: sudo cp config/99-midi.rules /etc/udev/rules.d/
|
||||||
|
# Reload: sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||||
|
|
||||||
|
# ── Generic USB MIDI class devices (class-compliant) ────────────────────────
|
||||||
|
|
||||||
|
# USB Audio Class MIDI Streaming (subclass 0x03)
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
GROUP="audio", MODE="0660", \
|
||||||
|
TAG+="systemd", ENV{SYSTEMD_WANTS}+="midi-hotplug@$devpath.service"
|
||||||
|
|
||||||
|
# ── Known controllers — persistent symlinks by VID:PID ──────────────────────
|
||||||
|
|
||||||
|
# Behringer X-Touch Compact
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
ATTRS{idVendor}=="1397", ATTRS{idProduct}=="00b4", \
|
||||||
|
SYMLINK+="midi/xtouch-compact", \
|
||||||
|
ENV{MIDI_CONTROLLER}="Behringer X-Touch Compact"
|
||||||
|
|
||||||
|
# Behringer X-Touch (MCU mode)
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
ATTRS{idVendor}=="1397", ATTRS{idProduct}=="00b5", \
|
||||||
|
SYMLINK+="midi/xtouch", \
|
||||||
|
ENV{MIDI_CONTROLLER}="Behringer X-Touch"
|
||||||
|
|
||||||
|
# FaderFox UC4
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="0d27", \
|
||||||
|
SYMLINK+="midi/faderfox-uc4", \
|
||||||
|
ENV{MIDI_CONTROLLER}="FaderFox UC4"
|
||||||
|
|
||||||
|
# Akai MIDImix
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
ATTRS{idVendor}=="09e8", ATTRS{idProduct}=="0031", \
|
||||||
|
SYMLINK+="midi/midimix", \
|
||||||
|
ENV{MIDI_CONTROLLER}="Akai MIDImix"
|
||||||
|
|
||||||
|
# Arturia BeatStep
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
ATTRS{idVendor}=="1c75", ATTRS{idProduct}=="0208", \
|
||||||
|
SYMLINK+="midi/beatstep", \
|
||||||
|
ENV{MIDI_CONTROLLER}="Arturia BeatStep"
|
||||||
|
|
||||||
|
# Novation Launch Control XL
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
ATTRS{idVendor}=="1235", ATTRS{idProduct}=="0061", \
|
||||||
|
SYMLINK+="midi/launchcontrol-xl", \
|
||||||
|
ENV{MIDI_CONTROLLER}="Novation Launch Control XL"
|
||||||
|
|
||||||
|
# Korg nanoKONTROL2
|
||||||
|
SUBSYSTEM=="sound", ACTION=="add", KERNEL=="midi*", \
|
||||||
|
ATTRS{idVendor}=="0944", ATTRS{idProduct}=="0117", \
|
||||||
|
SYMLINK+="midi/nanokontrol2", \
|
||||||
|
ENV{MIDI_CONTROLLER}="Korg nanoKONTROL2"
|
||||||
|
|
||||||
|
# ── Cleanup on removal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SUBSYSTEM=="sound", ACTION=="remove", KERNEL=="midi*", \
|
||||||
|
RUN+="/usr/bin/systemctl stop midi-hotplug@$devpath.service || true"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<CARLA-PRESET version="2.6.0">
|
||||||
|
<Info Name="RPi Mixer Default Rack" Author="hermes-kanban" />
|
||||||
|
<Engine>
|
||||||
|
<AudioDriver>JACK</AudioDriver>
|
||||||
|
<ProcessMode>MultipleClients</ProcessMode>
|
||||||
|
<TransportMode>Disabled</TransportMode>
|
||||||
|
<SampleRate>48000</SampleRate>
|
||||||
|
<BufferSize>128</BufferSize>
|
||||||
|
<ForceStereo>true</ForceStereo>
|
||||||
|
</Engine>
|
||||||
|
<Rack enabled="true">
|
||||||
|
<Plugin id="0" name="Ch1 Gate" type="LV2" uri="http://calf.sourceforge.net/plugins/Gate" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="threshold" value="-40" />
|
||||||
|
<Parameter name="ratio" value="4.0" />
|
||||||
|
<Parameter name="attack" value="10" />
|
||||||
|
<Parameter name="release" value="100" />
|
||||||
|
</Plugin>
|
||||||
|
<Plugin id="1" name="Ch1 EQ" type="LV2" uri="http://calf.sourceforge.net/plugins/Equalizer12Band" enabled="true" bypass="false" drywet="1.0" volume="0.0" />
|
||||||
|
<Plugin id="2" name="Ch1 Comp" type="LV2" uri="http://calf.sourceforge.net/plugins/Compressor" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="threshold" value="-20" />
|
||||||
|
<Parameter name="ratio" value="4.0" />
|
||||||
|
<Parameter name="attack" value="5" />
|
||||||
|
<Parameter name="release" value="50" />
|
||||||
|
<Parameter name="makeup" value="3" />
|
||||||
|
</Plugin>
|
||||||
|
<Plugin id="3" name="Ch2 Gate" type="LV2" uri="http://calf.sourceforge.net/plugins/Gate" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="threshold" value="-40" />
|
||||||
|
<Parameter name="ratio" value="4.0" />
|
||||||
|
</Plugin>
|
||||||
|
<Plugin id="4" name="Ch2 EQ" type="LV2" uri="http://calf.sourceforge.net/plugins/Equalizer8Band" enabled="true" bypass="false" drywet="1.0" volume="0.0" />
|
||||||
|
<Plugin id="5" name="Ch2 Comp" type="LV2" uri="http://calf.sourceforge.net/plugins/Compressor" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="threshold" value="-20" />
|
||||||
|
<Parameter name="ratio" value="4.0" />
|
||||||
|
</Plugin>
|
||||||
|
<Plugin id="6" name="Ch3 NAM" type="LV2" uri="http://github.com/mikeoliphant/neural-amp-modeler-lv2" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="model" value="/home/pi/nam-models/default.nam" />
|
||||||
|
</Plugin>
|
||||||
|
<Plugin id="7" name="Ch3 IR Loader" type="LV2" uri="http://guitarix.org/plugins/gx_ampmodeller" enabled="true" bypass="false" drywet="1.0" volume="0.0" />
|
||||||
|
<Plugin id="8" name="AUX1 Hall Reverb" type="LV2" uri="http://calf.sourceforge.net/plugins/Reverb" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="decay_time" value="2.5" />
|
||||||
|
<Parameter name="dry/wet" value="0.3" />
|
||||||
|
<Parameter name="room_size" value="0.7" />
|
||||||
|
</Plugin>
|
||||||
|
<Plugin id="9" name="AUX2 Delay" type="LV2" uri="http://calf.sourceforge.net/plugins/VintageDelay" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="time_l" value="250" />
|
||||||
|
<Parameter name="time_r" value="375" />
|
||||||
|
<Parameter name="feedback" value="0.4" />
|
||||||
|
<Parameter name="dry/wet" value="0.25" />
|
||||||
|
</Plugin>
|
||||||
|
<Plugin id="10" name="Master Limiter" type="LV2" uri="http://calf.sourceforge.net/plugins/Limiter" enabled="true" bypass="false" drywet="1.0" volume="0.0">
|
||||||
|
<Parameter name="limit" value="-1.0" />
|
||||||
|
<Parameter name="threshold" value="-3.0" />
|
||||||
|
<Parameter name="release" value="20" />
|
||||||
|
</Plugin>
|
||||||
|
</Rack>
|
||||||
|
</CARLA-PRESET>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<CARLA-PATCHBAY version="2.6.0">
|
||||||
|
<Connection id="0" source="system:capture_1" dest="Carla:Ch1_Gate_in1" label="Capture 1 → Ch1 Gate" enabled="true" />
|
||||||
|
<Connection id="1" source="system:capture_2" dest="Carla:Ch2_Gate_in1" label="Capture 2 → Ch2 Gate" enabled="true" />
|
||||||
|
<Connection id="2" source="system:capture_3" dest="Carla:Ch3_NAM_in1" label="Capture 3 → Guitar NAM" enabled="true" />
|
||||||
|
<Connection id="3" source="Carla:Master_Limiter_out1" dest="system:playback_1" label="Master L → Playback 1" enabled="true" />
|
||||||
|
<Connection id="4" source="Carla:Master_Limiter_out2" dest="system:playback_2" label="Master R → Playback 2" enabled="true" />
|
||||||
|
</CARLA-PATCHBAY>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# USB audio quirks configuration
|
||||||
|
# Apply device-specific workarounds for USB audio interfaces
|
||||||
|
#
|
||||||
|
# Find your device VID/PID with: lsusb | grep -i audio
|
||||||
|
# Then uncomment the matching line below.
|
||||||
|
|
||||||
|
# ── General optimizations ────────────────────────────
|
||||||
|
|
||||||
|
# Reduce USB transfer latency by disabling implicit feedback buffering
|
||||||
|
options snd-usb-audio nrpacks=1
|
||||||
|
|
||||||
|
# ── Focusrite Scarlett 18i20 (3rd Gen) ───────────────
|
||||||
|
# VID=0x1235 PID=0x8210
|
||||||
|
# Device setup 1 enables ALSA mixer controls for routing
|
||||||
|
# options snd-usb-audio vid=0x1235 pid=0x8210 device_setup=1
|
||||||
|
|
||||||
|
# ── Behringer UMC1820 ────────────────────────────────
|
||||||
|
# VID=0x1397 PID=0x0508
|
||||||
|
# Implicit feedback sync for clock stability
|
||||||
|
# options snd-usb-audio vid=0x1397 pid=0x0508 implicit_fb=1
|
||||||
|
|
||||||
|
# ── Behringer UMC404HD ───────────────────────────────
|
||||||
|
# VID=0x1397 PID=0x0509
|
||||||
|
# options snd-usb-audio vid=0x1397 pid=0x0509 implicit_fb=1
|
||||||
|
|
||||||
|
# ── Behringer UMC204HD ───────────────────────────────
|
||||||
|
# VID=0x1397 PID=0x0507
|
||||||
|
# options snd-usb-audio vid=0x1397 pid=0x0507 implicit_fb=1
|
||||||
|
|
||||||
|
# ── Zoom UAC-8 ───────────────────────────────────────
|
||||||
|
# VID=0x1686 PID=0x0160
|
||||||
|
# options snd-usb-audio vid=0x1686 pid=0x0160 nrpacks=1
|
||||||
|
|
||||||
|
# ── RME Babyface Pro FS ──────────────────────────────
|
||||||
|
# Class-compliant mode — no quirks needed, works OOTB
|
||||||
|
# Just ensure the interface is in "CC" (Class Compliant) mode
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
# Carla Integration — Plugin Host for RPi4B Audio Mixer
|
||||||
|
|
||||||
|
**Date:** 2026-05-19
|
||||||
|
**Task:** P2-R2
|
||||||
|
**Parent:** P2-R1 (ALSA + JACK2 Low-Latency Config)
|
||||||
|
**Target Platform:** Raspberry Pi 4B, RPi OS Lite 64-bit (Bookworm), PREEMPT_RT 6.12.y
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#1-architecture-overview)
|
||||||
|
2. [Build Carla from Source](#2-build-carla-from-source)
|
||||||
|
3. [Build Neural Amp Modeler (NAM) LV2](#3-build-neural-amp-modeler-nam-lv2)
|
||||||
|
4. [Carla Configuration](#4-carla-configuration)
|
||||||
|
5. [Default Mixer Rack & Patchbay](#5-default-mixer-rack--patchbay)
|
||||||
|
6. [JACK Auto-Connection](#6-jack-auto-connection)
|
||||||
|
7. [Plugin Compatibility Testing](#7-plugin-compatibility-testing)
|
||||||
|
8. [Preset & Program Management](#8-preset--program-management)
|
||||||
|
9. [Performance Tuning for RPi4B](#9-performance-tuning-for-rpi4b)
|
||||||
|
10. [Operational Procedures](#10-operational-procedures)
|
||||||
|
11. [Troubleshooting](#11-troubleshooting)
|
||||||
|
12. [File Inventory](#12-file-inventory)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Overview
|
||||||
|
|
||||||
|
Carla sits as the plugin host layer between JACK and the audio applications:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ User Space │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Carla Plugin Host (carla) │ │
|
||||||
|
│ │ ┌──────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Rack Mode — 8-channel mixer │ │ │
|
||||||
|
│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │ │ │
|
||||||
|
│ │ │ │Ch1 │ │Ch2 │ │Ch3 │ │AUX 1/2 │ │ │ │
|
||||||
|
│ │ │ │Gate→ │ │Gate→ │ │NAM→ │ │Reverb │ │ │ │
|
||||||
|
│ │ │ │EQ→ │ │EQ→ │ │IR │ │Delay │ │ │ │
|
||||||
|
│ │ │ │Comp │ │Comp │ │ │ │ │ │ │ │
|
||||||
|
│ │ │ └──┬───┘ └──┬───┘ └──┬───┘ └────┬─────┘ │ │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ │ └────────┼────────┼───────────┘ │ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ │ ┌────▼────────▼────┐ │ │ │
|
||||||
|
│ │ │ │ Master Limiter │ │ │ │
|
||||||
|
│ │ │ └────────┬────────┘ │ │ │
|
||||||
|
│ │ └──────────────────┼──────────────────────────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ┌──────────────────▼──────────────────────────┐ │ │
|
||||||
|
│ │ │ Patchbay — dynamic signal routing │ │ │
|
||||||
|
│ │ │ (system capture → plugin inputs) │ │ │
|
||||||
|
│ │ │ (plugin outputs → system playback) │ │ │
|
||||||
|
│ │ └──────────────────┬──────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────┼──────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────────────▼──────────────────────────────┐ │
|
||||||
|
│ │ JACK2 (jackd) — SCHED_FIFO prio 70-80 │ │
|
||||||
|
│ │ ALSA backend (hw:USB), 48kHz, 128f/3p, 8ms │ │
|
||||||
|
│ └─────────────────────┬──────────────────────────────┘ │
|
||||||
|
├─────────────────────────┼──────────────────────────────────────┤
|
||||||
|
│ Kernel │ │
|
||||||
|
│ snd-usb-audio (nrpacks=1) → xHCI (IRQ prio 95) │
|
||||||
|
│ PREEMPT_RT 6.12.y, cores 1-3 isolated for audio │
|
||||||
|
└─────────────────────────┼──────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ USB Audio I/F │
|
||||||
|
│ (class UAC2) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Format Support
|
||||||
|
|
||||||
|
| Format | Carla Support | Build Requirement |
|
||||||
|
|--------|--------------|-------------------|
|
||||||
|
| LV2 | ✓ Native | lv2-dev, lilv |
|
||||||
|
| LADSPA | ✓ Native | Built-in |
|
||||||
|
| DSSI | ✓ Native | Built-in |
|
||||||
|
| VST2 | ✓ Via bridge | VST2 SDK (optional) |
|
||||||
|
| VST3 | ✓ Via bridge | VST3 SDK (/opt/vst3sdk) |
|
||||||
|
| SF2/SFZ| ✓ Native | libfluidsynth-dev |
|
||||||
|
| NAM | ✓ LV2 host | See §3 below |
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
| Parameter | Value | Rationale |
|
||||||
|
|-----------|-------|-----------|
|
||||||
|
| Carla process mode | MultipleClients | Each plugin = separate JACK client for independent patching |
|
||||||
|
| Default channels | 8 (expandable) | 2 mic/line, 1 guitar, 2 aux, master |
|
||||||
|
| Plugin format priority | LV2 | Native Linux, no bridge overhead, best ARM support |
|
||||||
|
| GUI toolkit | PyQt5 (headless supported) | Carla can run without GUI on RPi4B |
|
||||||
|
| ARM optimization | Cortex-A72 + NEON + LTO | -march=armv8-a+crc+crypto -mtune=cortex-a72 |
|
||||||
|
| Transport sync | Disabled | Mixer doesn't need DAW transport |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Build Carla from Source
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Working JACK2 setup (see parent task P2-R1: `docs/audio-stack-config.md`)
|
||||||
|
- ~500MB free disk space for build
|
||||||
|
- Internet connection for dependency download
|
||||||
|
- **Run directly on the Raspberry Pi 4B** (not cross-compiled)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/pi/raspberry-pi-mixer
|
||||||
|
|
||||||
|
# One-command build
|
||||||
|
chmod +x scripts/carla-build.sh
|
||||||
|
./scripts/carla-build.sh --install
|
||||||
|
```
|
||||||
|
|
||||||
|
Estimated build time: 30-60 minutes on RPi4B (4 cores).
|
||||||
|
|
||||||
|
### What gets built
|
||||||
|
|
||||||
|
The script:
|
||||||
|
1. Installs all build dependencies (Qt5, JACK, ALSA, FFmpeg, FluidSynth, OSC, etc.)
|
||||||
|
2. Clones Carla from `https://github.com/falkTX/Carla.git`
|
||||||
|
3. Patches the Makefile to add ARM Cortex-A72 optimization flags (`-march=armv8-a+crc+crypto -mtune=cortex-a72`)
|
||||||
|
4. Builds with `WITH_LTO=true` for smaller, faster binary
|
||||||
|
5. Optionally installs to `/usr/local`
|
||||||
|
|
||||||
|
### Cortex-A72 Optimization
|
||||||
|
|
||||||
|
The Makefile patch adds ARM64-specific flags to `source/Makefile.mk`:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
ifeq ($(CPU_ARM64_OR_AARCH64),true)
|
||||||
|
BASE_OPTS += -march=armv8-a+crc+crypto -mtune=cortex-a72
|
||||||
|
endif
|
||||||
|
```
|
||||||
|
|
||||||
|
The original Makefile only has NEON flags for ARM32 (`Cortex-A7`). ARM64/aarch64 uses the compiler's default NEON support (always available on ARMv8-A).
|
||||||
|
|
||||||
|
### VST3 Support
|
||||||
|
|
||||||
|
VST3 plugin hosting requires the Steinberg VST3 SDK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo git clone --recursive https://github.com/steinbergmedia/vst3sdk.git /opt/vst3sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild Carla. Without the SDK, LV2, LADSPA, and DSSI still work.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check binary
|
||||||
|
file /usr/local/bin/carla
|
||||||
|
# Expected: ELF 64-bit LSB executable, ARM aarch64
|
||||||
|
|
||||||
|
# Check features
|
||||||
|
carla --help 2>&1 | head -20
|
||||||
|
|
||||||
|
# Test with JACK
|
||||||
|
sudo audio-stack-start
|
||||||
|
carla &
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Build Neural Amp Modeler (NAM) LV2
|
||||||
|
|
||||||
|
NAM uses the RTNeural library (real-time neural inference, header-only C++) wrapped in
|
||||||
|
an LV2 plugin. It does NOT require libtorch/PyTorch.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/pi/raspberry-pi-mixer
|
||||||
|
chmod +x scripts/nam-build.sh
|
||||||
|
./scripts/nam-build.sh --install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical: Model Sizes Matter
|
||||||
|
|
||||||
|
| Model Size | Parameters | RPi4B Feasibility | xrun Risk at 128f |
|
||||||
|
|------------|-----------|-------------------|-------------------|
|
||||||
|
| Standard | ~500K+ | ✗ No | Guaranteed xruns |
|
||||||
|
| Feather | ~100-200K | ⚠ Marginal | Occasional |
|
||||||
|
| Nano | ~30-50K | ✓ Yes | Low |
|
||||||
|
| Micro | ~10-20K | ✓ Yes | Very low |
|
||||||
|
|
||||||
|
**For RPi4B, use nano or feather models from [Tone3000](https://www.tone3000.com/search?sizes=feather).**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download models
|
||||||
|
mkdir -p ~/nam-models
|
||||||
|
# Then download .nam files from Tone3000 and place them in ~/nam-models/
|
||||||
|
```
|
||||||
|
|
||||||
|
### NAM + IR Chain in Carla
|
||||||
|
|
||||||
|
The default rack preset places NAM in Channel 3 followed by an IR loader
|
||||||
|
for cabinet simulation:
|
||||||
|
|
||||||
|
```
|
||||||
|
system:capture_3 → [Ch3 NAM] → [Ch3 IR Loader] → Master → system:playback_1/2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Carla Configuration
|
||||||
|
|
||||||
|
### Configuration File
|
||||||
|
|
||||||
|
Carla's settings are stored at `~/.config/falkTX/Carla.conf` (JSON-format).
|
||||||
|
|
||||||
|
Key settings for our mixer setup:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Main": {
|
||||||
|
"AudioDriver": "JACK",
|
||||||
|
"ProcessMode": "MultipleClients",
|
||||||
|
"TransportMode": "Disabled"
|
||||||
|
},
|
||||||
|
"JACK": {
|
||||||
|
"AutoConnectPorts": false,
|
||||||
|
"RackExportPorts": true
|
||||||
|
},
|
||||||
|
"LV2": {
|
||||||
|
"UIbridges": false,
|
||||||
|
"BridgedWine": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `scripts/carla-jack-autoconnect` script handles port wiring instead of
|
||||||
|
Carla's built-in auto-connect, giving us precise control.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For headless operation (RPi4B without display)
|
||||||
|
export CARLA_BRIDGE_DUMMY=1 # Disable plugin UIs
|
||||||
|
export QT_QPA_PLATFORM=offscreen # Qt offscreen rendering
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
export JACK_NO_AUDIO_OUT=0 # Don't mute JACK when jackd starts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Carla Startup with the Rack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the full audio stack
|
||||||
|
sudo audio-stack-start
|
||||||
|
|
||||||
|
# Launch Carla with the default mixer rack
|
||||||
|
carla config/carla-8ch-default.carxp &
|
||||||
|
|
||||||
|
# Auto-wire JACK ports
|
||||||
|
./scripts/carla-jack-autoconnect --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Default Mixer Rack & Patchbay
|
||||||
|
|
||||||
|
### Rack Layout (`config/carla-8ch-default.carxp`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Plugin Chain (signal flow top-to-bottom):
|
||||||
|
─────────────────────────────────────────
|
||||||
|
Channel 1: Gate → 12-Band EQ → Compressor
|
||||||
|
Channel 2: Gate → 8-Band EQ → Compressor
|
||||||
|
Channel 3: NAM → IR Loader (guitar)
|
||||||
|
AUX 1: Hall Reverb
|
||||||
|
AUX 2: Vintage Delay
|
||||||
|
Master: Limiter (ceiling -1.0 dBFS)
|
||||||
|
─────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
All plugins use Calf Studio Gear LV2 suite for CPU-efficient DSP on ARM.
|
||||||
|
|
||||||
|
### Patchbay Rules (`config/carla-patchbay.xml`)
|
||||||
|
|
||||||
|
```
|
||||||
|
system:capture_1 → Ch1 Gate input
|
||||||
|
system:capture_2 → Ch2 Gate input
|
||||||
|
system:capture_3 → Ch3 NAM input
|
||||||
|
Master Limiter out 1 → system:playback_1
|
||||||
|
Master Limiter out 2 → system:playback_2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating Custom Racks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 4-channel minimal rack
|
||||||
|
python3 scripts/carla-preset-gen.py --channels 4
|
||||||
|
|
||||||
|
# Patchbay only
|
||||||
|
python3 scripts/carla-preset-gen.py --patchbay
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding/Removing Plugins
|
||||||
|
|
||||||
|
Edit the rack in Carla's GUI, or modify `scripts/carla-preset-gen.py` and regenerate.
|
||||||
|
The preset uses standard LV2 URIs:
|
||||||
|
- `http://calf.sourceforge.net/plugins/Gate`
|
||||||
|
- `http://calf.sourceforge.net/plugins/Equalizer12Band`
|
||||||
|
- `http://calf.sourceforge.net/plugins/Equalizer8Band`
|
||||||
|
- `http://calf.sourceforge.net/plugins/Compressor`
|
||||||
|
- `http://calf.sourceforge.net/plugins/Reverb`
|
||||||
|
- `http://calf.sourceforge.net/plugins/VintageDelay`
|
||||||
|
- `http://calf.sourceforge.net/plugins/Limiter`
|
||||||
|
- `http://github.com/mikeoliphant/neural-amp-modeler-lv2`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. JACK Auto-Connection
|
||||||
|
|
||||||
|
### One-shot Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/carla-jack-autoconnect
|
||||||
|
```
|
||||||
|
|
||||||
|
Connects all currently available Carla ports according to routing rules.
|
||||||
|
|
||||||
|
### Persistent Auto-Connect Daemon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/carla-jack-autoconnect --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitors JACK for new Carla plugin ports and wires them automatically.
|
||||||
|
Run this in a tmux session or as a systemd user service:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# ~/.config/systemd/user/carla-autoconnect.service
|
||||||
|
[Unit]
|
||||||
|
Description=Carla JACK Auto-Connect
|
||||||
|
After=jackd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/home/pi/raspberry-pi-mixer/scripts/carla-jack-autoconnect --watch
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now carla-autoconnect.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dry Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/carla-jack-autoconnect --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows what would be connected without making changes.
|
||||||
|
|
||||||
|
### Disconnect All Carla Ports
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/carla-jack-autoconnect --disconnect-all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Plugin Compatibility Testing
|
||||||
|
|
||||||
|
### LV2 Lint Validation
|
||||||
|
|
||||||
|
The `lv2lint` tool checks LV2 plugin bundles for spec compliance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/lv2lint-check.sh
|
||||||
|
|
||||||
|
# Check all installed LV2 plugins
|
||||||
|
./scripts/lv2lint-check.sh
|
||||||
|
|
||||||
|
# Check only NAM
|
||||||
|
./scripts/lv2lint-check.sh --nam-only
|
||||||
|
|
||||||
|
# Check a specific plugin
|
||||||
|
./scripts/lv2lint-check.sh /usr/lib/lv2/calf.lv2
|
||||||
|
|
||||||
|
# List all installed plugins
|
||||||
|
./scripts/lv2lint-check.sh --list
|
||||||
|
```
|
||||||
|
|
||||||
|
### What gets checked
|
||||||
|
|
||||||
|
| Check | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| Manifest parsing | Valid `manifest.ttl` exists |
|
||||||
|
| Binary architecture | Must be ARM aarch64 (not x86) |
|
||||||
|
| Shared library deps | All `.so` dependencies resolvable |
|
||||||
|
| URI validity | Unique, well-formed plugin URI |
|
||||||
|
| Port definitions | Valid audio/CV/control port specs |
|
||||||
|
| Parameter ranges | min/max/default values defined |
|
||||||
|
| atom:Path support | Critical for NAM model selection |
|
||||||
|
| Carla extensions | Carla-specific features if present |
|
||||||
|
|
||||||
|
### ARM-Specific Checks
|
||||||
|
|
||||||
|
- Architecture verification: ensures no x86 binaries are present
|
||||||
|
- NEON SIMD detection: checks if plugins use NEON optimization
|
||||||
|
- CPU feature requirements: verifies no SSSE3/AVX instructions
|
||||||
|
|
||||||
|
### Runtime Testing Procedure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start JACK
|
||||||
|
sudo audio-stack-start
|
||||||
|
|
||||||
|
# 2. Run Carla with test rack
|
||||||
|
carla config/carla-8ch-default.carxp &
|
||||||
|
|
||||||
|
# 3. Auto-connect
|
||||||
|
sleep 2 && ./scripts/carla-jack-autoconnect
|
||||||
|
|
||||||
|
# 4. Check for xruns
|
||||||
|
watch -n 1 "jack_cpu_load; echo '---'; tail -5 /tmp/jackd.log | grep -i xrun || echo 'no xruns'"
|
||||||
|
|
||||||
|
# 5. Verify audio path
|
||||||
|
jack_lsp -c | grep -E "(Carla|system)"
|
||||||
|
|
||||||
|
# 6. Test each plugin bypass
|
||||||
|
# Send MIDI CC or use OSC to toggle plugin bypass
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Preset & Program Management
|
||||||
|
|
||||||
|
### CLI Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/carla-preset-manager.py [command]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `list` | List all saved presets |
|
||||||
|
| `save <name>` | Save current Carla state as preset |
|
||||||
|
| `load <name>` | Load a preset (overwrites current state) |
|
||||||
|
| `export <name> <file>` | Export preset + models as .tar.gz bundle |
|
||||||
|
| `import <bundle>` | Import a preset bundle |
|
||||||
|
| `set-model <plugin> <path>` | Set NAM model path via OSC |
|
||||||
|
|
||||||
|
### Preset Storage
|
||||||
|
|
||||||
|
- **User presets:** `~/carla-presets/*.carxp`
|
||||||
|
- **System presets:** `config/*.carxp` (in repo)
|
||||||
|
- **Metadata:** `~/carla-presets/*.meta.json`
|
||||||
|
- **Models:** `~/nam-models/*.nam`
|
||||||
|
|
||||||
|
### Workflow: Save and Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tweak your rack in Carla GUI, then:
|
||||||
|
python3 scripts/carla-preset-manager.py save "my-live-set"
|
||||||
|
|
||||||
|
# Later, restore:
|
||||||
|
python3 scripts/carla-preset-manager.py load "my-live-set"
|
||||||
|
# Restart Carla to apply
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow: Export a Full Bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export preset with model references
|
||||||
|
python3 scripts/carla-preset-manager.py export "my-live-set" /tmp/my-set.tar.gz
|
||||||
|
|
||||||
|
# Transfer to another RPi4B
|
||||||
|
scp /tmp/my-set.tar.gz pi@other-rpi:/tmp/
|
||||||
|
ssh pi@other-rpi "python3 ~/raspberry-pi-mixer/scripts/carla-preset-manager.py import /tmp/my-set.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Performance Tuning for RPi4B
|
||||||
|
|
||||||
|
### CPU Governor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Must be 'performance' for audio
|
||||||
|
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
|
||||||
|
# Expected: performance
|
||||||
|
```
|
||||||
|
|
||||||
|
### CPU Isolation
|
||||||
|
|
||||||
|
From parent task — cores 1-3 isolated for audio via `isolcpus=1-3` in cmdline.txt.
|
||||||
|
Carla plugins run on isolated cores. Check with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
taskset -cp $(pgrep carla)
|
||||||
|
# Expected: affinity list includes 1-3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Carla Process Priority
|
||||||
|
|
||||||
|
After Carla starts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set Carla to SCHED_FIFO priority (JACK client)
|
||||||
|
sudo chrt -f -p 80 $(pgrep carla)
|
||||||
|
```
|
||||||
|
|
||||||
|
### NAM Performance Tuning
|
||||||
|
|
||||||
|
For the NAM plugin specifically:
|
||||||
|
|
||||||
|
1. **Use nano/feather models** — standard models cause xruns at any buffer
|
||||||
|
2. **Increase buffer if needed** — edit `/etc/jackdrc` and change `-p 128` to `-p 256`
|
||||||
|
3. **Disable GUI updates** — Carla GUI redraws consume CPU
|
||||||
|
4. **Bypass unused plugins** — Carla's bypass is true DSP bypass (zero CPU)
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Real-time CPU load
|
||||||
|
jack_cpu_load
|
||||||
|
|
||||||
|
# Per-process DSP load
|
||||||
|
top -H -p $(pgrep carla)
|
||||||
|
|
||||||
|
# Xrun count
|
||||||
|
grep -c xrun /tmp/jackd.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Performance
|
||||||
|
|
||||||
|
| Configuration | CPU Load (idle) | CPU Load (8ch active) | Headroom |
|
||||||
|
|--------------|-----------------|-----------------------|----------|
|
||||||
|
| No NAM, all Calf | ~8% | ~22% | 78% |
|
||||||
|
| 1x Nano NAM | ~12% | ~35% | 65% |
|
||||||
|
| 1x Feather NAM | ~18% | ~52% | 48% |
|
||||||
|
| 1x Standard NAM | ~45% | ~95% | **xruns** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Operational Procedures
|
||||||
|
|
||||||
|
### Startup Sequence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Full audio stack startup with Carla
|
||||||
|
|
||||||
|
# 1. Start JACK + ALSA
|
||||||
|
sudo audio-stack-start
|
||||||
|
|
||||||
|
# 2. Wait for JACK to stabilize
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# 3. Set CPU governor
|
||||||
|
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||||
|
|
||||||
|
# 4. Launch Carla with default rack
|
||||||
|
carla config/carla-8ch-default.carxp &
|
||||||
|
CARLA_PID=$!
|
||||||
|
|
||||||
|
# 5. Set realtime priority
|
||||||
|
sleep 2
|
||||||
|
sudo chrt -f -p 80 $CARLA_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
# 6. Auto-connect JACK ports
|
||||||
|
./scripts/carla-jack-autoconnect &
|
||||||
|
AUTOCONNECT_PID=$!
|
||||||
|
|
||||||
|
echo "Audio stack running. Carla PID: $CARLA_PID"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shutdown Sequence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Graceful shutdown
|
||||||
|
|
||||||
|
# 1. Disconnect all Carla ports
|
||||||
|
./scripts/carla-jack-autoconnect --disconnect-all
|
||||||
|
|
||||||
|
# 2. Stop Carla
|
||||||
|
pkill -f carla || true
|
||||||
|
|
||||||
|
# 3. Stop JACK
|
||||||
|
sudo audio-stack-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Headless Operation
|
||||||
|
|
||||||
|
For RPi4B without a display, Carla can operate headlessly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Carla without GUI
|
||||||
|
QT_QPA_PLATFORM=offscreen carla config/carla-8ch-default.carxp &
|
||||||
|
|
||||||
|
# Control via OSC
|
||||||
|
carla-control --osc-port 22752 set_parameter "Ch1 EQ" 0 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### OSC Remote Control
|
||||||
|
|
||||||
|
Carla exposes all parameters via OSC on port 22752 (configurable):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List plugins
|
||||||
|
oscsend localhost 22752 /Carla/refresh
|
||||||
|
|
||||||
|
# Set parameter (plugin_id, param_index, value)
|
||||||
|
oscsend localhost 22752 /Carla/set_parameter i 0 i 0 f 0.707
|
||||||
|
|
||||||
|
# Bypass plugin
|
||||||
|
oscsend localhost 22752 /Carla/set_active i 0 i 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Troubleshooting
|
||||||
|
|
||||||
|
### Carla won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check JACK is running
|
||||||
|
pgrep jackd && echo "JACK OK" || echo "Start JACK first: sudo audio-stack-start"
|
||||||
|
|
||||||
|
# Check Carla binary
|
||||||
|
ldd $(which carla) | grep "not found"
|
||||||
|
# If any libraries missing, rebuild with: ./scripts/carla-build.sh --clean --install
|
||||||
|
|
||||||
|
# Run with debug output
|
||||||
|
carla --debug 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
### No audio (silent, no xruns)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check JACK connections
|
||||||
|
jack_lsp -c | grep system
|
||||||
|
|
||||||
|
# Run auto-connect
|
||||||
|
./scripts/carla-jack-autoconnect
|
||||||
|
|
||||||
|
# Check plugin bypass state — all bypassed = silence
|
||||||
|
# In Carla GUI: check each plugin's bypass button
|
||||||
|
```
|
||||||
|
|
||||||
|
### Xruns with NAM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Switch to nano model
|
||||||
|
# 2. Increase JACK buffer: edit /etc/jackdrc, change -p 128 to -p 256
|
||||||
|
sudo systemctl restart jackd
|
||||||
|
|
||||||
|
# 3. Try feather models with buffer 256
|
||||||
|
# 4. If still xrunning, skip NAM for live use — use it only for re-amping
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin not loading in Carla
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Refresh LV2 plugin cache
|
||||||
|
rm -f ~/.cache/carla/carla-plugin-cache
|
||||||
|
carla --refresh &
|
||||||
|
|
||||||
|
# Check lv2ls
|
||||||
|
lv2ls 2>&1 | grep -i <plugin-name>
|
||||||
|
|
||||||
|
# Validate the plugin
|
||||||
|
./scripts/lv2lint-check.sh /usr/lib/lv2/<plugin-name>.lv2
|
||||||
|
```
|
||||||
|
|
||||||
|
### VST3 plugins not showing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# VST3 SDK was not installed during build
|
||||||
|
ls /opt/vst3sdk && echo "SDK found" || echo "Install SDK and rebuild"
|
||||||
|
# Rebuild: ./scripts/carla-build.sh --clean --install
|
||||||
|
```
|
||||||
|
|
||||||
|
### High CPU / thermal throttling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check CPU temp
|
||||||
|
vcgencmd measure_temp
|
||||||
|
|
||||||
|
# Check if throttled
|
||||||
|
vcgencmd get_throttled
|
||||||
|
|
||||||
|
# If throttling:
|
||||||
|
# - Reduce plugin count
|
||||||
|
# - Bypass unused plugins
|
||||||
|
# - Add active cooling
|
||||||
|
# - Use nano NAM models only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. File Inventory
|
||||||
|
|
||||||
|
```
|
||||||
|
raspberry-pi-mixer/
|
||||||
|
├── scripts/
|
||||||
|
│ ├── carla-build.sh # Build Carla from source (ARM Cortex-A72 optimized)
|
||||||
|
│ ├── nam-build.sh # Build NAM LV2 plugin for ARM
|
||||||
|
│ ├── carla-preset-gen.py # Generate default rack + patchbay presets
|
||||||
|
│ ├── carla-jack-autoconnect # Auto-wire JACK ports to Carla plugins
|
||||||
|
│ ├── carla-preset-manager.py # Save/load/export presets
|
||||||
|
│ ├── lv2lint-check.sh # LV2 plugin validation suite
|
||||||
|
│ ├── audio-stack-start # (from P2-R1) Full audio stack startup
|
||||||
|
│ ├── audio-stack-stop # (from P2-R1) Full audio stack shutdown
|
||||||
|
│ ├── jack-latency-test # (from P2-R1) Round-trip latency measurement
|
||||||
|
│ └── jack-route-default # (from P2-R1) Default JACK routing
|
||||||
|
├── config/
|
||||||
|
│ ├── carla-8ch-default.carxp # Default 8-channel mixer rack preset
|
||||||
|
│ ├── carla-patchbay.xml # Patchbay connection rules
|
||||||
|
│ ├── jackdrc # (from P2-R1) JACK server config
|
||||||
|
│ ├── alsa-usb.conf # (from P2-R1) ALSA USB config
|
||||||
|
│ └── ... # (other P2-R1 configs)
|
||||||
|
└── docs/
|
||||||
|
├── audio-stack-config.md # (from P2-R1) Full audio stack docs
|
||||||
|
└── carla-integration.md # THIS FILE — Carla integration docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lines of Code (new in this task)
|
||||||
|
|
||||||
|
| File | Lines | Purpose |
|
||||||
|
|------|-------|---------|
|
||||||
|
| scripts/carla-build.sh | 196 | Carla build automation |
|
||||||
|
| scripts/nam-build.sh | 184 | NAM LV2 build automation |
|
||||||
|
| scripts/carla-preset-gen.py | 196 | Preset generation |
|
||||||
|
| scripts/carla-jack-autoconnect | 208 | JACK port auto-wiring |
|
||||||
|
| scripts/carla-preset-manager.py | 246 | Preset save/load/export |
|
||||||
|
| scripts/lv2lint-check.sh | 217 | Plugin validation |
|
||||||
|
| config/carla-8ch-default.carxp | auto-generated | Default rack |
|
||||||
|
| config/carla-patchbay.xml | auto-generated | Patchbay rules |
|
||||||
|
| docs/carla-integration.md | this file | Documentation |
|
||||||
|
| **Total** | **~1,247 lines** | 7 scripts + 2 configs + 1 doc |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [ ] Carla builds successfully on RPi4B (`./scripts/carla-build.sh --install`)
|
||||||
|
- [ ] `carla --help` works and shows JACK audio driver
|
||||||
|
- [ ] NAM LV2 builds successfully (`./scripts/nam-build.sh --install`)
|
||||||
|
- [ ] `lv2ls | grep neural` shows NAM plugin
|
||||||
|
- [ ] `./scripts/lv2lint-check.sh` passes with 0 errors
|
||||||
|
- [ ] JACK starts: `sudo audio-stack-start`
|
||||||
|
- [ ] Carla launches with rack: `carla config/carla-8ch-default.carxp`
|
||||||
|
- [ ] Auto-connect wires ports: `./scripts/carla-jack-autoconnect`
|
||||||
|
- [ ] Audio passes through: `jack_lsp -c` shows Carla ↔ system connections
|
||||||
|
- [ ] No xruns at idle: `jack_cpu_load` < 10%
|
||||||
|
- [ ] NAM loads a nano model without xruns (128f buffer)
|
||||||
|
- [ ] Preset save/load works: `./scripts/carla-preset-manager.py save test && load test`
|
||||||
|
- [ ] CPU governor is 'performance': `cat /sys/.../scaling_governor`
|
||||||
|
- [ ] Carla runs at SCHED_FIFO prio 80: `chrt -p $(pgrep carla)`
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
# MIDI Controller Support — Binding + Learn Mode
|
||||||
|
|
||||||
|
**Date:** 2026-05-19
|
||||||
|
**Task:** P4-R1
|
||||||
|
**Target:** Raspberry Pi 4B, RPi OS Lite 64-bit, PREEMPT_RT kernel
|
||||||
|
|
||||||
|
Comprehensive MIDI controller mapping engine with learn mode, clock sync,
|
||||||
|
and JACK MIDI bridge for the RPi Audio Mixer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#1-architecture-overview)
|
||||||
|
2. [Hardware Setup — RPi as USB Host](#2-hardware-setup)
|
||||||
|
3. [Supported Controllers](#3-supported-controllers)
|
||||||
|
4. [Installation](#4-installation)
|
||||||
|
5. [MIDI Learn Mode](#5-midi-learn-mode)
|
||||||
|
6. [MIDI Clock Sync](#6-midi-clock-sync)
|
||||||
|
7. [JACK MIDI Integration](#7-jack-midi-integration)
|
||||||
|
8. [Mapping Persistence](#8-mapping-persistence)
|
||||||
|
9. [Udev Rules — Hotplug](#9-udev-rules)
|
||||||
|
10. [MIDI Mapper Daemon](#10-midi-mapper-daemon)
|
||||||
|
11. [NRPN Support](#11-nrpn-support)
|
||||||
|
12. [File Reference](#12-file-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Physical World │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ X-Touch │ │ MIDImix │ │ FaderFox │ ... │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └─────────────┼─────────────┘ │
|
||||||
|
│ │ USB │
|
||||||
|
├─────────────────────┼────────────────────────────────────────┤
|
||||||
|
│ ┌──────▼──────┐ │
|
||||||
|
│ Kernel │ xHCI USB │ udev rules (99-midi.rules) │
|
||||||
|
│ │ Host Ctrl │ hotplug → ALSA seq clients │
|
||||||
|
│ └──────┬──────┘ │
|
||||||
|
│ │ /dev/snd/midi* │
|
||||||
|
├─────────────────────┼────────────────────────────────────────┤
|
||||||
|
│ ┌──────▼──────┐ │
|
||||||
|
│ ALSA │ ALSA RawMIDI│ snd-usb-audio │
|
||||||
|
│ │ / Sequencer │ snd-seq (clients 20+) │
|
||||||
|
│ └──────┬──────┘ │
|
||||||
|
│ │ │
|
||||||
|
├─────────────────────┼────────────────────────────────────────┤
|
||||||
|
│ ┌──────▼──────┐ │
|
||||||
|
│ Python │ MIDI Engine │ midi_engine.py │
|
||||||
|
│ (our code) │ │ - CC/NRPN parsing │
|
||||||
|
│ │ ┌─────────┐ │ - Mapping lookup │
|
||||||
|
│ │ │ Mapping │ │ - Value scaling │
|
||||||
|
│ │ │ Table │ │ - Learn mode │
|
||||||
|
│ │ └─────────┘ │ - Clock sync │
|
||||||
|
│ └──┬───────┬───┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌─────────▼─┐ ┌──▼──────────┐ │
|
||||||
|
│ │ Parameter │ │ JACK MIDI │ │
|
||||||
|
│ │ Registry │ │ Bridge │ │
|
||||||
|
│ │ (DSP/UI) │ │ (4 ports) │ │
|
||||||
|
│ └────────────┘ └──┬──────────┘ │
|
||||||
|
│ │ JACK MIDI │
|
||||||
|
├──────────────────────────┼────────────────────────────────────┤
|
||||||
|
│ ┌──────▼──────┐ │
|
||||||
|
│ JACK2 │ JACK MIDI │ a2jmidid (ALSA→JACK) │
|
||||||
|
│ │ Ports │ │
|
||||||
|
│ └──────┬──────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────▼──────┐ │
|
||||||
|
│ Carla │ MIDI IN │ → soft synths, samplers │
|
||||||
|
│ │ Ports │ │
|
||||||
|
│ └─────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data flow:**
|
||||||
|
1. USB MIDI controller sends CC/NRPN messages over USB
|
||||||
|
2. Linux kernel (snd-usb-audio) exposes them as ALSA sequencer clients
|
||||||
|
3. Python MIDI engine reads from ALSA seq / rtmidi
|
||||||
|
4. Engine matches messages against the mapping table
|
||||||
|
5. Matched messages are:
|
||||||
|
a. Sent to the Parameter Registry (for DSP processing / UI feedback)
|
||||||
|
b. Forwarded to JACK MIDI output ports (for Carla routing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hardware Setup — RPi as USB Host
|
||||||
|
|
||||||
|
The Raspberry Pi 4B acts as a USB **host** (not device). USB MIDI controllers
|
||||||
|
plug directly into the Pi's USB ports. No OTG adapter needed.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Raspberry Pi 4B with powered USB ports
|
||||||
|
- USB MIDI class-compliant controller (99% of modern controllers)
|
||||||
|
- No drivers needed — Linux UAC2 + snd-usb-audio handles everything
|
||||||
|
|
||||||
|
**Verify USB MIDI is working:**
|
||||||
|
```bash
|
||||||
|
# List raw MIDI devices
|
||||||
|
ls -la /dev/snd/midi*
|
||||||
|
|
||||||
|
# List ALSA sequencer clients (MIDI controllers appear as clients 20+)
|
||||||
|
cat /proc/asound/seq/clients
|
||||||
|
|
||||||
|
# Use our script
|
||||||
|
./scripts/midi-mapper --list-devices
|
||||||
|
|
||||||
|
# Or with amidi
|
||||||
|
amidi -l
|
||||||
|
```
|
||||||
|
|
||||||
|
**Power considerations:**
|
||||||
|
- RPi4B USB ports provide up to 1.2A total
|
||||||
|
- Most MIDI controllers draw <100mA — safe
|
||||||
|
- For bus-powered controllers + audio interface, use a powered USB hub
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Supported Controllers
|
||||||
|
|
||||||
|
### Pre-configured profiles
|
||||||
|
|
||||||
|
| Manufacturer | Model | VID:PID | Faders | Knobs | Buttons | Transport |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Behringer | X-Touch Compact | 1397:00B4 | 9 | 16 | 39 | Yes |
|
||||||
|
| Behringer | X-Touch (MCU) | 1397:00B5 | 9 | 8 | 92 | Yes |
|
||||||
|
| FaderFox | UC4 | 16D0:0D27 | 8 | 0 | 32 | No |
|
||||||
|
| Akai | MIDImix | 09E8:0031 | 9 | 24 | 16 | No |
|
||||||
|
| Arturia | BeatStep | 1C75:0208 | 0 | 17 | 16 | Yes |
|
||||||
|
| Novation | Launch Control XL | 1235:0061 | 8 | 24 | 16 | No |
|
||||||
|
| Korg | nanoKONTROL2 | 0944:0117 | 8 | 8 | 32 | Yes |
|
||||||
|
|
||||||
|
### Adding a new controller
|
||||||
|
|
||||||
|
Add a `ControllerProfile` to `src/midi/controllers.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="YourBrand",
|
||||||
|
model="YourModel",
|
||||||
|
usb_vendor_id=0x1234,
|
||||||
|
usb_product_id=0x5678,
|
||||||
|
alsa_client_pattern="Your Model Name",
|
||||||
|
num_faders=8,
|
||||||
|
num_knobs=8,
|
||||||
|
num_buttons=16,
|
||||||
|
has_transport=True,
|
||||||
|
controls=[
|
||||||
|
ControllerControl("Fader 1", 0, 0, "fader"),
|
||||||
|
# ... etc
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
To discover a controller's CC numbers:
|
||||||
|
1. Install `aseqdump`: `sudo apt install alsa-utils`
|
||||||
|
2. Run: `aseqdump -p <client_id>`
|
||||||
|
3. Wiggle each control and note the CC number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# System packages
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3 python3-pip python3-venv \
|
||||||
|
alsa-utils a2jmidid jackd2
|
||||||
|
|
||||||
|
# Python dependencies
|
||||||
|
pip install python-rtmidi # MIDI I/O via ALSA rawmidi
|
||||||
|
pip install JACK-Client # JACK MIDI port creation
|
||||||
|
pip install pyudev # Hotplug monitoring (optional)
|
||||||
|
|
||||||
|
# Install udev rules
|
||||||
|
sudo cp config/99-midi.rules /etc/udev/rules.d/
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start the JACK MIDI bridge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start a2jmidid to bridge ALSA sequencer → JACK MIDI
|
||||||
|
a2jmidid -e &
|
||||||
|
|
||||||
|
# Or let our bridge do it directly (preferred)
|
||||||
|
./scripts/midi-mapper
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MIDI Learn Mode
|
||||||
|
|
||||||
|
The interactive CLI lets you map physical controls to mixer parameters
|
||||||
|
without editing config files.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/midi-learn-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. **Plug in your MIDI controller** (before or during)
|
||||||
|
2. **Start learn mode:** `./scripts/midi-learn-cli`
|
||||||
|
3. **Pick a parameter:** type `l volume 0` (learn volume for channel 0)
|
||||||
|
4. **Wiggle the control** on your physical controller
|
||||||
|
5. **Confirm:** type `c` (or `d` to discard)
|
||||||
|
6. **Save:** type `save` (or just `quit` — auto-saves)
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `l <param> [ch]` | Start learning for a parameter |
|
||||||
|
| `b [ch]` | Batch learn all faders for a channel |
|
||||||
|
| `c` | Confirm captured mapping |
|
||||||
|
| `d` | Discard captured mapping |
|
||||||
|
| `list` | Show all mappings |
|
||||||
|
| `del <n>` | Delete mapping #n |
|
||||||
|
| `save` | Save to disk |
|
||||||
|
| `status` | Show learn state |
|
||||||
|
| `quit` | Exit (auto-saves) |
|
||||||
|
|
||||||
|
### Example session
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./scripts/midi-learn-cli --session live-set
|
||||||
|
|
||||||
|
midi-learn> l volume 0
|
||||||
|
Listening for MIDI CC on any channel → volume CH0
|
||||||
|
Wiggle the knob/fader you want to assign, then type 'confirm' or 'c'.
|
||||||
|
[wiggle fader 1 on controller]
|
||||||
|
midi-learn> c
|
||||||
|
Confirmed: CH0 volume ← CC70
|
||||||
|
|
||||||
|
midi-learn> l pan 0
|
||||||
|
Listening for MIDI CC on any channel → pan CH0
|
||||||
|
[wiggle knob 1 on controller]
|
||||||
|
midi-learn> c
|
||||||
|
Confirmed: CH0 pan ← CC10
|
||||||
|
|
||||||
|
midi-learn> quit
|
||||||
|
Auto-saved 2 mappings to 'live-set'.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch learn
|
||||||
|
|
||||||
|
Maps faders sequentially without typing between each one:
|
||||||
|
|
||||||
|
```
|
||||||
|
midi-learn> b 0
|
||||||
|
Batch learn for CH0: wiggle each control in order.
|
||||||
|
[1/4] CH0 Vol — wiggle the fader now...
|
||||||
|
[wiggle]
|
||||||
|
[2/4] CH0 Pan — wiggle the pan knob now...
|
||||||
|
[wiggle]
|
||||||
|
[3/4] CH0 Mute — press the mute button now...
|
||||||
|
[press]
|
||||||
|
[4/4] CH0 Gain — wiggle the gain knob now...
|
||||||
|
[wiggle]
|
||||||
|
Batch learn complete: 4 mappings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. MIDI Clock Sync
|
||||||
|
|
||||||
|
The engine accepts MIDI clock (timing clock) messages and derives tempo (BPM).
|
||||||
|
This can sync backing tracks, tempo-synced delays, LFOs, and sequencers.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
- MIDI clock sends **24 pulses per quarter note** (PPQN)
|
||||||
|
- The engine measures inter-pulse intervals
|
||||||
|
- A moving average window (96 pulses = 4 beats) smooths jitter
|
||||||
|
- When the estimate stabilises (window half-full), `bpm_stable = True`
|
||||||
|
- Tempo changes are reported on beat boundaries
|
||||||
|
|
||||||
|
### Usage in the daemon
|
||||||
|
|
||||||
|
The MIDI mapper daemon automatically tracks clock from any connected device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/midi-mapper
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# 19:34:01 Transport: start
|
||||||
|
# 19:34:02 Tempo: 128.5 BPM (stable=True)
|
||||||
|
# 19:34:03 Tempo: 128.4 BPM (stable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transport control
|
||||||
|
|
||||||
|
| MIDI Message | Effect |
|
||||||
|
|---|---|
|
||||||
|
| START (0xFA) | Reset position, begin playing |
|
||||||
|
| CONTINUE (0xFB) | Resume from current position |
|
||||||
|
| STOP (0xFC) | Stop, hold position |
|
||||||
|
| Song Position Pointer | Jump to beat position |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. JACK MIDI Integration
|
||||||
|
|
||||||
|
The engine creates JACK MIDI output ports that Carla can connect to.
|
||||||
|
|
||||||
|
### Direct mode (preferred)
|
||||||
|
|
||||||
|
Uses the `jack` Python module to create JACK MIDI ports directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/midi-mapper
|
||||||
|
# Creates ports:
|
||||||
|
# rpi-mixer:midi_out_0 (channel params: volume, pan, mute, etc.)
|
||||||
|
# rpi-mixer:midi_out_1 (EQ, dynamics params)
|
||||||
|
# rpi-mixer:midi_out_2 (FX sends/returns)
|
||||||
|
# rpi-mixer:midi_out_3 (transport: play, stop, record)
|
||||||
|
```
|
||||||
|
|
||||||
|
In Carla, connect `rpi-mixer:midi_out_*` to your soft synth's MIDI input.
|
||||||
|
|
||||||
|
### ALSA sequencer fallback
|
||||||
|
|
||||||
|
If `jack` module isn't available, the bridge uses ALSA sequencer ports.
|
||||||
|
Run `a2jmidid -e` to bridge them to JACK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
a2jmidid -e &
|
||||||
|
./scripts/midi-mapper --no-jack
|
||||||
|
# ALSA seq ports appear, a2jmidid auto-bridges to JACK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing mapped CCs
|
||||||
|
|
||||||
|
Mapped MIDI messages are forwarded to JACK ports based on parameter category:
|
||||||
|
|
||||||
|
| Parameter type | JACK port |
|
||||||
|
|---|---|
|
||||||
|
| Volume, Pan, Mute, Solo, Gain, Phase | `midi_out_0` |
|
||||||
|
| EQ, Compressor, Gate | `midi_out_1` |
|
||||||
|
| FX sends/returns | `midi_out_2` |
|
||||||
|
| Transport (play, stop, record) | `midi_out_3` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Mapping Persistence
|
||||||
|
|
||||||
|
Mappings are stored as JSON in `~/.config/rpi-mixer/mappings/`.
|
||||||
|
|
||||||
|
### File format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"session": "live-set",
|
||||||
|
"updated": "2026-05-19T23:45:00+00:00",
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"midi": {
|
||||||
|
"type": "CONTROL_CHANGE",
|
||||||
|
"channel": 0,
|
||||||
|
"controller": 70,
|
||||||
|
"is_nrpn": false,
|
||||||
|
"nrpn_number": 0,
|
||||||
|
"source_device": null
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"parameter": "volume",
|
||||||
|
"channel": 0
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"midi_min": 0,
|
||||||
|
"midi_max": 127,
|
||||||
|
"param_min": -60.0,
|
||||||
|
"param_max": 12.0,
|
||||||
|
"invert": false,
|
||||||
|
"curve": "linear"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"label": "CH0 Vol",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
You can maintain multiple mapping configurations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/midi-learn-cli --session studio # Studio setup
|
||||||
|
./scripts/midi-learn-cli --session live-set # Live performance setup
|
||||||
|
./scripts/midi-mapper --session live-set # Launch with specific setup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Udev Rules — Hotplug
|
||||||
|
|
||||||
|
The udev rules in `config/99-midi.rules` provide:
|
||||||
|
|
||||||
|
1. **Automatic permissions:** MIDI devices owned by `audio` group (mode 0660)
|
||||||
|
2. **Persistent symlinks:** Known controllers get stable names under `/dev/midi/`
|
||||||
|
3. **Environment tagging:** `MIDI_CONTROLLER` env var set for known devices
|
||||||
|
4. **Hotplug service trigger:** Systemd service can auto-restart on plug/unplug
|
||||||
|
|
||||||
|
Install:
|
||||||
|
```bash
|
||||||
|
sudo cp config/99-midi.rules /etc/udev/rules.d/
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
The Python hotplug monitor (`device_discovery.py`) uses `pyudev` to detect
|
||||||
|
add/remove events and can re-open ports dynamically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. MIDI Mapper Daemon
|
||||||
|
|
||||||
|
### Starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage
|
||||||
|
./scripts/midi-mapper
|
||||||
|
|
||||||
|
# With specific session
|
||||||
|
./scripts/midi-mapper --session live-set
|
||||||
|
|
||||||
|
# List devices
|
||||||
|
./scripts/midi-mapper --list-devices
|
||||||
|
|
||||||
|
# Without JACK (headless test)
|
||||||
|
./scripts/midi-mapper --no-jack --allow-no-device
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signal handling
|
||||||
|
|
||||||
|
- `SIGINT` (Ctrl+C): Graceful shutdown, outputs stats
|
||||||
|
- `SIGTERM`: Same as SIGINT
|
||||||
|
|
||||||
|
### Stats output
|
||||||
|
|
||||||
|
Every 10 seconds:
|
||||||
|
```
|
||||||
|
19:35:01 Stats: 2847 events, 1423 mapped, 71.2 ev/s, 12 mappings active
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd service (optional)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/midi-mapper.service
|
||||||
|
[Unit]
|
||||||
|
Description=RPi Audio Mixer MIDI Mapper
|
||||||
|
After=jackd.service sound.target
|
||||||
|
Wants=jackd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
ExecStart=/home/pi/rpi-mixer/scripts/midi-mapper --session default
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. NRPN Support
|
||||||
|
|
||||||
|
NRPN (Non-Registered Parameter Number) is a MIDI protocol extension that
|
||||||
|
provides 14-bit resolution (16,384 steps vs 128 for CC). The engine
|
||||||
|
transparently handles the NRPN state machine:
|
||||||
|
|
||||||
|
1. Controller sends CC 99 (NRPN MSB) + CC 98 (NRPN LSB) to select parameter
|
||||||
|
2. Controller sends CC 6 (Data MSB) + CC 38 (Data LSB) for value
|
||||||
|
3. Engine assembles into a 14-bit value
|
||||||
|
4. Mapping lookup is on the full NRPN number
|
||||||
|
|
||||||
|
NRPN is transparent in learn mode — just wiggle the control and the
|
||||||
|
engine detects the CC 99/98 preamble and captures the NRPN number.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. File Reference
|
||||||
|
|
||||||
|
| File | Lines | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/midi/types.py` | ~230 | MIDI message types, enums, data classes |
|
||||||
|
| `src/midi/controllers.py` | ~350 | Known controller profiles database |
|
||||||
|
| `src/midi/device_discovery.py` | ~250 | USB MIDI enumeration + hotplug monitor |
|
||||||
|
| `src/midi/midi_engine.py` | ~260 | Core mapping engine + NRPN state machine |
|
||||||
|
| `src/midi/midi_learn.py` | ~310 | Interactive learn mode state machine |
|
||||||
|
| `src/midi/midi_clock.py` | ~210 | MIDI clock sync + tempo detection |
|
||||||
|
| `src/midi/mapping_store.py` | ~140 | JSON persistence (save/load/list/delete) |
|
||||||
|
| `src/midi/jack_midi_bridge.py` | ~240 | JACK MIDI port creation + ALSA fallback |
|
||||||
|
| `src/midi/__init__.py` | ~40 | Package exports |
|
||||||
|
| `src/mixer/__init__.py` | ~165 | Parameter registry + channel strip factory |
|
||||||
|
| `scripts/midi-mapper` | ~290 | Main daemon entry point |
|
||||||
|
| `scripts/midi-learn-cli` | ~295 | Interactive learn mode CLI |
|
||||||
|
| `config/99-midi.rules` | ~60 | udev rules for USB MIDI hotplug |
|
||||||
|
| `docs/midi-controller-support.md` | This file | Full documentation |
|
||||||
|
|
||||||
|
**Total: ~2,800 lines of Python + config + docs**
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Touchscreen UI Toolkit Evaluation
|
||||||
|
|
||||||
|
Raspberry Pi 4B, official 7" (800×480) or Waveshare 5" (720×720) DSI/HDMI
|
||||||
|
touch displays. Requirements: faders, meters, mute/solo, routing matrix,
|
||||||
|
plugin chain, touch gestures, DPI-aware layout.
|
||||||
|
|
||||||
|
## Candidates
|
||||||
|
|
||||||
|
### LVGL (Light and Versatile Graphics Library)
|
||||||
|
- **Type**: C library, embedded-first, Python bindings via `lvgl` package
|
||||||
|
- **RPi support**: Native framebuffer + SDL2/KMS backends, no X11 required
|
||||||
|
- **Touch**: Native input device support (libinput/evdev), multi-touch, gestures
|
||||||
|
- **DPI**: Pixel-based (px), no automatic scaling — manual math required
|
||||||
|
- **Widgets**: Sliders, buttons, labels, charts, tables — good but basic
|
||||||
|
- **Performance**: ⭐⭐⭐⭐⭐ — C rendering, 30-60 FPS on RPi 4
|
||||||
|
- **Python ergonomics**: ⭐⭐ — Python bindings are auto-generated, not idiomatic;
|
||||||
|
callbacks are C-style, error messages opaque; limited docs for Python usage
|
||||||
|
- **Verdict**: Best performance, but Python API is friction-heavy. Good for C
|
||||||
|
projects. Too much pain for a Python codebase.
|
||||||
|
|
||||||
|
### GTK 3/4
|
||||||
|
- **Type**: C library, GObject introspection for Python (`PyGObject`)
|
||||||
|
- **RPi support**: Requires X11 or Wayland compositor — adds RAM overhead
|
||||||
|
- **Touch**: Mouse-emulated touch; not natively touch-optimized
|
||||||
|
- **DPI**: CSS-based theming, but DPI scaling is complex on small screens
|
||||||
|
- **Widgets**: Very rich (sliders, buttons, tree views, notebooks)
|
||||||
|
- **Performance**: ⭐⭐ — X11/Wayland overhead, not optimized for embedded
|
||||||
|
- **Python ergonomics**: ⭐⭐⭐ — GObject patterns leak through; verbose
|
||||||
|
- **Verdict**: Desktop toolkit forced onto embedded. Heavy, poor touch UX.
|
||||||
|
Best for desktop companion apps, not the primary mixer UI.
|
||||||
|
|
||||||
|
### SDL2
|
||||||
|
- **Type**: C library, Python via `PySDL2`
|
||||||
|
- **RPi support**: Framebuffer/KMSDRM backends, no X11 required
|
||||||
|
- **Touch**: Raw input events only — no gesture recognition built in
|
||||||
|
- **DPI**: None — raw pixels, manual scaling required
|
||||||
|
- **Widgets**: None — you build every widget from primitives (rects, lines, text)
|
||||||
|
- **Performance**: ⭐⭐⭐⭐ — low-level, fast rendering
|
||||||
|
- **Python ergonomics**: ⭐ — no widgets, no layout engine, no theme system.
|
||||||
|
Building a mixer UI in raw SDL2 means writing a UI toolkit from scratch.
|
||||||
|
- **Verdict**: Great for games, terrible for complex UIs. Too much boilerplate.
|
||||||
|
|
||||||
|
### Kivy (selected)
|
||||||
|
- **Type**: Python-native UI framework, OpenGL ES 2.0 backend
|
||||||
|
- **RPi support**: Native RPi support via `kivy-rpi`; framebuffer or X11 backends;
|
||||||
|
works with official 7" and Waveshare displays out of the box
|
||||||
|
- **Touch**: First-class multi-touch, gesture recognition (swipe, pinch, long-press),
|
||||||
|
mouse fallback for development
|
||||||
|
- **DPI**: `dp()` and `sp()` (density-independent pixels/scaled pixels) —
|
||||||
|
automatic scaling across 5" / 7" / desktop displays
|
||||||
|
- **Widgets**: Sliders, buttons, toggles, labels, layouts, screens, tabbed panels
|
||||||
|
- **Performance**: ⭐⭐⭐ — OpenGL-accelerated; 30-60 FPS on RPi 4 for
|
||||||
|
typical mixer UI complexity; GPU handles rendering, CPU free for DSP
|
||||||
|
- **Python ergonomics**: ⭐⭐⭐⭐⭐ — Pure Python, declarative KV language or
|
||||||
|
programmatic widget construction; natural fit for the existing Python codebase
|
||||||
|
- **Verdict**: Best balance of performance, touch UX, and Python ergonomics.
|
||||||
|
Purpose-built for multi-touch interfaces. DPI-aware by default. Rich widget
|
||||||
|
set plus easy custom widget creation for faders/meters.
|
||||||
|
|
||||||
|
## Decision: Kivy
|
||||||
|
|
||||||
|
| Criterion | LVGL | GTK | SDL2 | Kivy |
|
||||||
|
|-----------|------|-----|------|------|
|
||||||
|
| Touch UX | ★★★★ | ★★ | ★ | ★★★★★ |
|
||||||
|
| Performance | ★★★★★ | ★★ | ★★★★ | ★★★ |
|
||||||
|
| Python API | ★★ | ★★★ | ★ | ★★★★★ |
|
||||||
|
| DPI scaling | ★ | ★★★ | ★ | ★★★★★ |
|
||||||
|
| Widget richness | ★★★ | ★★★★★ | ★ | ★★★★ |
|
||||||
|
| RPi setup | ★★★★ | ★★ | ★★★ | ★★★★ |
|
||||||
|
|
||||||
|
Kivy is the clear winner for a Python-project building a touch-optimized mixer
|
||||||
|
UI on Raspberry Pi. The DPI-aware layout system means the same code works on
|
||||||
|
5" and 7" displays without manual scaling math. Multi-touch and gestures
|
||||||
|
come free. Custom widgets (faders, meters, knobs) are straightforward to
|
||||||
|
build on Kivy's Canvas primitives.
|
||||||
|
|
||||||
|
### Installation (RPi)
|
||||||
|
```bash
|
||||||
|
sudo apt install python3-kivy # system package, includes RPi deps
|
||||||
|
# or
|
||||||
|
pip3 install kivy # + manual SDL2/OpenGL deps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development (desktop)
|
||||||
|
```bash
|
||||||
|
pip3 install kivy
|
||||||
|
# Run with: python3 main_touch.py
|
||||||
|
# Mouse works as touch fallback
|
||||||
|
```
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Entry point for the Raspberry Pi Touchscreen Mixer UI.
|
||||||
|
|
||||||
|
A Kivy-based multi-touch mixer control surface for the
|
||||||
|
Raspberry Pi RT Audio Mixer. Connects to the mixer engine
|
||||||
|
via REST API + OSC.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 main_touch.py
|
||||||
|
python3 main_touch.py --host 192.168.1.10 --port 8000
|
||||||
|
python3 main_touch.py --api-key my-secret-key
|
||||||
|
KIVY_DPI=220 python3 main_touch.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Raspberry Pi Touchscreen Mixer UI",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s # local mixer on :8000
|
||||||
|
%(prog)s --host 192.168.1.10 # remote mixer
|
||||||
|
%(prog)s --host 127.0.0.1 --api-key abc123 # with auth
|
||||||
|
KIVY_DPI=220 %(prog)s # force 220 DPI
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
MIXER_HOST mixer hostname (default 127.0.0.1)
|
||||||
|
MIXER_PORT REST API port (default 8000)
|
||||||
|
MIXER_OSC_PORT OSC server port (default 9001)
|
||||||
|
MIXER_API_KEY API key for authentication
|
||||||
|
KIVY_DPI force screen DPI (auto-detected otherwise)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", default=os.environ.get("MIXER_HOST", "127.0.0.1"),
|
||||||
|
help="Mixer server hostname (default: 127.0.0.1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", type=int, default=int(os.environ.get("MIXER_PORT", "8000")),
|
||||||
|
help="REST API port (default: 8000)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--osc-port", type=int, default=int(os.environ.get("MIXER_OSC_PORT", "9001")),
|
||||||
|
help="OSC server port (default: 9001)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key", default=os.environ.get("MIXER_API_KEY", ""),
|
||||||
|
help="API key for mixer authentication",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Ensure src is on the path
|
||||||
|
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
# Import Kivy app (late import after CLI parsing)
|
||||||
|
from src.ui.app import MixerApp
|
||||||
|
|
||||||
|
app = MixerApp(
|
||||||
|
host=args.host,
|
||||||
|
rest_port=args.port,
|
||||||
|
osc_port=args.osc_port,
|
||||||
|
api_key=args.api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Starting touchscreen mixer UI...")
|
||||||
|
print(f" Mixer server: {args.host}:{args.port}")
|
||||||
|
print(f" OSC server: {args.host}:{args.osc_port}")
|
||||||
|
print(f" Touch to begin. ESC to cycle screens.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# audio-stack-start — initialize the full low-latency audio environment
|
||||||
|
# Run with: sudo audio-stack-start
|
||||||
|
# Then JACK runs as the 'pi' user (or current user if not root)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════╗"
|
||||||
|
echo "║ Audio Stack Initialization ║"
|
||||||
|
echo "╚══════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. CPU Governor ──────────────────────────────────
|
||||||
|
echo "1/6 Setting CPU governor → performance..."
|
||||||
|
for cpu in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
|
||||||
|
if [ -f "$cpu" ]; then
|
||||||
|
echo performance > "$cpu" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo " Done."
|
||||||
|
|
||||||
|
# ── 2. IRQ Priorities ────────────────────────────────
|
||||||
|
echo "2/6 Configuring IRQ priorities..."
|
||||||
|
if command -v rtirq > /dev/null 2>&1; then
|
||||||
|
if systemctl is-active --quiet rtirq 2>/dev/null; then
|
||||||
|
systemctl restart rtirq 2>/dev/null || true
|
||||||
|
else
|
||||||
|
systemctl start rtirq 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo " rtirq started."
|
||||||
|
else
|
||||||
|
echo " rtirq not installed. Run: sudo apt install rtirq-init"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. PAM Limits Check ──────────────────────────────
|
||||||
|
echo "3/6 Checking PAM limits..."
|
||||||
|
CURRENT_RTPRIO=$(ulimit -r 2>/dev/null || echo "unknown")
|
||||||
|
CURRENT_MEMLOCK=$(ulimit -l 2>/dev/null || echo "unknown")
|
||||||
|
echo " rtprio: $CURRENT_RTPRIO (need 99)"
|
||||||
|
echo " memlock: $CURRENT_MEMLOCK (need unlimited)"
|
||||||
|
|
||||||
|
# ── 4. USB Audio Driver ──────────────────────────────
|
||||||
|
echo "4/6 Configuring USB audio driver..."
|
||||||
|
if lsmod 2>/dev/null | grep -q snd_usb_audio; then
|
||||||
|
modprobe -r snd-usb-audio 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
modprobe snd-usb-audio nrpacks=1 2>/dev/null || true
|
||||||
|
echo " snd-usb-audio loaded with nrpacks=1"
|
||||||
|
|
||||||
|
# ── 5. USB Audio Detection ───────────────────────────
|
||||||
|
echo "5/6 Checking USB audio device..."
|
||||||
|
sleep 1
|
||||||
|
if aplay -l 2>/dev/null | grep -qi usb; then
|
||||||
|
echo " USB audio interface detected:"
|
||||||
|
aplay -l 2>/dev/null | grep -i usb | head -3 | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " WARNING: No USB audio interface found!"
|
||||||
|
echo " Check: lsusb | grep -i audio"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 6. Start JACK ────────────────────────────────────
|
||||||
|
echo "6/6 Starting JACK2..."
|
||||||
|
if pgrep -x jackd > /dev/null; then
|
||||||
|
echo " JACK is already running. PID: $(pgrep -x jackd)"
|
||||||
|
else
|
||||||
|
if [ "$EUID" -eq 0 ] && id pi >/dev/null 2>&1; then
|
||||||
|
# Running as root, drop to pi user
|
||||||
|
sudo -u pi jackd -P70 -t2000 -d alsa -d hw:USB -r48000 -p128 -n3 -S > /tmp/jackd.log 2>&1 &
|
||||||
|
echo " JACK launched as user 'pi' (PID: $!)"
|
||||||
|
else
|
||||||
|
jackd -P70 -t2000 -d alsa -d hw:USB -r48000 -p128 -n3 -S > /tmp/jackd.log 2>&1 &
|
||||||
|
echo " JACK launched (PID: $!)"
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Final status ─────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════╗"
|
||||||
|
echo "║ Audio Stack Ready ║"
|
||||||
|
echo "╚══════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo " Check ports: jack_lsp -c system"
|
||||||
|
echo " Test latency: jack_delay -O system:playback_1 -I system:capture_1"
|
||||||
|
echo " Auto-route: jack-route-default"
|
||||||
|
echo " Stop stack: audio-stack-stop"
|
||||||
|
echo " Xrun monitor: jack_cpu_load"
|
||||||
|
echo ""
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# audio-stack-stop — gracefully stop the audio stack
|
||||||
|
# Kills JACK and any associated audio tools
|
||||||
|
|
||||||
|
echo "Stopping audio stack..."
|
||||||
|
|
||||||
|
# Graceful shutdown first
|
||||||
|
killall -15 jackd 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Force kill if still running
|
||||||
|
killall -9 jackd 2>/dev/null || true
|
||||||
|
killall -9 jack_delay 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Audio stack stopped."
|
||||||
Executable
+202
@@ -0,0 +1,202 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# carla-build.sh — Build Carla plugin host from source for RPi4B (ARM Cortex-A72)
|
||||||
|
# Run on the Raspberry Pi 4B directly. Estimated build time: 30-60 min.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x carla-build.sh
|
||||||
|
# ./carla-build.sh # Build only
|
||||||
|
# ./carla-build.sh --install # Build + install to /usr/local
|
||||||
|
# ./carla-build.sh --clean # Clean and rebuild
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CARLA_VERSION="2.6.0-alpha1"
|
||||||
|
CARLA_REPO="https://github.com/falkTX/Carla.git"
|
||||||
|
CARLA_DIR="/tmp/carla-build"
|
||||||
|
INSTALL_PREFIX="/usr/local"
|
||||||
|
JOBS=$(nproc 2>/dev/null || echo 4)
|
||||||
|
|
||||||
|
# Cortex-A72 optimized flags for aarch64
|
||||||
|
# -march=armv8-a+crc+crypto covers baseline A72 features
|
||||||
|
# -mtune=cortex-a72 schedules for A72 pipeline
|
||||||
|
# -O3 -ffast-math: aggressive math (safe for audio)
|
||||||
|
A72_CFLAGS="-march=armv8-a+crc+crypto -mtune=cortex-a72 -O3 -ffast-math -fdata-sections -ffunction-sections -fvisibility=hidden"
|
||||||
|
A72_CXXFLAGS="-march=armv8-a+crc+crypto -mtune=cortex-a72 -O3 -ffast-math -fdata-sections -ffunction-sections -fvisibility=hidden -fvisibility-inlines-hidden"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[carla-build]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[carla-build] WARN:${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[carla-build] ERROR:${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── Installation mode ──────────────────────────────────
|
||||||
|
INSTALL=false
|
||||||
|
CLEAN=false
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--install) INSTALL=true; shift ;;
|
||||||
|
--clean) CLEAN=true; shift ;;
|
||||||
|
*) echo "Unknown flag: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Check platform ─────────────────────────────────────
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$ARCH" != "aarch64" ]] && [[ "$ARCH" != "armv7l" ]]; then
|
||||||
|
warn "Not running on ARM (detected $ARCH). Cross-compilation not supported by this script."
|
||||||
|
warn "Run this script directly on the Raspberry Pi 4B."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install build dependencies ─────────────────────────
|
||||||
|
log "Installing build dependencies..."
|
||||||
|
sudo apt update -qq
|
||||||
|
sudo apt install -y \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
cmake \
|
||||||
|
pkg-config \
|
||||||
|
g++ \
|
||||||
|
gcc \
|
||||||
|
make \
|
||||||
|
libasound2-dev \
|
||||||
|
libjack-jackd2-dev \
|
||||||
|
libpulse-dev \
|
||||||
|
libsndfile1-dev \
|
||||||
|
libx11-dev \
|
||||||
|
libxcursor-dev \
|
||||||
|
libxext-dev \
|
||||||
|
libxrandr-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libfontconfig1-dev \
|
||||||
|
libgl1-mesa-dev \
|
||||||
|
liblo-dev \
|
||||||
|
libmagic-dev \
|
||||||
|
libfluidsynth-dev \
|
||||||
|
libsmf-dev \
|
||||||
|
python3-pyqt5 \
|
||||||
|
python3-pyqt5.qtsvg \
|
||||||
|
python3-rdflib \
|
||||||
|
pyqt5-dev-tools \
|
||||||
|
python3-dev \
|
||||||
|
python3-liblo \
|
||||||
|
qtbase5-dev \
|
||||||
|
qtbase5-dev-tools \
|
||||||
|
libfftw3-dev \
|
||||||
|
librtmidi-dev \
|
||||||
|
libzita-resampler-dev
|
||||||
|
|
||||||
|
# Optional: VST3 SDK (needed for VST3 plugin hosting)
|
||||||
|
# The VST3 SDK must be obtained separately from Steinberg.
|
||||||
|
# We check for it but don't error if absent.
|
||||||
|
VST3_SDK_DIR="/opt/vst3sdk"
|
||||||
|
VST3_AVAILABLE=false
|
||||||
|
if [ -d "$VST3_SDK_DIR" ]; then
|
||||||
|
log "VST3 SDK found at $VST3_SDK_DIR"
|
||||||
|
VST3_AVAILABLE=true
|
||||||
|
else
|
||||||
|
warn "VST3 SDK not found at $VST3_SDK_DIR"
|
||||||
|
warn "To add VST3 plugin hosting, download from:"
|
||||||
|
warn " https://github.com/steinbergmedia/vst3sdk.git"
|
||||||
|
warn " sudo git clone --recursive https://github.com/steinbergmedia/vst3sdk.git /opt/vst3sdk"
|
||||||
|
warn "Continuing without VST3 support..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Clone Carla ────────────────────────────────────────
|
||||||
|
if [ "$CLEAN" = true ] || [ ! -d "$CARLA_DIR" ]; then
|
||||||
|
log "Cloning Carla repository..."
|
||||||
|
rm -rf "$CARLA_DIR"
|
||||||
|
git clone --depth 1 --branch main "$CARLA_REPO" "$CARLA_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$CARLA_DIR"
|
||||||
|
|
||||||
|
# ── Patch Makefile for ARM Cortex-A72 ─────────────────
|
||||||
|
log "Patching Carla Makefile for Cortex-A72 optimization..."
|
||||||
|
|
||||||
|
# Back up original
|
||||||
|
if [ ! -f source/Makefile.mk.orig ]; then
|
||||||
|
cp source/Makefile.mk source/Makefile.mk.orig
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add ARM64 optimization flags after the ARM32 section
|
||||||
|
if ! grep -q "CPU_ARM64_OR_AARCH64" source/Makefile.mk; then
|
||||||
|
sed -i '/^ifeq .*CPU_ARM_OR_AARCH64.*/,/^endif/{
|
||||||
|
/^endif/{
|
||||||
|
i\
|
||||||
|
\
|
||||||
|
ifeq ($(CPU_ARM64_OR_AARCH64),true)\
|
||||||
|
BASE_OPTS += -march=armv8-a+crc+crypto -mtune=cortex-a72\
|
||||||
|
endif
|
||||||
|
}
|
||||||
|
}' source/Makefile.mk
|
||||||
|
log "Added ARM64 optimization block to Makefile.mk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for features
|
||||||
|
log "Checking available features..."
|
||||||
|
make features 2>&1 | tee /tmp/carla-features.log || true
|
||||||
|
|
||||||
|
# ── Build Carla ────────────────────────────────────────
|
||||||
|
log "Building Carla (${JOBS} parallel jobs)..."
|
||||||
|
export CFLAGS="$A72_CFLAGS"
|
||||||
|
export CXXFLAGS="$A72_CXXFLAGS"
|
||||||
|
|
||||||
|
# Build with all available features
|
||||||
|
# NOOPT= removes CPU-specific flags so we can supply our own
|
||||||
|
# WITH_LTO=true enables link-time optimization for smaller/faster binary
|
||||||
|
make -j"$JOBS" \
|
||||||
|
PREFIX="$INSTALL_PREFIX" \
|
||||||
|
WITH_LTO=true \
|
||||||
|
2>&1 | tee /tmp/carla-build.log
|
||||||
|
|
||||||
|
log "Carla build complete."
|
||||||
|
|
||||||
|
# ── Check build results ───────────────────────────────
|
||||||
|
if [ -f "source/frontend/carla" ]; then
|
||||||
|
log "✓ Carla frontend binary built successfully."
|
||||||
|
else
|
||||||
|
err "Carla frontend binary not found. Check /tmp/carla-build.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install (optional) ─────────────────────────────────
|
||||||
|
if [ "$INSTALL" = true ]; then
|
||||||
|
log "Installing Carla to $INSTALL_PREFIX..."
|
||||||
|
sudo make install PREFIX="$INSTALL_PREFIX"
|
||||||
|
sudo ldconfig
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if command -v carla >/dev/null 2>&1; then
|
||||||
|
log "✓ Carla installed successfully: $(which carla)"
|
||||||
|
else
|
||||||
|
warn "Carla binary not on PATH. It may be in /usr/local/bin/carla"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create desktop integration
|
||||||
|
if [ -f "resources/carla.desktop" ]; then
|
||||||
|
sudo cp resources/carla.desktop /usr/local/share/applications/
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Output summary ─────────────────────────────────────
|
||||||
|
log ""
|
||||||
|
log "╔══════════════════════════════════════════════╗"
|
||||||
|
log "║ Carla Build Summary ║"
|
||||||
|
log "╚══════════════════════════════════════════════╝"
|
||||||
|
log ""
|
||||||
|
log " Binary: $CARLA_DIR/source/frontend/carla"
|
||||||
|
log " Install: $([ "$INSTALL" = true ] && echo "yes" || echo "no (use --install)")"
|
||||||
|
log " Platform: $ARCH (Cortex-A72 optimized)"
|
||||||
|
log " Plugins: LV2, LADSPA, DSSI, SF2/SFZ"
|
||||||
|
log " VST3: $([ "$VST3_AVAILABLE" = true ] && echo "yes" || echo "no (install VST3 SDK)")"
|
||||||
|
log " JACK: yes (ALSA backend)"
|
||||||
|
log " OSC: yes (liblo)"
|
||||||
|
log " FluidSynth: yes (SF2/SFZ)"
|
||||||
|
log ""
|
||||||
|
log " Next steps:"
|
||||||
|
log " 1. Start JACK: sudo audio-stack-start"
|
||||||
|
log " 2. Run Carla: carla ./config/carla-default.carxp"
|
||||||
|
log " 3. Build NAM: ./scripts/nam-build.sh"
|
||||||
|
log ""
|
||||||
Executable
+184
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
carla-jack-autoconnect — Auto-connect Carla plugins to JACK system ports.
|
||||||
|
|
||||||
|
Monitors JACK for new Carla plugin ports and auto-wires them according
|
||||||
|
to the default mixer routing (carxp preset). Runs as a daemon or one-shot.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 carla-jack-autoconnect # One-shot: connect existing ports
|
||||||
|
python3 carla-jack-autoconnect --watch # Daemon: watch for new ports
|
||||||
|
python3 carla-jack-autoconnect --dry-run # Show what would be connected
|
||||||
|
python3 carla-jack-autoconnect --disconnect-all # Disconnect all Carla ports
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, Tuple, Set
|
||||||
|
|
||||||
|
# ── Routing rules ──────────────────────────────────────
|
||||||
|
# Format: (source_regex, dest_regex, label)
|
||||||
|
# Order matters — first match wins for each dest port.
|
||||||
|
ROUTING_RULES: List[Tuple[str, str, str]] = [
|
||||||
|
# System inputs → Channel processing chain
|
||||||
|
(r"system:capture_1$", r".*Ch1.*Gate.*in[12]$", "Capture 1 → Ch1 Gate"),
|
||||||
|
(r"system:capture_2$", r".*Ch2.*Gate.*in[12]$", "Capture 2 → Ch2 Gate"),
|
||||||
|
(r"system:capture_3$", r".*Ch3.*NAM.*in1$", "Capture 3 → Guitar NAM"),
|
||||||
|
(r"system:capture_4$", r".*Ch4.*in[12]$", "Capture 4 → Ch4"),
|
||||||
|
|
||||||
|
# Chain outputs → chain inputs (plugin internal routing)
|
||||||
|
(r".*Ch1.*Gate.*out[12]$", r".*Ch1.*EQ.*in[12]$", "Ch1 Gate → Ch1 EQ"),
|
||||||
|
(r".*Ch1.*EQ.*out[12]$", r".*Ch1.*Comp.*in[12]$", "Ch1 EQ → Ch1 Comp"),
|
||||||
|
(r".*Ch2.*Gate.*out[12]$", r".*Ch2.*EQ.*in[12]$", "Ch2 Gate → Ch2 EQ"),
|
||||||
|
(r".*Ch2.*EQ.*out[12]$", r".*Ch2.*Comp.*in[12]$", "Ch2 EQ → Ch2 Comp"),
|
||||||
|
(r".*Ch3.*NAM.*out[12]$", r".*Ch3.*IR.*in[12]$", "Ch3 NAM → Ch3 IR Loader"),
|
||||||
|
|
||||||
|
# Master output → System playback
|
||||||
|
(r".*Master.*Limiter.*out1$", r"system:playback_1$", "Master L → Playback 1"),
|
||||||
|
(r".*Master.*Limiter.*out2$", r"system:playback_2$", "Master R → Playback 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def jack_lsp() -> List[str]:
|
||||||
|
"""Get all 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 (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
print("[autoconnect] ERROR: jack_lsp not found. Is JACK running?", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def jack_connect(source: str, dest: str) -> bool:
|
||||||
|
"""Connect two JACK ports."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jack_connect", source, dest],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[autoconnect] Connect failed: {source} → {dest}: {e}", file=sys.stderr)
|
||||||
|
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_current_connections() -> Set[Tuple[str, str]]:
|
||||||
|
"""Get all current JACK connections."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jack_lsp", "-c"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
connections = set()
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
# First token is the output port, subsequent are inputs
|
||||||
|
source = parts[0]
|
||||||
|
for dest in parts[1:]:
|
||||||
|
connections.add((source, dest))
|
||||||
|
return connections
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def match_port(pattern: str, ports: List[str]) -> List[str]:
|
||||||
|
"""Return ports matching a regex pattern."""
|
||||||
|
matched = []
|
||||||
|
for port in ports:
|
||||||
|
if re.search(pattern, port):
|
||||||
|
matched.append(port)
|
||||||
|
return matched
|
||||||
|
|
||||||
|
def apply_routing(dry_run: bool = False, verbose: bool = True) -> Dict:
|
||||||
|
"""Apply routing rules to JACK ports. Returns stats."""
|
||||||
|
ports = jack_lsp()
|
||||||
|
if not ports:
|
||||||
|
return {"status": "no_jack", "connected": 0, "failed": 0, "skipped": 0}
|
||||||
|
|
||||||
|
stats = {"connected": 0, "failed": 0, "skipped": 0, "pairs": []}
|
||||||
|
|
||||||
|
for source_regex, dest_regex, label in ROUTING_RULES:
|
||||||
|
sources = match_port(source_regex, ports)
|
||||||
|
dests = match_port(dest_regex, ports)
|
||||||
|
|
||||||
|
for src in sources:
|
||||||
|
for dst in dests:
|
||||||
|
if dry_run:
|
||||||
|
print(f" [DRY-RUN] {src} → {dst} ({label})")
|
||||||
|
stats["pairs"].append((src, dst, label))
|
||||||
|
else:
|
||||||
|
success = jack_connect(src, dst)
|
||||||
|
if success:
|
||||||
|
if verbose:
|
||||||
|
print(f" ✓ {src} → {dst} ({label})")
|
||||||
|
stats["connected"] += 1
|
||||||
|
else:
|
||||||
|
print(f" ✗ FAILED: {src} → {dst}", file=sys.stderr)
|
||||||
|
stats["failed"] += 1
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def disconnect_all_carla():
|
||||||
|
"""Disconnect all Carla-related ports."""
|
||||||
|
ports = jack_lsp()
|
||||||
|
connections = get_current_connections()
|
||||||
|
count = 0
|
||||||
|
for src, dst in connections:
|
||||||
|
if "Carla" in src or "Carla" in dst:
|
||||||
|
if jack_disconnect(src, dst):
|
||||||
|
print(f" ✂ {src} → {dst}")
|
||||||
|
count += 1
|
||||||
|
print(f"\n Disconnected {count} Carla connections.")
|
||||||
|
return count
|
||||||
|
|
||||||
|
def watch_loop(interval: float = 1.0):
|
||||||
|
"""Watch for new JACK ports and auto-connect them."""
|
||||||
|
print("[autoconnect] Watching for JACK port changes... (Ctrl+C to stop)")
|
||||||
|
seen_ports = set(jack_lsp())
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(interval)
|
||||||
|
current_ports = set(jack_lsp())
|
||||||
|
new_ports = current_ports - seen_ports
|
||||||
|
|
||||||
|
if new_ports:
|
||||||
|
carla_new = {p for p in new_ports if "Carla" in p or "system" in p}
|
||||||
|
if carla_new:
|
||||||
|
print(f"\n[autoconnect] New ports detected: {', '.join(sorted(carla_new))}")
|
||||||
|
apply_routing(dry_run=False, verbose=True)
|
||||||
|
|
||||||
|
seen_ports = current_ports
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[autoconnect] Stopped.")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[autoconnect] Error: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# ── CLI ────────────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--watch" in sys.argv:
|
||||||
|
watch_loop()
|
||||||
|
elif "--dry-run" in sys.argv:
|
||||||
|
print("[autoconnect] DRY RUN — no connections made:\n")
|
||||||
|
apply_routing(dry_run=True)
|
||||||
|
elif "--disconnect-all" in sys.argv:
|
||||||
|
disconnect_all_carla()
|
||||||
|
else:
|
||||||
|
print("[autoconnect] One-shot port connection:\n")
|
||||||
|
stats = apply_routing(dry_run=False)
|
||||||
|
print(f"\n Connected: {stats['connected']} | Failed: {stats['failed']} | Skipped: {stats['skipped']}")
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
carla-preset-gen — Generate Carla rack preset (.carxp) for the mixer setup.
|
||||||
|
|
||||||
|
Creates a default 8-channel mixer rack with:
|
||||||
|
- 2 USB input channels (capture_1/2 → EQ → Comp → Gate)
|
||||||
|
- 1 NAM guitar channel (capture_3 → NAM → IR loader)
|
||||||
|
- 2 stereo aux buses (Reverb, Delay)
|
||||||
|
- Master output to playback_1/2
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/carla-preset-gen.py > config/carla-default.carxp
|
||||||
|
python3 scripts/carla-preset-gen.py --channels 4 > config/carla-4ch.carxp
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# JACK port mapping (matches audio-stack-config parent task)
|
||||||
|
# hw:USB is configured with JACK at 48kHz, 128f/3p
|
||||||
|
JACK_SAMPLE_RATE = 48000
|
||||||
|
JACK_BUFFER_SIZE = 128
|
||||||
|
|
||||||
|
def indent_xml(elem, level=0):
|
||||||
|
"""Pretty-print XML element tree."""
|
||||||
|
i = "\n" + level * " "
|
||||||
|
if len(elem):
|
||||||
|
if not elem.text or not elem.text.strip():
|
||||||
|
elem.text = i + " "
|
||||||
|
if not elem.tail or not elem.tail.strip():
|
||||||
|
elem.tail = i
|
||||||
|
for subelem in elem:
|
||||||
|
indent_xml(subelem, level + 1)
|
||||||
|
if not elem.tail or not elem.tail.strip():
|
||||||
|
elem.tail = i
|
||||||
|
else:
|
||||||
|
if level and (not elem.tail or not elem.tail.strip()):
|
||||||
|
elem.tail = i
|
||||||
|
|
||||||
|
def build_rack_xml(num_channels=8):
|
||||||
|
"""Construct a Carla rack preset XML with standard mixer layout."""
|
||||||
|
|
||||||
|
root = ET.Element("CARLA-PRESET", version="2.6.0")
|
||||||
|
ET.SubElement(root, "Info", Name="RPi Mixer Default Rack", Author="hermes-kanban")
|
||||||
|
|
||||||
|
# ── Engine settings ─────────────────────────────────
|
||||||
|
engine = ET.SubElement(root, "Engine")
|
||||||
|
ET.SubElement(engine, "AudioDriver").text = "JACK"
|
||||||
|
ET.SubElement(engine, "ProcessMode").text = "MultipleClients" # each plugin = separate JACK client
|
||||||
|
ET.SubElement(engine, "TransportMode").text = "Disabled"
|
||||||
|
ET.SubElement(engine, "SampleRate").text = str(JACK_SAMPLE_RATE)
|
||||||
|
ET.SubElement(engine, "BufferSize").text = str(JACK_BUFFER_SIZE)
|
||||||
|
ET.SubElement(engine, "ForceStereo").text = "true"
|
||||||
|
|
||||||
|
# ── Plugin rack ─────────────────────────────────────
|
||||||
|
rack = ET.SubElement(root, "Rack")
|
||||||
|
rack.set("enabled", "true")
|
||||||
|
|
||||||
|
plugin_id = 0
|
||||||
|
|
||||||
|
def add_plugin(name, plugin_type, uri_or_binary, params=None, enabled="true", bypass="false"):
|
||||||
|
nonlocal plugin_id
|
||||||
|
pid = plugin_id
|
||||||
|
plugin_id += 1
|
||||||
|
|
||||||
|
plugin = ET.SubElement(rack, "Plugin")
|
||||||
|
plugin.set("id", str(pid))
|
||||||
|
plugin.set("name", name)
|
||||||
|
plugin.set("type", plugin_type)
|
||||||
|
plugin.set("uri", uri_or_binary)
|
||||||
|
plugin.set("enabled", enabled)
|
||||||
|
plugin.set("bypass", bypass)
|
||||||
|
plugin.set("drywet", "1.0")
|
||||||
|
plugin.set("volume", "0.0") # dB
|
||||||
|
|
||||||
|
if params:
|
||||||
|
for pname, pval in params.items():
|
||||||
|
param = ET.SubElement(plugin, "Parameter")
|
||||||
|
param.set("name", pname)
|
||||||
|
param.set("value", str(pval))
|
||||||
|
|
||||||
|
return pid
|
||||||
|
|
||||||
|
# Channel 1 — Mic/Line input with EQ and Comp
|
||||||
|
add_plugin("Ch1 Gate",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Gate",
|
||||||
|
{"threshold": "-40", "ratio": "4.0", "attack": "10", "release": "100"})
|
||||||
|
|
||||||
|
add_plugin("Ch1 EQ",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Equalizer12Band",
|
||||||
|
{})
|
||||||
|
|
||||||
|
add_plugin("Ch1 Comp",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Compressor",
|
||||||
|
{"threshold": "-20", "ratio": "4.0", "attack": "5", "release": "50", "makeup": "3"})
|
||||||
|
|
||||||
|
# Channel 2 — Second input
|
||||||
|
add_plugin("Ch2 Gate",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Gate",
|
||||||
|
{"threshold": "-40", "ratio": "4.0"})
|
||||||
|
|
||||||
|
add_plugin("Ch2 EQ",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Equalizer8Band",
|
||||||
|
{})
|
||||||
|
|
||||||
|
add_plugin("Ch2 Comp",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Compressor",
|
||||||
|
{"threshold": "-20", "ratio": "4.0"})
|
||||||
|
|
||||||
|
# Channel 3 — Guitar / NAM channel
|
||||||
|
add_plugin("Ch3 NAM",
|
||||||
|
"LV2",
|
||||||
|
"http://github.com/mikeoliphant/neural-amp-modeler-lv2",
|
||||||
|
{"model": "/home/pi/nam-models/default.nam"})
|
||||||
|
|
||||||
|
add_plugin("Ch3 IR Loader",
|
||||||
|
"LV2",
|
||||||
|
"http://guitarix.org/plugins/gx_ampmodeller",
|
||||||
|
{})
|
||||||
|
|
||||||
|
# AUX 1 — Reverb
|
||||||
|
add_plugin("AUX1 Hall Reverb",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Reverb",
|
||||||
|
{"decay_time": "2.5", "dry/wet": "0.3", "room_size": "0.7"})
|
||||||
|
|
||||||
|
# AUX 2 — Delay
|
||||||
|
add_plugin("AUX2 Delay",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/VintageDelay",
|
||||||
|
{"time_l": "250", "time_r": "375", "feedback": "0.4", "dry/wet": "0.25"})
|
||||||
|
|
||||||
|
# Master chain — Limiter
|
||||||
|
add_plugin("Master Limiter",
|
||||||
|
"LV2",
|
||||||
|
"http://calf.sourceforge.net/plugins/Limiter",
|
||||||
|
{"limit": "-1.0", "threshold": "-3.0", "release": "20"})
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
def build_patchbay_xml():
|
||||||
|
"""Construct patchbay connection rules for JACK auto-routing."""
|
||||||
|
root = ET.Element("CARLA-PATCHBAY", version="2.6.0")
|
||||||
|
|
||||||
|
# Connection rules: source → destination with optional regex
|
||||||
|
rules = [
|
||||||
|
# System captures → Channel inputs
|
||||||
|
("system:capture_1", "Carla:Ch1_Gate_in1", "Capture 1 → Ch1 Gate"),
|
||||||
|
("system:capture_2", "Carla:Ch2_Gate_in1", "Capture 2 → Ch2 Gate"),
|
||||||
|
("system:capture_3", "Carla:Ch3_NAM_in1", "Capture 3 → Guitar NAM"),
|
||||||
|
|
||||||
|
# Master stereo output → system playback
|
||||||
|
("Carla:Master_Limiter_out1", "system:playback_1", "Master L → Playback 1"),
|
||||||
|
("Carla:Master_Limiter_out2", "system:playback_2", "Master R → Playback 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx, (source, dest, label) in enumerate(rules):
|
||||||
|
conn = ET.SubElement(root, "Connection")
|
||||||
|
conn.set("id", str(idx))
|
||||||
|
conn.set("source", source)
|
||||||
|
conn.set("dest", dest)
|
||||||
|
conn.set("label", label)
|
||||||
|
conn.set("enabled", "true")
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
# ── CLI interface ──────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
num_channels = 8
|
||||||
|
mode = "rack"
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
i = 0
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "--channels" and i + 1 < len(args):
|
||||||
|
num_channels = int(args[i + 1])
|
||||||
|
i += 2
|
||||||
|
elif args[i] == "--patchbay":
|
||||||
|
mode = "patchbay"
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
print(f"Unknown arg: {args[i]}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if mode == "patchbay":
|
||||||
|
xml = build_patchbay_xml()
|
||||||
|
else:
|
||||||
|
xml = build_rack_xml(num_channels)
|
||||||
|
|
||||||
|
indent_xml(xml)
|
||||||
|
ET.dump(xml)
|
||||||
|
|
||||||
|
# Also write to config/ directory
|
||||||
|
import os
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
config_dir = os.path.join(os.path.dirname(script_dir), "config")
|
||||||
|
os.makedirs(config_dir, exist_ok=True)
|
||||||
|
|
||||||
|
if mode == "patchbay":
|
||||||
|
out_path = os.path.join(config_dir, "carla-patchbay.xml")
|
||||||
|
else:
|
||||||
|
out_path = os.path.join(config_dir, f"carla-{num_channels}ch-default.carxp")
|
||||||
|
|
||||||
|
tree = ET.ElementTree(xml)
|
||||||
|
indent_xml(xml)
|
||||||
|
tree.write(out_path, encoding="utf-8", xml_declaration=True)
|
||||||
|
print(f"\n[preset-gen] Written to {out_path}", file=sys.stderr)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
carla-preset-manager — Manage Carla rack presets and plugin programs.
|
||||||
|
|
||||||
|
Provides a CLI interface to:
|
||||||
|
- List available presets
|
||||||
|
- Save current Carla state as preset
|
||||||
|
- Load presets into Carla (via OSC or file)
|
||||||
|
- Manage NAM model switching
|
||||||
|
- Export/import preset bundles
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 carla-preset-manager.py list
|
||||||
|
python3 carla-preset-manager.py save <name>
|
||||||
|
python3 carla-preset-manager.py load <name>
|
||||||
|
python3 carla-preset-manager.py export <name> <output.tar.gz>
|
||||||
|
python3 carla-preset-manager.py import <bundle.tar.gz>
|
||||||
|
python3 carla-preset-manager.py set-model <plugin-name> <model-path>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
# ── Configuration ──────────────────────────────────────
|
||||||
|
CARLA_CONFIG_DIR = Path(os.path.expanduser("~/.config/falkTX"))
|
||||||
|
PRESET_DIR = Path(os.path.expanduser("~/carla-presets")) # User presets
|
||||||
|
SYSTEM_PRESET_DIR = Path("/home/oplabs/projects/raspberry-pi-mixer/config")
|
||||||
|
NAM_MODELS_DIR = Path(os.path.expanduser("~/nam-models"))
|
||||||
|
|
||||||
|
# Carla OSC control port (configured in Carla settings)
|
||||||
|
CARLA_OSC_PORT = 22752
|
||||||
|
|
||||||
|
os.makedirs(PRESET_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ── Helper: list presets ───────────────────────────────
|
||||||
|
def list_presets() -> List[Dict]:
|
||||||
|
"""List all available presets."""
|
||||||
|
presets = []
|
||||||
|
for preset_file in sorted(PRESET_DIR.glob("*.carxp")):
|
||||||
|
stat = preset_file.stat()
|
||||||
|
presets.append({
|
||||||
|
"name": preset_file.stem,
|
||||||
|
"path": str(preset_file),
|
||||||
|
"size": preset_file.stat().st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
"source": "user"
|
||||||
|
})
|
||||||
|
for preset_file in sorted(SYSTEM_PRESET_DIR.glob("*.carxp")):
|
||||||
|
stat = preset_file.stat()
|
||||||
|
presets.append({
|
||||||
|
"name": preset_file.stem,
|
||||||
|
"path": str(preset_file),
|
||||||
|
"size": preset_file.stat().st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
"source": "system"
|
||||||
|
})
|
||||||
|
return presets
|
||||||
|
|
||||||
|
def cmd_list():
|
||||||
|
"""List presets command."""
|
||||||
|
presets = list_presets()
|
||||||
|
if not presets:
|
||||||
|
print("No presets found.")
|
||||||
|
print(f" System presets: {SYSTEM_PRESET_DIR}")
|
||||||
|
print(f" User presets: {PRESET_DIR}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{'NAME':<30} {'SOURCE':<8} {'SIZE':>8} {'MODIFIED'}")
|
||||||
|
print("-" * 70)
|
||||||
|
for p in presets:
|
||||||
|
size_kb = p["size"] / 1024
|
||||||
|
print(f"{p['name']:<30} {p['source']:<8} {size_kb:>7.1f}K {p['modified'][:16]}")
|
||||||
|
|
||||||
|
# ── Helper: save preset ────────────────────────────────
|
||||||
|
def cmd_save(name: str):
|
||||||
|
"""Save current Carla state as a preset."""
|
||||||
|
carla_carxp = CARLA_CONFIG_DIR / "Carla.carxp"
|
||||||
|
if not carla_carxp.exists():
|
||||||
|
print(f"ERROR: Carla state file not found at {carla_carxp}", file=sys.stderr)
|
||||||
|
print("Is Carla running? The state is saved on exit.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
dest = PRESET_DIR / f"{name}.carxp"
|
||||||
|
shutil.copy2(carla_carxp, dest)
|
||||||
|
print(f"Saved preset: {dest}")
|
||||||
|
|
||||||
|
# Save metadata alongside
|
||||||
|
meta = {
|
||||||
|
"name": name,
|
||||||
|
"saved_at": datetime.now().isoformat(),
|
||||||
|
"source_file": str(carla_carxp),
|
||||||
|
}
|
||||||
|
meta_path = PRESET_DIR / f"{name}.meta.json"
|
||||||
|
with open(meta_path, "w") as f:
|
||||||
|
json.dump(meta, f, indent=2)
|
||||||
|
print(f" Metadata: {meta_path}")
|
||||||
|
|
||||||
|
# ── Helper: load preset ────────────────────────────────
|
||||||
|
def cmd_load(name: str):
|
||||||
|
"""Load a preset into Carla."""
|
||||||
|
# Find the preset
|
||||||
|
preset_path = PRESET_DIR / f"{name}.carxp"
|
||||||
|
if not preset_path.exists():
|
||||||
|
preset_path = SYSTEM_PRESET_DIR / f"{name}.carxp"
|
||||||
|
if not preset_path.exists():
|
||||||
|
print(f"ERROR: Preset '{name}' not found.", file=sys.stderr)
|
||||||
|
print(f" Looked in: {PRESET_DIR}, {SYSTEM_PRESET_DIR}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target = CARLA_CONFIG_DIR / "Carla.carxp"
|
||||||
|
if target.exists():
|
||||||
|
backup = CARLA_CONFIG_DIR / f"Carla.carxp.backup-{int(time.time())}"
|
||||||
|
shutil.copy2(target, backup)
|
||||||
|
print(f"Backed up current state to: {backup}")
|
||||||
|
|
||||||
|
shutil.copy2(preset_path, target)
|
||||||
|
print(f"Loaded preset: {preset_path} → {target}")
|
||||||
|
print("Restart Carla to apply.")
|
||||||
|
|
||||||
|
# ── Helper: export preset bundle ───────────────────────
|
||||||
|
def cmd_export(name: str, output_path: str):
|
||||||
|
"""Export a preset and its associated models/IRs as a .tar.gz bundle."""
|
||||||
|
preset_path = PRESET_DIR / f"{name}.carxp"
|
||||||
|
if not preset_path.exists():
|
||||||
|
preset_path = SYSTEM_PRESET_DIR / f"{name}.carxp"
|
||||||
|
if not preset_path.exists():
|
||||||
|
print(f"ERROR: Preset '{name}' not found.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
meta_path = PRESET_DIR / f"{name}.meta.json"
|
||||||
|
|
||||||
|
with tarfile.open(output_path, "w:gz") as tar:
|
||||||
|
tar.add(preset_path, arcname=f"{name}/{name}.carxp")
|
||||||
|
if meta_path.exists():
|
||||||
|
tar.add(meta_path, arcname=f"{name}/{name}.meta.json")
|
||||||
|
|
||||||
|
print(f"Exported preset bundle: {output_path}")
|
||||||
|
print(f" Contains: {preset_path}")
|
||||||
|
|
||||||
|
# ── Helper: import preset bundle ───────────────────────
|
||||||
|
def cmd_import(bundle_path: str):
|
||||||
|
"""Import a preset bundle from a .tar.gz file."""
|
||||||
|
if not os.path.exists(bundle_path):
|
||||||
|
print(f"ERROR: Bundle not found: {bundle_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
bundle_name = os.path.basename(bundle_path).replace(".tar.gz", "").replace(".tgz", "")
|
||||||
|
|
||||||
|
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||||
|
tar.extractall(path=PRESET_DIR)
|
||||||
|
for member in tar.getmembers():
|
||||||
|
if member.name.endswith(".carxp"):
|
||||||
|
# Move to root of preset dir
|
||||||
|
extracted = PRESET_DIR / member.name
|
||||||
|
dest = PRESET_DIR / f"{bundle_name}.carxp"
|
||||||
|
shutil.move(str(extracted), str(dest))
|
||||||
|
print(f"Imported preset: {dest}")
|
||||||
|
elif member.name.endswith(".meta.json"):
|
||||||
|
meta_dest = PRESET_DIR / f"{bundle_name}.meta.json"
|
||||||
|
extracted = PRESET_DIR / member.name
|
||||||
|
if extracted.exists():
|
||||||
|
shutil.move(str(extracted), str(meta_dest))
|
||||||
|
|
||||||
|
# ── Helper: set NAM model ──────────────────────────────
|
||||||
|
def cmd_set_model(plugin_name: str, model_path: str):
|
||||||
|
"""Set the model for a NAM plugin in Carla via OSC."""
|
||||||
|
if not os.path.exists(model_path):
|
||||||
|
print(f"ERROR: Model file not found: {model_path}", file=sys.stderr)
|
||||||
|
print(f" Download models from: https://www.tone3000.com/", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Carla OSC API:
|
||||||
|
# /Carla/set_plugin_parameter <plugin_id> <param_index> <value>
|
||||||
|
# For NAM, the model param is typically index 0 (atom:Path)
|
||||||
|
print(f"Setting model: {plugin_name} → {model_path}")
|
||||||
|
print(f" Note: Model changes require Carla restart or manual parameter update.")
|
||||||
|
print(f" In Carla GUI: right-click {plugin_name} → atom:Path → select {model_path}")
|
||||||
|
|
||||||
|
# Try OSC if carla-control is available
|
||||||
|
osc_cmd = shutil.which("carla-control")
|
||||||
|
if osc_cmd:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[osc_cmd, "--osc-port", str(CARLA_OSC_PORT),
|
||||||
|
"set_parameter", plugin_name, "0", model_path],
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
print(f" ✓ Model set via OSC.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Could not set via OSC: {e}")
|
||||||
|
|
||||||
|
# ── CLI ────────────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
if __doc__:
|
||||||
|
print(__doc__.strip())
|
||||||
|
print()
|
||||||
|
cmd_list()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if command == "list":
|
||||||
|
cmd_list()
|
||||||
|
elif command == "save":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: carla-preset-manager save <name>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_save(sys.argv[2])
|
||||||
|
elif command == "load":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: carla-preset-manager load <name>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_load(sys.argv[2])
|
||||||
|
elif command == "export":
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("Usage: carla-preset-manager export <name> <output.tar.gz>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_export(sys.argv[2], sys.argv[3])
|
||||||
|
elif command == "import":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: carla-preset-manager import <bundle.tar.gz>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_import(sys.argv[2])
|
||||||
|
elif command == "set-model":
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("Usage: carla-preset-manager set-model <plugin-name> <model-path>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_set_model(sys.argv[2], sys.argv[3])
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: {command}", file=sys.stderr)
|
||||||
|
if __doc__:
|
||||||
|
print(__doc__.strip(), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nCancelled.")
|
||||||
|
sys.exit(0)
|
||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# jack-latency-test — measure USB audio round-trip latency at various buffer sizes
|
||||||
|
# Usage: sudo jack-latency-test [device] [sample_rate]
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - JACK2 installed
|
||||||
|
# - Physical loopback cable: Output 1 -> Input 1
|
||||||
|
# - User in 'audio' group with realtime limits
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DEVICE="${1:-hw:USB}"
|
||||||
|
RATE="${2:-48000}"
|
||||||
|
OUTPUT="system:playback_1"
|
||||||
|
INPUT="system:capture_1"
|
||||||
|
RESULT_FILE="/tmp/jack-latency-results.txt"
|
||||||
|
|
||||||
|
echo "=== JACK Latency Test ==="
|
||||||
|
echo "Device: $DEVICE"
|
||||||
|
echo "Sample rate: $RATE Hz"
|
||||||
|
echo "Output port: $OUTPUT"
|
||||||
|
echo "Input port: $INPUT"
|
||||||
|
echo ""
|
||||||
|
echo ">>> ENSURE PHYSICAL LOOPBACK CABLE IS CONNECTED: Output 1 -> Input 1 <<<"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
: > "$RESULT_FILE"
|
||||||
|
|
||||||
|
# Test various buffer sizes
|
||||||
|
for PERIOD in 32 64 128 256 512 1024; do
|
||||||
|
PERIOD_MS=$(echo "scale=2; $PERIOD / $RATE * 1000" | bc 2>/dev/null || \
|
||||||
|
python3 -c "print(f'{$PERIOD / $RATE * 1000:.2f}')" 2>/dev/null || \
|
||||||
|
echo "?")
|
||||||
|
echo -n "Period ${PERIOD} frames (${PERIOD_MS}ms): "
|
||||||
|
|
||||||
|
# Kill any running JACK
|
||||||
|
killall -9 jackd 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Start JACK with this period
|
||||||
|
jackd -P70 -t2000 -d alsa -d "$DEVICE" -r "$RATE" -p "$PERIOD" -n 3 -S \
|
||||||
|
> /tmp/jackd-latency-test.log 2>&1 &
|
||||||
|
JACK_PID=$!
|
||||||
|
|
||||||
|
# Wait for JACK to be ready
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
if jack_control status 2>/dev/null | grep -q "started"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! jack_control status 2>/dev/null | grep -q "started"; then
|
||||||
|
echo "JACK failed to start with period $PERIOD, skipping"
|
||||||
|
killall -9 jackd 2>/dev/null || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Run jack_delay measurement
|
||||||
|
RESULT=$(jack_delay -O "$OUTPUT" -I "$INPUT" -c 128 2>&1)
|
||||||
|
JACK_DELAY_EXIT=$?
|
||||||
|
|
||||||
|
if [ $JACK_DELAY_EXIT -eq 0 ] && [ -n "$RESULT" ]; then
|
||||||
|
# Parse the result line (last line is the measurement)
|
||||||
|
MEASUREMENT=$(echo "$RESULT" | tail -1)
|
||||||
|
echo "$MEASUREMENT"
|
||||||
|
echo "${PERIOD} frames: ${MEASUREMENT}" >> "$RESULT_FILE"
|
||||||
|
else
|
||||||
|
echo "measurement failed (check loopback cable)"
|
||||||
|
echo "error output: $RESULT" >> "$RESULT_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
killall -9 jackd jack_delay 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Results saved to: $RESULT_FILE"
|
||||||
|
echo ""
|
||||||
|
cat "$RESULT_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "Done."
|
||||||
|
echo ""
|
||||||
|
echo "Target: 128 frames should give <= 12ms round-trip."
|
||||||
|
echo "If above 12ms, check USB cable, power, CPU governor, and IRQ priorities."
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# jack-route-default — auto-connect JACK system captures to playbacks
|
||||||
|
# Run after JACK is started. Creates a 1:1 through-patch for direct monitoring.
|
||||||
|
|
||||||
|
jack_wait -w
|
||||||
|
|
||||||
|
# Get available ports
|
||||||
|
captures=$(jack_lsp -c system | grep capture || true)
|
||||||
|
playbacks=$(jack_lsp -c system | grep playback || true)
|
||||||
|
|
||||||
|
if [ -z "$captures" ] || [ -z "$playbacks" ]; then
|
||||||
|
echo "No system capture or playback ports found. Is JACK running?"
|
||||||
|
echo "Check: jack_lsp -c system"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Route capture -> playback in pairs
|
||||||
|
# Assumes matching channel counts; pairs capture_1->playback_1, etc.
|
||||||
|
MAX_CHANNELS=18
|
||||||
|
|
||||||
|
for i in $(seq 1 $MAX_CHANNELS); do
|
||||||
|
CAP_PORT="system:capture_${i}"
|
||||||
|
PB_PORT="system:playback_${i}"
|
||||||
|
|
||||||
|
if jack_lsp -c system | grep -q "^${CAP_PORT}$" && \
|
||||||
|
jack_lsp -c system | grep -q "^${PB_PORT}$"; then
|
||||||
|
jack_connect "$CAP_PORT" "$PB_PORT" 2>/dev/null && \
|
||||||
|
echo " Connected: $CAP_PORT -> $PB_PORT" || \
|
||||||
|
echo " Already connected: $CAP_PORT -> $PB_PORT"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Optional: start a2jmidid for MIDI bridging
|
||||||
|
if command -v a2j_control &> /dev/null; then
|
||||||
|
if ! pgrep -x a2jmidid > /dev/null; then
|
||||||
|
a2j_control start 2>/dev/null && echo " MIDI bridge (a2jmidid) started"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Routing complete."
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# lv2lint-check.sh — Validate LV2 plugins for compatibility with Carla/RPi4B
|
||||||
|
# Uses lv2lint to check plugin spec compliance, plus runtime checks.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x lv2lint-check.sh
|
||||||
|
# ./lv2lint-check.sh # Check all installed LV2 plugins
|
||||||
|
# ./lv2lint-check.sh /path/to/plugin.lv2 # Check a specific plugin
|
||||||
|
# ./lv2lint-check.sh --nam-only # Only check NAM plugin
|
||||||
|
# ./lv2lint-check.sh --list # List all installed LV2 plugins
|
||||||
|
#
|
||||||
|
# lv2lint checks (from https://git.open-music-kontrollers.ch/lv2/lv2lint):
|
||||||
|
# - URI validity, manifest parsing
|
||||||
|
# - Port definitions (in/out, control/audio/CV)
|
||||||
|
# - TTL consistency
|
||||||
|
# - Required features
|
||||||
|
# - Instantiation test
|
||||||
|
# - Parameter ranges
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[lv2lint]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[lv2lint]${NC} $*"; }
|
||||||
|
fail() { echo -e "${RED}[lv2lint] FAIL:${NC} $*"; }
|
||||||
|
|
||||||
|
# ── Install lv2lint if not present ────────────────────
|
||||||
|
install_lv2lint() {
|
||||||
|
if ! command -v lv2lint >/dev/null 2>&1; then
|
||||||
|
log "lv2lint not found. Building from source..."
|
||||||
|
sudo apt update -qq
|
||||||
|
sudo apt install -y build-essential cmake git \
|
||||||
|
liblilv-dev libserd-dev libsord-dev libsratom-dev \
|
||||||
|
lv2-dev libexempi-dev libsndfile1-dev
|
||||||
|
|
||||||
|
LV2LINT_DIR="/tmp/lv2lint-build"
|
||||||
|
git clone --depth 1 https://git.open-music-kontrollers.ch/lv2/lv2lint "$LV2LINT_DIR" 2>/dev/null || \
|
||||||
|
git clone --depth 1 https://github.com/brummer10/lv2lint.git "$LV2LINT_DIR"
|
||||||
|
|
||||||
|
cd "$LV2LINT_DIR"
|
||||||
|
meson setup build 2>/dev/null || \
|
||||||
|
(mkdir -p build && cd build && cmake .. && make -j"$(nproc)")
|
||||||
|
|
||||||
|
if [ -d "build" ]; then
|
||||||
|
cd build
|
||||||
|
if [ -f "meson.build" ] || [ -f "../meson.build" ]; then
|
||||||
|
meson compile 2>/dev/null && sudo meson install 2>/dev/null || true
|
||||||
|
elif [ -f "Makefile" ]; then
|
||||||
|
make -j"$(nproc)" && sudo make install 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v lv2lint >/dev/null 2>&1; then
|
||||||
|
log "lv2lint installed successfully."
|
||||||
|
else
|
||||||
|
warn "Could not install lv2lint. Skipping spec checks."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── List installed LV2 plugins ────────────────────────
|
||||||
|
list_plugins() {
|
||||||
|
log "Installed LV2 plugins:"
|
||||||
|
echo ""
|
||||||
|
for dir in /usr/lib/lv2 /usr/local/lib/lv2 /usr/lib/aarch64-linux-gnu/lv2 ~/.lv2; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
find "$dir" -maxdepth 2 -name "*.ttl" | while read -r ttl; do
|
||||||
|
uri=$(grep -oP 'a\s+lv2:Plugin' "$(dirname "$ttl")"/*.ttl 2>/dev/null | head -1 || true)
|
||||||
|
if [ -n "$uri" ]; then
|
||||||
|
echo " $(basename "$(dirname "$ttl")")"
|
||||||
|
fi
|
||||||
|
done | sort -u
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Run lv2lint checks on a plugin ─────────────────────
|
||||||
|
check_plugin() {
|
||||||
|
local plugin_uri="$1"
|
||||||
|
local plugin_path="$2"
|
||||||
|
local issues=0
|
||||||
|
|
||||||
|
log "Checking: $(basename "$plugin_path")"
|
||||||
|
echo " URI: $plugin_uri"
|
||||||
|
|
||||||
|
# Manifest check
|
||||||
|
if [ ! -f "$plugin_path/manifest.ttl" ]; then
|
||||||
|
fail " Missing manifest.ttl"
|
||||||
|
issues=$((issues + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Binary check
|
||||||
|
local so_file=$(find "$plugin_path" -name "*.so" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$so_file" ]; then
|
||||||
|
warn " No .so file found (might be an extension-only bundle)"
|
||||||
|
else
|
||||||
|
echo " Binary: $so_file"
|
||||||
|
file "$so_file" 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
# ARM-specific: check architecture
|
||||||
|
if file "$so_file" 2>/dev/null | grep -q "x86"; then
|
||||||
|
fail " WRONG ARCH: x86 binary on ARM. Rebuild!"
|
||||||
|
issues=$((issues + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check shared library deps
|
||||||
|
echo " Shared libs needed:"
|
||||||
|
ldd "$so_file" 2>/dev/null | grep "not found" | while read -r missing; do
|
||||||
|
fail " MISSING: $missing"
|
||||||
|
issues=$((issues + 1))
|
||||||
|
done || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run lv2lint if available
|
||||||
|
if command -v lv2lint >/dev/null 2>&1; then
|
||||||
|
echo " lv2lint output:"
|
||||||
|
local lint_output
|
||||||
|
lint_output=$(lv2lint -M pack -q -s skip "$plugin_uri" 2>&1) || true
|
||||||
|
if [ -n "$lint_output" ]; then
|
||||||
|
echo "$lint_output" | sed 's/^/ /'
|
||||||
|
# Count warnings/errors
|
||||||
|
local lint_issues=$(echo "$lint_output" | grep -ciE "(FAIL|ERROR|WARN)" || true)
|
||||||
|
issues=$((issues + lint_issues))
|
||||||
|
else
|
||||||
|
echo " ✓ No issues found"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for Carla-specific features
|
||||||
|
if grep -qi "carla" "$plugin_path"/*.ttl 2>/dev/null; then
|
||||||
|
echo " ✓ Contains Carla-specific extensions"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
return $issues
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check NAM specifically ─────────────────────────────
|
||||||
|
check_nam() {
|
||||||
|
local nam_dir=""
|
||||||
|
for dir in /usr/lib/lv2/neural_amp_modeler.lv2 \
|
||||||
|
/usr/local/lib/lv2/neural_amp_modeler.lv2 \
|
||||||
|
~/.lv2/neural_amp_modeler.lv2 \
|
||||||
|
/tmp/nam-lv2-build/build/neural_amp_modeler.lv2; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
nam_dir="$dir"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$nam_dir" ]; then
|
||||||
|
warn "NAM LV2 plugin not found. Build it first: ./scripts/nam-build.sh --install"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_plugin "http://github.com/mikeoliphant/neural-amp-modeler-lv2" "$nam_dir"
|
||||||
|
|
||||||
|
# NAM-specific: check if atom:Path is supported (critical for model loading)
|
||||||
|
if grep -q "atom:Path" "$nam_dir"/*.ttl 2>/dev/null; then
|
||||||
|
log "✓ NAM supports atom:Path (model selection in Carla works)"
|
||||||
|
else
|
||||||
|
warn "NAM does not declare atom:Path support. Model loading may not work in Carla."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Performance note for RPi4B
|
||||||
|
echo ""
|
||||||
|
warn "NAM on RPi4B — performance note:"
|
||||||
|
echo " Standard models may cause xruns at 128f buffer."
|
||||||
|
echo " Use 'nano' or 'feather' models from https://www.tone3000.com/"
|
||||||
|
echo " Increase JACK buffer to 256 samples if needed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check all installed plugins ────────────────────────
|
||||||
|
check_all() {
|
||||||
|
local total_issues=0
|
||||||
|
for dir in /usr/lib/lv2 /usr/local/lib/lv2 /usr/lib/aarch64-linux-gnu/lv2 ~/.lv2; do
|
||||||
|
if [ ! -d "$dir" ]; then continue; fi
|
||||||
|
for bundle in "$dir"/*/; do
|
||||||
|
if [ -f "$bundle/manifest.ttl" ]; then
|
||||||
|
# Extract URI from manifest
|
||||||
|
local uri=""
|
||||||
|
uri=$(grep -oP 'a\s+lv2:Plugin' "$bundle"/*.ttl 2>/dev/null | head -1 | sed 's/:.*//' || true)
|
||||||
|
if [ -z "$uri" ]; then
|
||||||
|
# Just use bundle name
|
||||||
|
uri="urn:lv2:$(basename "$bundle")"
|
||||||
|
fi
|
||||||
|
check_plugin "$uri" "${bundle%/}" || total_issues=$((total_issues + $?))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
log "Total issues: $total_issues"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────
|
||||||
|
case "${1:-}" in
|
||||||
|
--list|-l)
|
||||||
|
list_plugins
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--nam-only|-n)
|
||||||
|
install_lv2lint
|
||||||
|
check_nam
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
install_lv2lint
|
||||||
|
check_all
|
||||||
|
check_nam
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ -d "$1" ]; then
|
||||||
|
install_lv2lint
|
||||||
|
check_plugin "urn:lv2:$(basename "$1")" "$1"
|
||||||
|
else
|
||||||
|
echo "Usage: $0 [--list|--nam-only|<plugin-path>]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+313
@@ -0,0 +1,313 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MIDI Learn Mode — interactive CLI for assigning MIDI controllers to mixer parameters.
|
||||||
|
|
||||||
|
Run this on the RPi to map physical knobs/faders to the mixer's parameters.
|
||||||
|
The learned mappings are saved to ~/.config/rpi-mixer/mappings/ for the daemon.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
midi-learn-cli # Interactive learn mode
|
||||||
|
midi-learn-cli --list # List current mappings
|
||||||
|
midi-learn-cli --clear # Clear all mappings for default session
|
||||||
|
midi-learn-cli --batch # Batch learn: assign faders in order
|
||||||
|
midi-learn-cli --session mysetup # Use a named session
|
||||||
|
midi-learn-cli --export mysetup.json # Export mappings to JSON file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path when run as script
|
||||||
|
_proj_root = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_proj_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_proj_root))
|
||||||
|
|
||||||
|
from src.midi.types import ParameterType, MIDIMapping
|
||||||
|
from src.midi.midi_learn import MIDILearn, LearnState
|
||||||
|
from src.midi.mapping_store import (
|
||||||
|
save_mappings,
|
||||||
|
load_mappings,
|
||||||
|
list_sessions,
|
||||||
|
DEFAULT_SESSION_NAME,
|
||||||
|
)
|
||||||
|
from src.mixer import ParameterRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global flag for SIGINT handling
|
||||||
|
_running = True
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_sigint(signum, frame):
|
||||||
|
global _running
|
||||||
|
print("\nInterrupted.", file=sys.stderr)
|
||||||
|
_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging(verbose: bool = False):
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
logging.basicConfig(level=level, format="%(levelname)s %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_mappings(session: str):
|
||||||
|
"""Print current mappings for a session."""
|
||||||
|
mappings = load_mappings(session)
|
||||||
|
if not mappings:
|
||||||
|
print(f"No mappings found for session '{session}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Session '{session}': {len(mappings)} mappings\n")
|
||||||
|
for i, m in enumerate(mappings):
|
||||||
|
src = f"NRPN{m.nrpn_number}" if m.is_nrpn else f"CC{m.controller}"
|
||||||
|
dev = f" [{m.source_device}]" if m.source_device else ""
|
||||||
|
ch = f"CH{m.param_channel}" if m.param_channel >= 0 else "Master"
|
||||||
|
curve = f" {m.curve}" if m.curve != "linear" else ""
|
||||||
|
state = "✓" if m.enabled else "✗"
|
||||||
|
print(f" {i:3d}. [{state}] CH{m.channel:2d} {src:<12s} → {ch:6s} {m.param_type.value:<16s} [{m.midi_min}..{m.midi_max}]{curve}{dev}")
|
||||||
|
|
||||||
|
|
||||||
|
def _interactive_learn(session: str):
|
||||||
|
"""Run the interactive MIDI learn CLI."""
|
||||||
|
global _running
|
||||||
|
signal.signal(signal.SIGINT, _handle_sigint)
|
||||||
|
|
||||||
|
registry = ParameterRegistry()
|
||||||
|
learn = MIDILearn()
|
||||||
|
mappings: list[MIDIMapping] = load_mappings(session)
|
||||||
|
|
||||||
|
print("╔══════════════════════════════════════════════════════════╗")
|
||||||
|
print("║ RPi Audio Mixer — MIDI Learn Mode ║")
|
||||||
|
print("╠══════════════════════════════════════════════════════════╣")
|
||||||
|
print("║ Commands: ║")
|
||||||
|
print("║ l <param> [ch] Learn: start listening for a parameter ║")
|
||||||
|
print("║ b Batch learn: assign faders in order ║")
|
||||||
|
print("║ c Confirm captured mapping ║")
|
||||||
|
print("║ d Discard captured mapping ║")
|
||||||
|
print("║ list List current mappings ║")
|
||||||
|
print("║ save Save mappings to disk ║")
|
||||||
|
print("║ del <n> Delete mapping #n ║")
|
||||||
|
print("║ status Show learn status ║")
|
||||||
|
print("║ help Show this help ║")
|
||||||
|
print("║ quit Exit (auto-saves) ║")
|
||||||
|
print("╚══════════════════════════════════════════════════════════╝")
|
||||||
|
print()
|
||||||
|
print(f"Session: {session} | Mappings loaded: {len(mappings)}")
|
||||||
|
print(f"Parameters available: {len(list(registry.iter_all()))}")
|
||||||
|
print()
|
||||||
|
print("Available parameters:")
|
||||||
|
_print_params(registry)
|
||||||
|
print()
|
||||||
|
print("Type a command. Tip: plug in your MIDI controller first,")
|
||||||
|
print("then type 'l volume 0' and wiggle the fader you want to assign.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while _running:
|
||||||
|
try:
|
||||||
|
raw = input("midi-learn> ").strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = raw.split()
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
|
||||||
|
if cmd == "quit" or cmd == "exit" or cmd == "q":
|
||||||
|
break
|
||||||
|
elif cmd == "help" or cmd == "h":
|
||||||
|
_print_help()
|
||||||
|
elif cmd == "list" or cmd == "ls":
|
||||||
|
_list_current(mappings)
|
||||||
|
elif cmd == "status" or cmd == "st":
|
||||||
|
_print_status(learn, mappings)
|
||||||
|
elif cmd == "save" or cmd == "s":
|
||||||
|
save_mappings(mappings, session)
|
||||||
|
print(f"Saved {len(mappings)} mappings to session '{session}'.")
|
||||||
|
elif cmd == "l" or cmd == "learn":
|
||||||
|
_cmd_learn(parts, learn, registry)
|
||||||
|
elif cmd == "b" or cmd == "batch":
|
||||||
|
_cmd_batch(parts, learn, registry)
|
||||||
|
elif cmd == "c" or cmd == "confirm":
|
||||||
|
_cmd_confirm(learn, mappings)
|
||||||
|
elif cmd == "d" or cmd == "discard":
|
||||||
|
learn.discard()
|
||||||
|
print("Discarded. Ready for new learn.")
|
||||||
|
elif cmd == "del" or cmd == "delete":
|
||||||
|
_cmd_delete(parts, mappings)
|
||||||
|
elif cmd == "clear":
|
||||||
|
mappings.clear()
|
||||||
|
print("All mappings cleared.")
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: {cmd} (type 'help' for commands)")
|
||||||
|
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Auto-save on exit
|
||||||
|
if mappings:
|
||||||
|
save_mappings(mappings, session)
|
||||||
|
print(f"Auto-saved {len(mappings)} mappings to '{session}'.")
|
||||||
|
else:
|
||||||
|
print("No mappings to save.")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_help():
|
||||||
|
print("""
|
||||||
|
Commands:
|
||||||
|
learn, l <param> [ch] Start learning for a parameter
|
||||||
|
batch, b [ch] Batch learn all channel faders in order
|
||||||
|
confirm, c Confirm the last captured mapping
|
||||||
|
discard, d Discard the last captured mapping
|
||||||
|
list, ls List current mappings
|
||||||
|
delete, del <n> Delete mapping #n
|
||||||
|
save, s Save mappings to disk
|
||||||
|
status, st Show current learn status
|
||||||
|
clear Clear all mappings
|
||||||
|
quit, q Exit (auto-saves)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_params(registry: ParameterRegistry):
|
||||||
|
"""Print a compact parameter reference."""
|
||||||
|
# Group by category
|
||||||
|
from src.midi.types import ParameterCategory
|
||||||
|
cats: dict[ParameterCategory, list[str]] = {}
|
||||||
|
for p in registry.iter_all():
|
||||||
|
cats.setdefault(p.category, []).append(p.param_type.value)
|
||||||
|
|
||||||
|
for cat, params in sorted(cats.items()):
|
||||||
|
unique = sorted(set(params))
|
||||||
|
print(f" {cat.value}: {', '.join(unique[:12])}{'...' if len(unique) > 12 else ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_current(mappings: list[MIDIMapping]):
|
||||||
|
if not mappings:
|
||||||
|
print("No mappings defined.")
|
||||||
|
return
|
||||||
|
for i, m in enumerate(mappings):
|
||||||
|
src = f"NRPN{m.nrpn_number}" if m.is_nrpn else f"CC{m.controller}"
|
||||||
|
print(f" {i:3d}. CH{m.channel} {src} → {m.param_type.value} CH{m.param_channel} {'[OFF]' if not m.enabled else ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_status(learn: MIDILearn, mappings: list[MIDIMapping]):
|
||||||
|
print(f"Learn state: {learn.status_text}")
|
||||||
|
print(f"Mappings: {len(mappings)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_learn(parts: list[str], learn: MIDILearn, registry: ParameterRegistry):
|
||||||
|
"""Handle 'learn <param> [channel]' command."""
|
||||||
|
if len(parts) < 2:
|
||||||
|
print("Usage: learn <param> [channel]")
|
||||||
|
print("Example: learn volume 0")
|
||||||
|
return
|
||||||
|
|
||||||
|
param_name = parts[1].lower()
|
||||||
|
channel = int(parts[2]) if len(parts) > 2 else -1
|
||||||
|
|
||||||
|
# Resolve parameter type
|
||||||
|
try:
|
||||||
|
pt = ParameterType(param_name)
|
||||||
|
except ValueError:
|
||||||
|
# Try fuzzy match
|
||||||
|
matches = [p for p in ParameterType if param_name in p.value]
|
||||||
|
if len(matches) == 1:
|
||||||
|
pt = matches[0]
|
||||||
|
elif matches:
|
||||||
|
print(f"Ambiguous: {', '.join(m.value for m in matches)}")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(f"Unknown parameter: {param_name}")
|
||||||
|
print(f"Available: {', '.join(p.value for p in ParameterType)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
learn.start_learn(pt, channel)
|
||||||
|
if channel >= 0:
|
||||||
|
print(f"Listening for MIDI CC on any channel → {pt.value} CH{channel}")
|
||||||
|
else:
|
||||||
|
print(f"Listening for MIDI CC on any channel → {pt.value} (Master)")
|
||||||
|
print(" Wiggle the knob/fader you want to assign, then type 'confirm' or 'c'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_batch(parts: list[str], learn: MIDILearn, registry: ParameterRegistry):
|
||||||
|
"""Handle 'batch [channel]' command — learn all channel params in sequence."""
|
||||||
|
channel = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
|
||||||
|
params: list[tuple[ParameterType, int, str]] = [
|
||||||
|
(ParameterType.VOLUME, channel, f"CH{channel} Vol"),
|
||||||
|
(ParameterType.PAN, channel, f"CH{channel} Pan"),
|
||||||
|
(ParameterType.MUTE, channel, f"CH{channel} Mute"),
|
||||||
|
(ParameterType.GAIN, channel, f"CH{channel} Gain"),
|
||||||
|
]
|
||||||
|
learn.start_batch(params)
|
||||||
|
print(f"Batch learn for CH{channel}: wiggle each control in order.")
|
||||||
|
print(f" [1/4] {params[0][2]} — wiggle the fader now...")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_confirm(learn: MIDILearn, mappings: list[MIDIMapping]):
|
||||||
|
mapping = learn.confirm()
|
||||||
|
if mapping:
|
||||||
|
mappings.append(mapping)
|
||||||
|
print(f"Confirmed: {mapping.label or f'CC{mapping.controller} → {mapping.param_type.value}'}")
|
||||||
|
else:
|
||||||
|
print("Nothing to confirm. Use 'learn <param>' first.")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_delete(parts: list[str], mappings: list[MIDIMapping]):
|
||||||
|
if len(parts) < 2:
|
||||||
|
print("Usage: delete <n>")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
idx = int(parts[1])
|
||||||
|
if 0 <= idx < len(mappings):
|
||||||
|
removed = mappings.pop(idx)
|
||||||
|
print(f"Deleted: {removed.label or f'CC{removed.controller}'}")
|
||||||
|
else:
|
||||||
|
print(f"Invalid index: {idx} (0-{len(mappings)-1})")
|
||||||
|
except ValueError:
|
||||||
|
print(f"Invalid index: {parts[1]}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="MIDI Learn Mode — assign controllers to mixer parameters",
|
||||||
|
)
|
||||||
|
parser.add_argument("--list", action="store_true", help="List current mappings")
|
||||||
|
parser.add_argument("--clear", action="store_true", help="Clear all mappings")
|
||||||
|
parser.add_argument("--batch", action="store_true", help="Batch learn mode (non-interactive)")
|
||||||
|
parser.add_argument("--session", default=DEFAULT_SESSION_NAME, help="Mapping session name")
|
||||||
|
parser.add_argument("--export", metavar="FILE", help="Export mappings to JSON file")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_setup_logging(args.verbose)
|
||||||
|
|
||||||
|
if args.export:
|
||||||
|
mappings = load_mappings(args.session)
|
||||||
|
save_mappings(mappings, os.path.splitext(args.export)[0])
|
||||||
|
print(f"Exported {len(mappings)} mappings to {args.export}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
_list_mappings(args.session)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.clear:
|
||||||
|
save_mappings([], args.session)
|
||||||
|
print(f"Cleared session '{args.session}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive mode
|
||||||
|
_interactive_learn(args.session)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+336
@@ -0,0 +1,336 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MIDI Mapper Daemon — main runtime for the Raspberry Pi RT Audio Mixer.
|
||||||
|
|
||||||
|
Reads MIDI events from connected USB controllers via ALSA sequencer
|
||||||
|
or rtmidi, applies mapping rules, and routes mapped parameter changes
|
||||||
|
to the mixer engine and JACK MIDI output ports.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
midi-mapper # Run with default session
|
||||||
|
midi-mapper --session live-set # Use named mapping session
|
||||||
|
midi-mapper --list-devices # List connected MIDI devices
|
||||||
|
midi-mapper --no-jack # Don't create JACK MIDI ports
|
||||||
|
midi-mapper --port 0 # Only listen on ALSA seq client 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path when run as script
|
||||||
|
_proj_root = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_proj_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_proj_root))
|
||||||
|
|
||||||
|
from src.midi.types import (
|
||||||
|
MIDIMessage,
|
||||||
|
MIDIMessageType,
|
||||||
|
MIDIMapping,
|
||||||
|
)
|
||||||
|
from src.midi.midi_engine import MIDIEngine
|
||||||
|
from src.midi.midi_clock import MIDIClock
|
||||||
|
from src.midi.jack_midi_bridge import JACKMIDIBridge, midi_cc, midi_note_on, midi_note_off
|
||||||
|
from src.midi.device_discovery import discover_all, MIDIDevice
|
||||||
|
from src.midi.mapping_store import load_mappings, DEFAULT_SESSION_NAME
|
||||||
|
from src.mixer import ParameterRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_running = True
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_signal(signum, frame):
|
||||||
|
global _running
|
||||||
|
logger.info("Received signal %d, shutting down...", signum)
|
||||||
|
_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging(verbose: bool = False, quiet: bool = False):
|
||||||
|
if quiet:
|
||||||
|
level = logging.WARNING
|
||||||
|
elif verbose:
|
||||||
|
level = logging.DEBUG
|
||||||
|
else:
|
||||||
|
level = logging.INFO
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_devices():
|
||||||
|
"""Print connected MIDI devices and exit."""
|
||||||
|
devices = discover_all()
|
||||||
|
if not devices:
|
||||||
|
print("No MIDI devices found.")
|
||||||
|
print("Make sure your USB MIDI controller is plugged in.")
|
||||||
|
print("Check with: ls /dev/snd/midi* or cat /proc/asound/seq/clients")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\nFound {len(devices)} MIDI device(s):\n")
|
||||||
|
for d in devices:
|
||||||
|
vidpid = f"{d.usb_vendor_id:04x}:{d.usb_product_id:04x}" if d.usb_vendor_id else "n/a"
|
||||||
|
print(f" {d.display_name}")
|
||||||
|
print(f" Device node: {d.device_node}")
|
||||||
|
print(f" USB VID:PID: {vidpid}")
|
||||||
|
print(f" ALSA seq client: {d.alsa_client_id or 'n/a'}")
|
||||||
|
print(f" Serial: {d.serial or 'n/a'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_parameter_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage):
|
||||||
|
"""Default callback: log parameter changes to console."""
|
||||||
|
ch_str = f"CH{mapping.param_channel}" if mapping.param_channel >= 0 else "Master"
|
||||||
|
logger.info(
|
||||||
|
"PARAM | %s %s = %.3f (MIDI CC%d=%d)",
|
||||||
|
ch_str,
|
||||||
|
mapping.param_type.value,
|
||||||
|
value,
|
||||||
|
mapping.controller,
|
||||||
|
raw_msg.value if raw_msg.value is not None else -1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _jack_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage, bridge: JACKMIDIBridge):
|
||||||
|
"""Forward mapped events to JACK MIDI ports."""
|
||||||
|
ch = mapping.channel
|
||||||
|
cc = mapping.controller
|
||||||
|
midi_val = int(value * 127)
|
||||||
|
midi_val = max(0, min(127, midi_val))
|
||||||
|
|
||||||
|
# Route to different JACK MIDI ports based on parameter category
|
||||||
|
from src.midi.types import ParameterCategory
|
||||||
|
port_idx = 0
|
||||||
|
if mapping.param_type.value.startswith("eq_"):
|
||||||
|
port_idx = 1
|
||||||
|
elif mapping.param_type.value.startswith("comp_"):
|
||||||
|
port_idx = 1
|
||||||
|
elif mapping.param_type.value.startswith("fx_"):
|
||||||
|
port_idx = 2
|
||||||
|
elif mapping.param_type.value in ("play", "stop", "record", "loop"):
|
||||||
|
port_idx = 3
|
||||||
|
|
||||||
|
if cc >= 0:
|
||||||
|
raw = midi_cc(ch, cc, midi_val)
|
||||||
|
bridge.send_midi(port_idx, raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_midi_input(device: MIDIDevice) -> object | None:
|
||||||
|
"""Open a MIDI input port for a device.
|
||||||
|
|
||||||
|
Tries rtmidi first, falls back to ALSA sequencer.
|
||||||
|
Returns the open port object or None.
|
||||||
|
"""
|
||||||
|
# Try rtmidi
|
||||||
|
try:
|
||||||
|
import rtmidi
|
||||||
|
midi_in = rtmidi.MidiIn()
|
||||||
|
# Find the port matching our device
|
||||||
|
ports = midi_in.get_ports()
|
||||||
|
for i, port_name in enumerate(ports):
|
||||||
|
if device.alsa_client_name and device.alsa_client_name.lower() in port_name.lower():
|
||||||
|
midi_in.open_port(i)
|
||||||
|
logger.info("Opened rtmidi port %d: %s", i, port_name)
|
||||||
|
return midi_in
|
||||||
|
if device.product and device.product.lower() in port_name.lower():
|
||||||
|
midi_in.open_port(i)
|
||||||
|
logger.info("Opened rtmidi port %d: %s", i, port_name)
|
||||||
|
return midi_in
|
||||||
|
# If no match, open first available
|
||||||
|
if ports:
|
||||||
|
midi_in.open_port(0)
|
||||||
|
logger.info("Opened rtmidi port 0: %s", ports[0])
|
||||||
|
return midi_in
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("rtmidi not available")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("rtmidi open failed: %s", exc)
|
||||||
|
|
||||||
|
# Try ALSA sequencer
|
||||||
|
try:
|
||||||
|
import alsaseq
|
||||||
|
client = alsaseq.SequencerClient("rpi-mixer-input", client_type=alsaseq.SEQ_CLIENT_DUPLEX)
|
||||||
|
src = (device.alsa_client_id, 0)
|
||||||
|
dest = (client.client_id, 0)
|
||||||
|
port = client.create_port(
|
||||||
|
f"input_{device.alsa_client_id}",
|
||||||
|
caps=alsaseq.SEQ_PORT_CAP_WRITE | alsaseq.SEQ_PORT_CAP_SUBS_WRITE,
|
||||||
|
type=alsaseq.SEQ_PORT_TYPE_MIDI_GENERIC,
|
||||||
|
)
|
||||||
|
client.connect_ports(src, dest)
|
||||||
|
logger.info("Opened ALSA seq port from client %d", device.alsa_client_id)
|
||||||
|
return client
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("alsaseq not available")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ALSA seq open failed: %s", exc)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_midi_rtmidi(midi_in, engine: MIDIEngine, clock: MIDIClock):
|
||||||
|
"""Poll rtmidi for events and feed them to the engine."""
|
||||||
|
msg = midi_in.get_message()
|
||||||
|
while msg is not None:
|
||||||
|
data, timestamp = msg
|
||||||
|
if len(data) >= 1:
|
||||||
|
_dispatch_raw_midi(data, timestamp, engine, clock)
|
||||||
|
msg = midi_in.get_message()
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_raw_midi(data: tuple | list, timestamp: float, engine: MIDIEngine, clock: MIDIClock):
|
||||||
|
"""Parse raw MIDI bytes and dispatch to engine or clock."""
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
status = data[0]
|
||||||
|
|
||||||
|
# System realtime messages → clock
|
||||||
|
if status >= 0xF8:
|
||||||
|
clock.process_message(status)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Channel voice messages → engine
|
||||||
|
if status >= 0x80 and status < 0xF0:
|
||||||
|
msg_type = MIDIMessageType.from_status(status)
|
||||||
|
channel = status & 0x0F
|
||||||
|
controller = data[1] if len(data) > 1 else None
|
||||||
|
value = data[2] if len(data) > 2 else None
|
||||||
|
|
||||||
|
midi_msg = MIDIMessage(
|
||||||
|
msg_type=msg_type,
|
||||||
|
channel=channel,
|
||||||
|
controller=controller,
|
||||||
|
value=value,
|
||||||
|
timestamp=time.monotonic(),
|
||||||
|
)
|
||||||
|
engine.process_event(midi_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _main_loop(args):
|
||||||
|
"""Main event loop: discover devices, open inputs, process MIDI."""
|
||||||
|
global _running
|
||||||
|
|
||||||
|
# Load mappings
|
||||||
|
mappings = load_mappings(args.session)
|
||||||
|
logger.info("Loaded %d mappings from session '%s'", len(mappings), args.session)
|
||||||
|
|
||||||
|
# Create engine
|
||||||
|
engine = MIDIEngine()
|
||||||
|
engine.set_mappings(mappings)
|
||||||
|
engine.on_mapped(_print_parameter_callback)
|
||||||
|
|
||||||
|
# Create clock
|
||||||
|
clock = MIDIClock()
|
||||||
|
clock.on_transport(lambda ev: logger.info("Transport: %s", ev))
|
||||||
|
clock.on_tempo_change(lambda bpm, raw, stable: logger.info("Tempo: %.1f BPM (stable=%s)", bpm, stable))
|
||||||
|
|
||||||
|
# Create JACK bridge
|
||||||
|
bridge = JACKMIDIBridge()
|
||||||
|
if not args.no_jack:
|
||||||
|
bridge.start()
|
||||||
|
engine.on_mapped(lambda m, v, r: _jack_callback(m, v, r, bridge))
|
||||||
|
logger.info("JACK MIDI bridge active")
|
||||||
|
|
||||||
|
# Create parameter registry
|
||||||
|
registry = ParameterRegistry()
|
||||||
|
engine.on_mapped(lambda m, v, r: registry.set_value(m.param_type, m.param_channel, v))
|
||||||
|
|
||||||
|
# Discover and open devices
|
||||||
|
devices = discover_all()
|
||||||
|
logger.info("Found %d MIDI device(s)", len(devices))
|
||||||
|
|
||||||
|
midi_inputs = []
|
||||||
|
for dev in devices:
|
||||||
|
if args.port >= 0 and dev.alsa_client_id != args.port:
|
||||||
|
continue
|
||||||
|
inp = _open_midi_input(dev)
|
||||||
|
if inp:
|
||||||
|
midi_inputs.append((dev, inp))
|
||||||
|
|
||||||
|
if not midi_inputs:
|
||||||
|
logger.warning("No MIDI inputs opened. Is a controller plugged in?")
|
||||||
|
if not args.allow_no_device:
|
||||||
|
logger.error("No MIDI devices available. Use --list-devices to check, or --allow-no-device to run anyway.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
engine.start()
|
||||||
|
logger.info("MIDI mapper running. Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
stats_interval = 10.0
|
||||||
|
last_stats = time.monotonic()
|
||||||
|
|
||||||
|
while _running:
|
||||||
|
# Poll each input
|
||||||
|
for dev, inp in midi_inputs:
|
||||||
|
try:
|
||||||
|
if hasattr(inp, 'get_message'): # rtmidi
|
||||||
|
_read_midi_rtmidi(inp, engine, clock)
|
||||||
|
# ALSA seq events are handled differently (callback-based)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error reading from %s: %s", dev.display_name, exc)
|
||||||
|
|
||||||
|
# Periodic stats
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_stats >= stats_interval:
|
||||||
|
s = engine.stats
|
||||||
|
logger.info(
|
||||||
|
"Stats: %d events, %d mapped, %.1f ev/s, %d mappings active",
|
||||||
|
s["events_total"], s["events_mapped"],
|
||||||
|
s["events_per_second"], s["mappings_active"],
|
||||||
|
)
|
||||||
|
last_stats = now
|
||||||
|
|
||||||
|
time.sleep(0.001) # ~1kHz poll
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
engine.stop()
|
||||||
|
if bridge.is_active:
|
||||||
|
bridge.stop()
|
||||||
|
for dev, inp in midi_inputs:
|
||||||
|
try:
|
||||||
|
if hasattr(inp, 'close_port'):
|
||||||
|
inp.close_port()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("MIDI mapper shut down cleanly.")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="MIDI Mapper Daemon — RPi Audio Mixer MIDI engine",
|
||||||
|
)
|
||||||
|
parser.add_argument("--session", default=DEFAULT_SESSION_NAME, help="Mapping session to load")
|
||||||
|
parser.add_argument("--list-devices", action="store_true", help="List connected MIDI devices and exit")
|
||||||
|
parser.add_argument("--no-jack", action="store_true", help="Don't create JACK MIDI output ports")
|
||||||
|
parser.add_argument("--port", type=int, default=-1, help="Only listen on specific ALSA seq client ID")
|
||||||
|
parser.add_argument("--allow-no-device", action="store_true", help="Run even if no MIDI devices are found")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
|
||||||
|
parser.add_argument("--quiet", "-q", action="store_true", help="Minimal logging")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_setup_logging(args.verbose, args.quiet)
|
||||||
|
|
||||||
|
if args.list_devices:
|
||||||
|
_list_devices()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Signal handling
|
||||||
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
|
||||||
|
_main_loop(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+179
@@ -0,0 +1,179 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# nam-build.sh — Build Neural Amp Modeler LV2 plugin for RPi4B (ARM Cortex-A72)
|
||||||
|
# This builds the headless LV2 version that Carla can host.
|
||||||
|
# Uses RTNeural for real-time neural inference (no libtorch needed).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x nam-build.sh
|
||||||
|
# ./nam-build.sh # Build only
|
||||||
|
# ./nam-build.sh --install # Build + install LV2 to /usr/lib/lv2
|
||||||
|
# ./nam-build.sh --clean # Clean rebuild
|
||||||
|
#
|
||||||
|
# Note: NAM models are CPU-intensive. For RPi4B, use "feather" or "nano"
|
||||||
|
# sized models from https://www.tone3000.com/. Standard models may xrun.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAM_REPO="https://github.com/mikeoliphant/neural-amp-modeler-lv2.git"
|
||||||
|
NAM_DIR="/tmp/nam-lv2-build"
|
||||||
|
JOBS=$(nproc 2>/dev/null || echo 4)
|
||||||
|
|
||||||
|
# Cortex-A72 optimized flags
|
||||||
|
# Use -march=armv8-a (no crypto needed for NAM) + -O3
|
||||||
|
# Enable NEON explicitly for vectorized math
|
||||||
|
A72_FLAGS="-march=armv8-a -mtune=cortex-a72 -O3 -ffast-math -ftree-vectorize"
|
||||||
|
NEON_FLAGS="-mfpu=neon"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[nam-build]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[nam-build] WARN:${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[nam-build] ERROR:${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
INSTALL=false
|
||||||
|
CLEAN=false
|
||||||
|
BUILD_TYPE="Release"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--install) INSTALL=true; shift ;;
|
||||||
|
--clean) CLEAN=true; shift ;;
|
||||||
|
--debug) BUILD_TYPE="Debug"; shift ;;
|
||||||
|
*) echo "Unknown flag: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Check platform ─────────────────────────────────────
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$ARCH" != "aarch64" ]] && [[ "$ARCH" != "armv7l" ]]; then
|
||||||
|
warn "Not running on ARM (detected $ARCH). Cross-compilation not supported."
|
||||||
|
warn "Run this script directly on the Raspberry Pi 4B."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install build dependencies ─────────────────────────
|
||||||
|
log "Installing NAM build dependencies..."
|
||||||
|
sudo apt update -qq
|
||||||
|
sudo apt install -y \
|
||||||
|
build-essential \
|
||||||
|
cmake \
|
||||||
|
pkg-config \
|
||||||
|
g++ \
|
||||||
|
git \
|
||||||
|
libeigen3-dev \
|
||||||
|
lv2-dev \
|
||||||
|
liblilv-dev \
|
||||||
|
libserd-dev \
|
||||||
|
libsord-dev \
|
||||||
|
libsratom-dev
|
||||||
|
|
||||||
|
# Check for Eigen3 (critical)
|
||||||
|
if ! pkg-config --modversion eigen3 2>/dev/null && ! dpkg -l | grep -q libeigen3-dev; then
|
||||||
|
err "Eigen3 not found. Install with: sudo apt install libeigen3-dev"
|
||||||
|
fi
|
||||||
|
log "Eigen3 found: $(pkg-config --modversion eigen3 2>/dev/null || echo 'version unknown')"
|
||||||
|
|
||||||
|
# ── Clone NAM-LV2 ──────────────────────────────────────
|
||||||
|
if [ "$CLEAN" = true ] || [ ! -d "$NAM_DIR" ]; then
|
||||||
|
log "Cloning NAM-LV2 repository (with submodules)..."
|
||||||
|
rm -rf "$NAM_DIR"
|
||||||
|
git clone --recursive -j4 "$NAM_REPO" "$NAM_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$NAM_DIR"
|
||||||
|
|
||||||
|
# ── Check submodule status ─────────────────────────────
|
||||||
|
if [ ! -f "NeuralAudio/CMakeLists.txt" ]; then
|
||||||
|
log "Initializing submodules..."
|
||||||
|
git submodule update --init --recursive
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Create build directory ─────────────────────────────
|
||||||
|
mkdir -p build
|
||||||
|
cd build
|
||||||
|
|
||||||
|
# ── Configure CMake with ARM Cortex-A72 optimizations ──
|
||||||
|
log "Configuring CMake (${BUILD_TYPE}, Cortex-A72 optimized)..."
|
||||||
|
|
||||||
|
# NeuralAudio performance options for ARM:
|
||||||
|
# USE_NATIVE_ARCH=ON → auto-detects CPU features
|
||||||
|
# NEON_OPTIMIZATIONS=ON → enable NEON SIMD in RTNeural
|
||||||
|
# RTNEURAL_NEON=ON → use NEON backend in RTNeural
|
||||||
|
#
|
||||||
|
# For maximum RPi4B performance:
|
||||||
|
# LTO=ON → link-time optimization
|
||||||
|
# SMARTCACHING=ON → cache layer activations to avoid recomputation
|
||||||
|
|
||||||
|
cmake .. \
|
||||||
|
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
|
||||||
|
-DCMAKE_CXX_FLAGS="$A72_FLAGS" \
|
||||||
|
-DCMAKE_C_FLAGS="$A72_FLAGS" \
|
||||||
|
-DUSE_NATIVE_ARCH=ON \
|
||||||
|
-DLTO=ON \
|
||||||
|
2>&1 | tee /tmp/nam-configure.log
|
||||||
|
|
||||||
|
# ── Build ──────────────────────────────────────────────
|
||||||
|
log "Building NAM LV2 (${JOBS} parallel jobs)..."
|
||||||
|
make -j"$JOBS" 2>&1 | tee /tmp/nam-build.log
|
||||||
|
|
||||||
|
# ── Check build results ────────────────────────────────
|
||||||
|
if [ -d "neural_amp_modeler.lv2" ]; then
|
||||||
|
log "✓ NAM LV2 bundle built successfully."
|
||||||
|
LV2_SIZE=$(du -sh neural_amp_modeler.lv2 | cut -f1)
|
||||||
|
log " Plugin size: $LV2_SIZE"
|
||||||
|
if [ -f "neural_amp_modeler.lv2/neural_amp_modeler.so" ]; then
|
||||||
|
file neural_amp_modeler.lv2/neural_amp_modeler.so
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
err "NAM LV2 bundle not found. Check /tmp/nam-build.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install (optional) ─────────────────────────────────
|
||||||
|
if [ "$INSTALL" = true ]; then
|
||||||
|
log "Installing NAM LV2 to system..."
|
||||||
|
LV2_DIR="/usr/lib/lv2"
|
||||||
|
# Respect LIB_INSTALL_DIR if set
|
||||||
|
if [ -n "${LIB_INSTALL_DIR:-}" ]; then
|
||||||
|
LV2_DIR="$LIB_INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
sudo mkdir -p "$LV2_DIR"
|
||||||
|
sudo cp -r neural_amp_modeler.lv2 "$LV2_DIR/"
|
||||||
|
log "✓ Installed to $LV2_DIR/neural_amp_modeler.lv2"
|
||||||
|
|
||||||
|
# Verify with lv2ls
|
||||||
|
if command -v lv2ls >/dev/null 2>&1; then
|
||||||
|
echo ""
|
||||||
|
log "LV2 plugins containing 'neural':"
|
||||||
|
lv2ls 2>/dev/null | grep -i neural || warn "NAM not showing in lv2ls yet. Run: lv2ls"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Model download guidance ────────────────────────────
|
||||||
|
# Check if lv2_validate is available (from lilv-utils)
|
||||||
|
log ""
|
||||||
|
log "╔══════════════════════════════════════════════╗"
|
||||||
|
log "║ NAM LV2 Build Summary ║"
|
||||||
|
log "╚══════════════════════════════════════════════╝"
|
||||||
|
log ""
|
||||||
|
log " Bundle: neural_amp_modeler.lv2"
|
||||||
|
log " Platform: $ARCH (Cortex-A72 optimized)"
|
||||||
|
log " Build type: $BUILD_TYPE"
|
||||||
|
log " Installed: $([ "$INSTALL" = true ] && echo "yes" || echo "no (use --install)")"
|
||||||
|
log ""
|
||||||
|
log " Model recommendations for RPi4B:"
|
||||||
|
log " • Use 'nano' or 'feather' sized models"
|
||||||
|
log " • Standard models are too CPU-heavy"
|
||||||
|
log " • Browse: https://www.tone3000.com/search?sizes=feather"
|
||||||
|
log ""
|
||||||
|
log " Using NAM in Carla:"
|
||||||
|
log " 1. Start Carla: carla ./config/carla-default.carxp"
|
||||||
|
log " 2. Add Plugin → LV2 → Neural Amp Modeler"
|
||||||
|
log " 3. Set model path via atom:Path parameter"
|
||||||
|
log " 4. Place an IR loader AFTER NAM for cab sim"
|
||||||
|
log ""
|
||||||
|
log " Download example models:"
|
||||||
|
log " mkdir -p ~/nam-models"
|
||||||
|
log " # Download feather models from Tone3000 and place here"
|
||||||
|
log ""
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Plugin Manager CLI — scan, list, install, remove, and manage audio plugins.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
plugin-mgr scan Full scan and registry sync
|
||||||
|
plugin-mgr list List all registered plugins
|
||||||
|
plugin-mgr list --format=lv2 List by format
|
||||||
|
plugin-mgr list --category=reverb List by category
|
||||||
|
plugin-mgr search <query> Search plugins
|
||||||
|
plugin-mgr info <uri|name> Show plugin details
|
||||||
|
plugin-mgr install-local <path> Install from local path
|
||||||
|
plugin-mgr install-nam <path> Install local .nam model
|
||||||
|
plugin-mgr download-nam <url> Download .nam model
|
||||||
|
plugin-mgr remove <uri|name> Remove plugin (registry only)
|
||||||
|
plugin-mgr remove --files <uri> Remove plugin + delete files
|
||||||
|
plugin-mgr enable <uri> Enable a disabled plugin
|
||||||
|
plugin-mgr disable <uri> Disable a plugin
|
||||||
|
plugin-mgr blacklist <uri> Blacklist a plugin
|
||||||
|
plugin-mgr unblacklist <uri> Remove from blacklist
|
||||||
|
plugin-mgr blacklist-list Show all blacklist rules
|
||||||
|
plugin-mgr blacklist-add <pat> Add a user blacklist rule
|
||||||
|
plugin-mgr nam-list List installed NAM models
|
||||||
|
plugin-mgr stats Show registry statistics
|
||||||
|
plugin-mgr vacuum Compact/optimize the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure the src directory is on the path
|
||||||
|
_project_root = Path(__file__).resolve().parent.parent
|
||||||
|
_src = _project_root / "src"
|
||||||
|
if str(_src) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_src))
|
||||||
|
|
||||||
|
from plugin import (
|
||||||
|
PluginManager,
|
||||||
|
PluginCategory,
|
||||||
|
PluginFormat,
|
||||||
|
PluginStatus,
|
||||||
|
PluginBlacklist,
|
||||||
|
BlacklistEntry,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
BUILTIN_BLACKLIST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI argument definitions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="plugin-mgr",
|
||||||
|
description="RPi Mixer Plugin Manager — scan, register, and manage audio plugins",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=textwrap.dedent("""\
|
||||||
|
Examples:
|
||||||
|
plugin-mgr scan
|
||||||
|
plugin-mgr list --format=lv2
|
||||||
|
plugin-mgr list --category=reverb
|
||||||
|
plugin-mgr search "tube amp"
|
||||||
|
plugin-mgr install-nam ~/downloads/my-tone.nam
|
||||||
|
plugin-mgr stats
|
||||||
|
"""),
|
||||||
|
)
|
||||||
|
sp = parser.add_subparsers(dest="command", help="Command to execute")
|
||||||
|
|
||||||
|
# scan
|
||||||
|
sp.add_parser("scan", help="Scan filesystem and sync registry")
|
||||||
|
|
||||||
|
# list
|
||||||
|
list_p = sp.add_parser("list", help="List registered plugins")
|
||||||
|
list_p.add_argument("--format", "-f", help="Filter by format (lv2, vst3, ladspa, nam)")
|
||||||
|
list_p.add_argument("--category", "-c", help="Filter by category (reverb, delay, etc.)")
|
||||||
|
list_p.add_argument("--loadable", "-l", action="store_true", help="Only show loadable plugins")
|
||||||
|
list_p.add_argument("--verbose", "-v", action="store_true", help="Show ports and metadata")
|
||||||
|
|
||||||
|
# search
|
||||||
|
search_p = sp.add_parser("search", help="Search plugins by name/description")
|
||||||
|
search_p.add_argument("query", help="Search query")
|
||||||
|
search_p.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
||||||
|
|
||||||
|
# info
|
||||||
|
info_p = sp.add_parser("info", help="Show detailed plugin info")
|
||||||
|
info_p.add_argument("plugin", help="Plugin URI or name")
|
||||||
|
|
||||||
|
# install-local
|
||||||
|
install_p = sp.add_parser("install-local", help="Install plugin from local path")
|
||||||
|
install_p.add_argument("path", help="Path to plugin bundle directory or file")
|
||||||
|
install_p.add_argument("--format", "-f", default="lv2", help="Plugin format (default: lv2)")
|
||||||
|
|
||||||
|
# install-nam
|
||||||
|
nam_inst_p = sp.add_parser("install-nam", help="Install local .nam model")
|
||||||
|
nam_inst_p.add_argument("path", help="Path to .nam file")
|
||||||
|
nam_inst_p.add_argument("--name", "-n", help="Name for the model")
|
||||||
|
|
||||||
|
# download-nam
|
||||||
|
nam_dl_p = sp.add_parser("download-nam", help="Download .nam model from URL")
|
||||||
|
nam_dl_p.add_argument("url", help="Download URL")
|
||||||
|
nam_dl_p.add_argument("--name", "-n", help="Name for the model")
|
||||||
|
|
||||||
|
# nam-list
|
||||||
|
sp.add_parser("nam-list", help="List installed NAM models")
|
||||||
|
|
||||||
|
# remove
|
||||||
|
remove_p = sp.add_parser("remove", help="Remove plugin from registry")
|
||||||
|
remove_p.add_argument("plugin", help="Plugin URI or name")
|
||||||
|
remove_p.add_argument("--files", action="store_true", help="Also delete plugin files from disk")
|
||||||
|
|
||||||
|
# enable / disable
|
||||||
|
enable_p = sp.add_parser("enable", help="Enable a disabled plugin")
|
||||||
|
enable_p.add_argument("plugin", help="Plugin URI or name")
|
||||||
|
disable_p = sp.add_parser("disable", help="Disable a plugin")
|
||||||
|
disable_p.add_argument("plugin", help="Plugin URI or name")
|
||||||
|
|
||||||
|
# blacklist
|
||||||
|
bl_p = sp.add_parser("blacklist", help="Blacklist a plugin")
|
||||||
|
bl_p.add_argument("plugin", help="Plugin URI or name")
|
||||||
|
unbl_p = sp.add_parser("unblacklist", help="Remove plugin from blacklist")
|
||||||
|
unbl_p.add_argument("plugin", help="Plugin URI or name")
|
||||||
|
|
||||||
|
# blacklist-list
|
||||||
|
bl_list = sp.add_parser("blacklist-list", help="Show all blacklist rules")
|
||||||
|
bl_list.add_argument("--builtin", action="store_true", help="Only show built-in rules")
|
||||||
|
bl_list.add_argument("--user", action="store_true", help="Only show user-defined rules")
|
||||||
|
|
||||||
|
# blacklist-add
|
||||||
|
bl_add = sp.add_parser("blacklist-add", help="Add a user blacklist rule")
|
||||||
|
bl_add.add_argument("pattern", help="Glob pattern to match (e.g., '*BrokenPlugin*')")
|
||||||
|
bl_add.add_argument("--field", default="name", help="Field to match: uri, name, path (default: name)")
|
||||||
|
bl_add.add_argument("--reason", "-r", default="User-defined blacklist", help="Reason for blacklisting")
|
||||||
|
bl_add.add_argument("--severity", default="block", choices=["block", "warn"], help="Severity (default: block)")
|
||||||
|
|
||||||
|
# stats
|
||||||
|
sp.add_parser("stats", help="Show registry statistics")
|
||||||
|
|
||||||
|
# vacuum
|
||||||
|
sp.add_parser("vacuum", help="Compact and optimize the database")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# ── Output helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fmt_plugin_short(p) -> str:
|
||||||
|
"""One-line plugin summary."""
|
||||||
|
status_icon = {
|
||||||
|
PluginStatus.ACTIVE: "[+]",
|
||||||
|
PluginStatus.DISABLED: "[-]",
|
||||||
|
PluginStatus.BLACKLISTED: "[!]",
|
||||||
|
PluginStatus.STALE: "[?]",
|
||||||
|
PluginStatus.ERROR: "[E]",
|
||||||
|
PluginStatus.REMOVED: " ",
|
||||||
|
}.get(p.status, "[ ]")
|
||||||
|
|
||||||
|
cats = ", ".join(c.value for c in p.categories[:3])
|
||||||
|
if len(p.categories) > 3:
|
||||||
|
cats += ", …"
|
||||||
|
|
||||||
|
io_str = f"{p.audio_inputs}i/{p.audio_outputs}o"
|
||||||
|
if p.midi_inputs or p.midi_outputs:
|
||||||
|
io_str += f" MIDI:{p.midi_inputs}i/{p.midi_outputs}o"
|
||||||
|
|
||||||
|
nam_tag = ""
|
||||||
|
if p.nam_model_size:
|
||||||
|
compat = "✓" if p.rpi4b_known_good else "⚠" if p.rpi4b_known_broken else "?"
|
||||||
|
nam_tag = f" [NAM/{p.nam_model_size} {compat}]"
|
||||||
|
|
||||||
|
cpu_str = f" CPU~{p.estimated_cpu_pct:.0f}%" if p.estimated_cpu_pct else ""
|
||||||
|
rpi_tag = " [RPi4B✓]" if p.rpi4b_known_good else " [RPi4B⚠]" if p.rpi4b_known_broken else ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{status_icon} {p.meta.name:<30} "
|
||||||
|
f"{p.meta.format.value:<6} {io_str:<12} "
|
||||||
|
f"[{cats}]{nam_tag}{rpi_tag}{cpu_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_plugin_verbose(p) -> str:
|
||||||
|
"""Detailed plugin info block."""
|
||||||
|
lines = [
|
||||||
|
f"Name: {p.meta.name}",
|
||||||
|
f"URI: {p.meta.uri}",
|
||||||
|
f"Format: {p.meta.format.value}",
|
||||||
|
f"Version: {p.meta.version or '—'}",
|
||||||
|
f"Author: {p.meta.author or '—'}",
|
||||||
|
f"License: {p.meta.license or '—'}",
|
||||||
|
f"Description: {p.meta.description or '—'}",
|
||||||
|
f"Homepage: {p.meta.homepage or '—'}",
|
||||||
|
f"Project: {p.meta.project or '—'}",
|
||||||
|
f"Categories: {', '.join(CATEGORY_LABELS.get(c, c.value) for c in p.categories)}",
|
||||||
|
f"Status: {p.status.value}",
|
||||||
|
"",
|
||||||
|
f"Audio I/O: {p.audio_inputs} in, {p.audio_outputs} out",
|
||||||
|
f"MIDI I/O: {p.midi_inputs} in, {p.midi_outputs} out",
|
||||||
|
f"Bundle: {p.bundle_path or '—'}",
|
||||||
|
f"Library: {p.library_path or '—'}",
|
||||||
|
f"Arch: {p.meta.arch or '—'} (ARM optimized: {'yes' if p.meta.arm_optimized else 'no'})",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if p.rpi4b_known_good:
|
||||||
|
lines.append("RPi4B: ✓ Verified working")
|
||||||
|
elif p.rpi4b_known_broken:
|
||||||
|
lines.append("RPi4B: ⚠ Known broken")
|
||||||
|
if p.estimated_cpu_pct:
|
||||||
|
lines.append(f"Est. CPU: ~{p.estimated_cpu_pct:.1f}%")
|
||||||
|
if p.estimated_ram_mb:
|
||||||
|
lines.append(f"Est. RAM: ~{p.estimated_ram_mb:.0f} MB")
|
||||||
|
|
||||||
|
if p.nam_model_path:
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"NAM Model: {p.nam_model_path}")
|
||||||
|
lines.append(f"Model Size: {p.nam_model_size}")
|
||||||
|
|
||||||
|
if p.control_ports:
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"Control Ports ({len(p.control_ports)}):")
|
||||||
|
for port in p.control_ports[:20]:
|
||||||
|
lines.append(f" [{port.index:3d}] {port.name:<25} {port.direction:6} "
|
||||||
|
f"{port.minimum:7.2f} .. {port.maximum:7.2f} (def: {port.default:.2f})")
|
||||||
|
if len(p.control_ports) > 20:
|
||||||
|
lines.append(f" … and {len(p.control_ports) - 20} more")
|
||||||
|
|
||||||
|
if p.error_message:
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"Error: {p.error_message}")
|
||||||
|
|
||||||
|
if p.scanned_at:
|
||||||
|
from datetime import datetime
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"Scanned: {datetime.fromtimestamp(p.scanned_at).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve plugin by URI or name ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolve(mgr: PluginManager, identifier: str):
|
||||||
|
"""Resolve a plugin by URI or name (case-insensitive)."""
|
||||||
|
plugin = mgr.registry.get(identifier)
|
||||||
|
if plugin:
|
||||||
|
return plugin
|
||||||
|
plugin = mgr.registry.get_by_name(identifier)
|
||||||
|
if plugin:
|
||||||
|
return plugin
|
||||||
|
# Try partial name match
|
||||||
|
for p in mgr.registry.list_all():
|
||||||
|
if identifier.lower() in p.meta.name.lower():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command is None:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
mgr = PluginManager()
|
||||||
|
|
||||||
|
# ── scan ─────────────────────────────────────────────────────────────
|
||||||
|
if args.command == "scan":
|
||||||
|
print("Scanning filesystem for audio plugins…")
|
||||||
|
result = mgr.sync()
|
||||||
|
print(f" New: {result['inserted']}")
|
||||||
|
print(f" Updated: {result['updated']}")
|
||||||
|
print(f" Stale: {result['stale']}")
|
||||||
|
print(f"Done.")
|
||||||
|
|
||||||
|
# ── list ─────────────────────────────────────────────────────────────
|
||||||
|
elif args.command == "list":
|
||||||
|
fmt = PluginFormat(args.format) if args.format else None
|
||||||
|
cat = PluginCategory(args.category) if args.category else None
|
||||||
|
|
||||||
|
if fmt:
|
||||||
|
plugins = mgr.registry.list_by_format(fmt)
|
||||||
|
elif cat:
|
||||||
|
plugins = mgr.registry.list_by_category(cat)
|
||||||
|
else:
|
||||||
|
plugins = mgr.registry.list_all()
|
||||||
|
|
||||||
|
if args.loadable:
|
||||||
|
plugins = [p for p in plugins if p.is_loadable]
|
||||||
|
|
||||||
|
if not plugins:
|
||||||
|
print("No plugins found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for p in plugins:
|
||||||
|
if args.verbose:
|
||||||
|
print(_fmt_plugin_verbose(p))
|
||||||
|
print("-" * 72)
|
||||||
|
else:
|
||||||
|
print(_fmt_plugin_short(p))
|
||||||
|
print(f"\n{len(plugins)} plugin(s)")
|
||||||
|
|
||||||
|
# ── search ───────────────────────────────────────────────────────────
|
||||||
|
elif args.command == "search":
|
||||||
|
plugins = mgr.registry.search(args.query)
|
||||||
|
if not plugins:
|
||||||
|
print(f"No plugins matching '{args.query}'.")
|
||||||
|
return
|
||||||
|
for p in plugins:
|
||||||
|
if args.verbose:
|
||||||
|
print(_fmt_plugin_verbose(p))
|
||||||
|
print("-" * 72)
|
||||||
|
else:
|
||||||
|
print(_fmt_plugin_short(p))
|
||||||
|
print(f"\n{len(plugins)} match(es)")
|
||||||
|
|
||||||
|
# ── info ─────────────────────────────────────────────────────────────
|
||||||
|
elif args.command == "info":
|
||||||
|
plugin = _resolve(mgr, args.plugin)
|
||||||
|
if plugin is None:
|
||||||
|
print(f"Plugin not found: {args.plugin}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(_fmt_plugin_verbose(plugin))
|
||||||
|
|
||||||
|
# ── install-local ────────────────────────────────────────────────────
|
||||||
|
elif args.command == "install-local":
|
||||||
|
fmt = PluginFormat(args.format)
|
||||||
|
result = mgr.install_local(args.path, fmt)
|
||||||
|
if result:
|
||||||
|
print(f"Installed: {result.meta.name} ({result.meta.uri})")
|
||||||
|
else:
|
||||||
|
print("Installation failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── install-nam ──────────────────────────────────────────────────────
|
||||||
|
elif args.command == "install-nam":
|
||||||
|
result = mgr.nam_install_local(args.path, args.name)
|
||||||
|
if result:
|
||||||
|
print(f"Installed NAM model: {result.meta.name} ({result.nam_model_size})")
|
||||||
|
if result.rpi4b_known_broken:
|
||||||
|
print("⚠ Warning: This model may cause xruns on RPi4B (standard size).")
|
||||||
|
else:
|
||||||
|
print("Installation failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── download-nam ─────────────────────────────────────────────────────
|
||||||
|
elif args.command == "download-nam":
|
||||||
|
print(f"Downloading NAM model from {args.url}…")
|
||||||
|
result = mgr.nam_download(args.url, args.name)
|
||||||
|
if result:
|
||||||
|
print(f"Downloaded: {result.meta.name} ({result.nam_model_size})")
|
||||||
|
if result.rpi4b_known_broken:
|
||||||
|
print("⚠ Warning: This model may cause xruns on RPi4B (standard size).")
|
||||||
|
else:
|
||||||
|
print("Download failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── nam-list ─────────────────────────────────────────────────────────
|
||||||
|
elif args.command == "nam-list":
|
||||||
|
models = mgr.nam.list_models()
|
||||||
|
if not models:
|
||||||
|
print("No NAM models installed.")
|
||||||
|
return
|
||||||
|
for m in models:
|
||||||
|
compat = "✓ RPi4B" if m.rpi4b_compatible else "⚠ xruns likely"
|
||||||
|
print(f" [{m.size_category:8}] {m.name:<30} {m.file_size_mb:6.1f} MB {compat}")
|
||||||
|
counts = mgr.nam.count_models()
|
||||||
|
print(f"\n nano:{counts['nano']} feather:{counts['feather']} "
|
||||||
|
f"standard:{counts['standard']} custom:{counts['custom']}")
|
||||||
|
|
||||||
|
# ── remove ───────────────────────────────────────────────────────────
|
||||||
|
elif args.command == "remove":
|
||||||
|
plugin = _resolve(mgr, args.plugin)
|
||||||
|
if plugin is None:
|
||||||
|
print(f"Plugin not found: {args.plugin}")
|
||||||
|
sys.exit(1)
|
||||||
|
if mgr.remove(plugin.meta.uri, delete_files=args.files):
|
||||||
|
action = "Removed (files deleted)" if args.files else "Removed (registry only)"
|
||||||
|
print(f"{action}: {plugin.meta.name}")
|
||||||
|
else:
|
||||||
|
print("Remove failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── enable / disable ─────────────────────────────────────────────────
|
||||||
|
elif args.command == "enable":
|
||||||
|
plugin = _resolve(mgr, args.plugin)
|
||||||
|
if plugin is None:
|
||||||
|
print(f"Plugin not found: {args.plugin}")
|
||||||
|
sys.exit(1)
|
||||||
|
if mgr.enable_plugin(plugin.meta.uri):
|
||||||
|
print(f"Enabled: {plugin.meta.name}")
|
||||||
|
else:
|
||||||
|
print("Failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.command == "disable":
|
||||||
|
plugin = _resolve(mgr, args.plugin)
|
||||||
|
if plugin is None:
|
||||||
|
print(f"Plugin not found: {args.plugin}")
|
||||||
|
sys.exit(1)
|
||||||
|
if mgr.disable_plugin(plugin.meta.uri):
|
||||||
|
print(f"Disabled: {plugin.meta.name}")
|
||||||
|
else:
|
||||||
|
print("Failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── blacklist / unblacklist ──────────────────────────────────────────
|
||||||
|
elif args.command == "blacklist":
|
||||||
|
plugin = _resolve(mgr, args.plugin)
|
||||||
|
if plugin is None:
|
||||||
|
print(f"Plugin not found: {args.plugin}")
|
||||||
|
sys.exit(1)
|
||||||
|
if mgr.blacklist_plugin(plugin.meta.uri):
|
||||||
|
print(f"Blacklisted: {plugin.meta.name}")
|
||||||
|
else:
|
||||||
|
print("Failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.command == "unblacklist":
|
||||||
|
plugin = _resolve(mgr, args.plugin)
|
||||||
|
if plugin is None:
|
||||||
|
print(f"Plugin not found: {args.plugin}")
|
||||||
|
sys.exit(1)
|
||||||
|
if mgr.unblacklist_plugin(plugin.meta.uri):
|
||||||
|
print(f"Removed from blacklist: {plugin.meta.name}")
|
||||||
|
else:
|
||||||
|
print("Failed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── blacklist-list ───────────────────────────────────────────────────
|
||||||
|
elif args.command == "blacklist-list":
|
||||||
|
if args.builtin:
|
||||||
|
entries = mgr.blacklist.list_builtin()
|
||||||
|
elif args.user:
|
||||||
|
entries = mgr.blacklist.list_user()
|
||||||
|
else:
|
||||||
|
entries = mgr.blacklist.list_all()
|
||||||
|
|
||||||
|
for e in entries:
|
||||||
|
tag = "blk" if e.severity == "block" else "warn"
|
||||||
|
src = "builtin" if e.added_by == "builtin" else "user"
|
||||||
|
print(f" [{tag}] [{src}] {e.field}:{e.pattern}")
|
||||||
|
if e.reason:
|
||||||
|
print(f" {e.reason}")
|
||||||
|
print(f"\n{len(entries)} rule(s)")
|
||||||
|
|
||||||
|
# ── blacklist-add ────────────────────────────────────────────────────
|
||||||
|
elif args.command == "blacklist-add":
|
||||||
|
import time
|
||||||
|
entry = BlacklistEntry(
|
||||||
|
pattern=args.pattern,
|
||||||
|
field=args.field,
|
||||||
|
reason=args.reason,
|
||||||
|
severity=args.severity,
|
||||||
|
added_by="user",
|
||||||
|
added_at=time.time(),
|
||||||
|
)
|
||||||
|
mgr.blacklist.add_user_entry(entry)
|
||||||
|
print(f"Added blacklist rule: {args.field}:{args.pattern} [{args.severity}]")
|
||||||
|
|
||||||
|
# ── stats ────────────────────────────────────────────────────────────
|
||||||
|
elif args.command == "stats":
|
||||||
|
stats = mgr.registry.stats()
|
||||||
|
print(f"Plugin Registry Statistics")
|
||||||
|
print(f" Total plugins: {stats['total']}")
|
||||||
|
print(f" By format:")
|
||||||
|
for fmt, count in stats["by_format"].items():
|
||||||
|
print(f" {fmt:<8} {count}")
|
||||||
|
print(f" Blacklisted: {stats['blacklisted']}")
|
||||||
|
print(f" Stale: {stats['stale']}")
|
||||||
|
print(f" RPi4B verified: {stats['rpi4b_verified']}")
|
||||||
|
print(f" NAM models: {stats['nam_models']}")
|
||||||
|
|
||||||
|
# ── vacuum ───────────────────────────────────────────────────────────
|
||||||
|
elif args.command == "vacuum":
|
||||||
|
print("Compacting database…")
|
||||||
|
mgr.registry.vacuum()
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Backing Track Player — synchronized audio playback engine.
|
||||||
|
|
||||||
|
The backing package provides a complete backing track playback system:
|
||||||
|
- Audio file loading (WAV, FLAC, MP3, AIFF) into numpy arrays
|
||||||
|
- Playlist / setlist management with ordering and metadata
|
||||||
|
- Per-track volume, pan, mute, solo, and loop controls
|
||||||
|
- JACK transport synchronization (tempo, position, start/stop)
|
||||||
|
- Click track / metronome generator synced to transport tempo
|
||||||
|
- Count-in before playback start
|
||||||
|
|
||||||
|
Integrates with the mixer DSP engine via ParameterRegistry for
|
||||||
|
MIDI and OSC control of transport and per-track parameters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
PlayMode,
|
||||||
|
TrackState,
|
||||||
|
Track,
|
||||||
|
Playlist,
|
||||||
|
BackingPlayerConfig,
|
||||||
|
)
|
||||||
|
from .loader import AudioLoader, AudioData, load_audio
|
||||||
|
from .playlist import PlaylistManager
|
||||||
|
from .transport import JACKTransport, TransportState, jack_transport_state
|
||||||
|
from .metronome import Metronome, ClickSound
|
||||||
|
from .player import BackingTrackPlayer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Types
|
||||||
|
"PlayMode",
|
||||||
|
"TrackState",
|
||||||
|
"Track",
|
||||||
|
"Playlist",
|
||||||
|
"BackingPlayerConfig",
|
||||||
|
# Loader
|
||||||
|
"AudioLoader",
|
||||||
|
"AudioData",
|
||||||
|
"load_audio",
|
||||||
|
# Playlist
|
||||||
|
"PlaylistManager",
|
||||||
|
# Transport
|
||||||
|
"JACKTransport",
|
||||||
|
"TransportState",
|
||||||
|
"jack_transport_state",
|
||||||
|
# Metronome
|
||||||
|
"Metronome",
|
||||||
|
"ClickSound",
|
||||||
|
# Player
|
||||||
|
"BackingTrackPlayer",
|
||||||
|
]
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Audio file loader — WAV, FLAC, MP3, AIFF → numpy arrays.
|
||||||
|
|
||||||
|
Uses soundfile (libsndfile) for WAV/FLAC/AIFF, with ffmpeg fallback
|
||||||
|
for MP3 and other compressed formats. Loaded audio is stored as float32
|
||||||
|
numpy arrays normalized to [-1.0, 1.0].
|
||||||
|
|
||||||
|
Format support matrix:
|
||||||
|
WAV — soundfile (native)
|
||||||
|
FLAC — soundfile (native via libsndfile)
|
||||||
|
AIFF — soundfile (native)
|
||||||
|
MP3 — ffmpeg → raw PCM → numpy (soundfile doesn't handle MP3)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
import soundfile as sf
|
||||||
|
HAS_SOUNDFILE = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_SOUNDFILE = False
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Supported formats ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".wav", ".flac", ".aiff", ".aif", ".mp3",
|
||||||
|
".wave", ".oga", ".ogg", ".m4a"}
|
||||||
|
|
||||||
|
# Formats handled natively by soundfile
|
||||||
|
SNDFILE_FORMATS = {".wav", ".wave", ".flac", ".aiff", ".aif", ".oga", ".ogg"}
|
||||||
|
|
||||||
|
# Formats needing ffmpeg decode (or other fallback)
|
||||||
|
FFMPEG_FORMATS = {".mp3", ".m4a"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audio data container ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioData:
|
||||||
|
"""Loaded audio data ready for playback.
|
||||||
|
|
||||||
|
Audio is stored as float32 numpy array, shape (frames, channels),
|
||||||
|
normalized to [-1.0, 1.0]. For mono files, shape is (frames,).
|
||||||
|
"""
|
||||||
|
samples: np.ndarray # float32, shape (frames,) or (frames, channels)
|
||||||
|
sample_rate: int = 48000
|
||||||
|
channels: int = 1
|
||||||
|
duration: float = 0.0 # seconds
|
||||||
|
file_path: str = ""
|
||||||
|
format_name: str = "" # "WAV", "FLAC", etc.
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_frames(self) -> int:
|
||||||
|
return len(self.samples)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_stereo(self) -> bool:
|
||||||
|
return self.channels == 2 and self.samples.ndim == 2
|
||||||
|
|
||||||
|
def to_mono(self) -> np.ndarray:
|
||||||
|
"""Return mono mixdown (mean of channels)."""
|
||||||
|
if self.samples.ndim == 1:
|
||||||
|
return self.samples
|
||||||
|
return self.samples.mean(axis=1).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audio loader ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AudioLoader:
|
||||||
|
"""Load audio files into AudioData containers.
|
||||||
|
|
||||||
|
Handles format detection and dispatch to the appropriate backend.
|
||||||
|
Thread-safe — each call to load() is independent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load(file_path: str | Path, target_sr: int = 0) -> AudioData:
|
||||||
|
"""Load an audio file into memory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the audio file.
|
||||||
|
target_sr: If >0, resample to this sample rate. 0 = keep original.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioData with samples, metadata.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: if file doesn't exist.
|
||||||
|
ValueError: if format is unsupported or file is corrupt.
|
||||||
|
"""
|
||||||
|
fp = Path(file_path)
|
||||||
|
if not fp.exists():
|
||||||
|
raise FileNotFoundError(f"Audio file not found: {file_path}")
|
||||||
|
|
||||||
|
suffix = fp.suffix.lower()
|
||||||
|
|
||||||
|
if suffix in SNDFILE_FORMATS and HAS_SOUNDFILE:
|
||||||
|
return AudioLoader._load_soundfile(fp, target_sr)
|
||||||
|
elif suffix in FFMPEG_FORMATS:
|
||||||
|
return AudioLoader._load_ffmpeg(fp, target_sr)
|
||||||
|
elif suffix in SNDFILE_FORMATS and not HAS_SOUNDFILE:
|
||||||
|
return AudioLoader._load_ffmpeg(fp, target_sr)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported audio format: {suffix}. "
|
||||||
|
f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def probe(file_path: str | Path) -> dict:
|
||||||
|
"""Quick probe of an audio file's metadata without loading samples.
|
||||||
|
|
||||||
|
Returns dict with keys: sample_rate, channels, duration, format.
|
||||||
|
"""
|
||||||
|
fp = Path(file_path)
|
||||||
|
if not fp.exists():
|
||||||
|
raise FileNotFoundError(f"Audio file not found: {file_path}")
|
||||||
|
|
||||||
|
suffix = fp.suffix.lower()
|
||||||
|
if suffix in SNDFILE_FORMATS and HAS_SOUNDFILE:
|
||||||
|
info = sf.info(str(fp))
|
||||||
|
return {
|
||||||
|
"sample_rate": info.samplerate,
|
||||||
|
"channels": info.channels,
|
||||||
|
"duration": info.duration,
|
||||||
|
"format": info.format,
|
||||||
|
"frames": info.frames,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Use ffprobe for format-agnostic probing
|
||||||
|
return AudioLoader._ffprobe(fp)
|
||||||
|
|
||||||
|
# ── Backends ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_soundfile(fp: Path, target_sr: int) -> AudioData:
|
||||||
|
"""Load via soundfile (libsndfile)."""
|
||||||
|
samples, sr = sf.read(str(fp), dtype="float32", always_2d=False)
|
||||||
|
info = sf.info(str(fp))
|
||||||
|
|
||||||
|
if target_sr > 0 and target_sr != sr:
|
||||||
|
# Simple resampling via linear interpolation for now
|
||||||
|
# For production, scipy.signal.resample or similar would be used
|
||||||
|
ratio = target_sr / sr
|
||||||
|
n_out = int(len(samples) * ratio)
|
||||||
|
if samples.ndim == 1:
|
||||||
|
samples = np.interp(
|
||||||
|
np.linspace(0, len(samples) - 1, n_out),
|
||||||
|
np.arange(len(samples)), samples
|
||||||
|
).astype(np.float32)
|
||||||
|
else:
|
||||||
|
resampled = np.zeros((n_out, samples.shape[1]), dtype=np.float32)
|
||||||
|
for ch in range(samples.shape[1]):
|
||||||
|
resampled[:, ch] = np.interp(
|
||||||
|
np.linspace(0, len(samples) - 1, n_out),
|
||||||
|
np.arange(len(samples)), samples[:, ch]
|
||||||
|
)
|
||||||
|
samples = resampled
|
||||||
|
sr = target_sr
|
||||||
|
|
||||||
|
channels = 1 if samples.ndim == 1 else samples.shape[1]
|
||||||
|
duration = len(samples) / sr
|
||||||
|
|
||||||
|
logger.info("Loaded %s: %d Hz, %d ch, %.1f s", fp.name, sr, channels, duration)
|
||||||
|
|
||||||
|
return AudioData(
|
||||||
|
samples=samples,
|
||||||
|
sample_rate=sr,
|
||||||
|
channels=channels,
|
||||||
|
duration=duration,
|
||||||
|
file_path=str(fp),
|
||||||
|
format_name=info.format,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_ffmpeg(fp: Path, target_sr: int) -> AudioData:
|
||||||
|
"""Load via ffmpeg → raw PCM → numpy.
|
||||||
|
|
||||||
|
Decodes to raw s16le PCM via ffmpeg, then reads into numpy.
|
||||||
|
Handles MP3, M4A, and fallback for any format ffmpeg supports.
|
||||||
|
"""
|
||||||
|
ar = str(target_sr) if target_sr > 0 else "44100"
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-v", "error",
|
||||||
|
"-i", str(fp),
|
||||||
|
"-f", "s16le",
|
||||||
|
"-acodec", "pcm_s16le",
|
||||||
|
"-ar", ar,
|
||||||
|
"-ac", "2", # always decode to stereo
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, timeout=120,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.decode(errors="replace")
|
||||||
|
raise ValueError(
|
||||||
|
f"ffmpeg decode failed for {fp.name}: {stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = result.stdout
|
||||||
|
# Convert s16le raw bytes to float32 numpy array
|
||||||
|
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32)
|
||||||
|
samples = samples.reshape(-1, 2) # stereo
|
||||||
|
samples /= 32768.0 # normalize to [-1, 1]
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ffmpeg not found. Install ffmpeg for MP3/AIFF/FLAC support."
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise TimeoutError(f"ffmpeg timed out loading {fp.name}")
|
||||||
|
|
||||||
|
sr = int(ar)
|
||||||
|
duration = len(samples) / sr
|
||||||
|
channels = 2
|
||||||
|
|
||||||
|
logger.info("Loaded %s (via ffmpeg): %d Hz, %d ch, %.1f s",
|
||||||
|
fp.name, sr, channels, duration)
|
||||||
|
|
||||||
|
return AudioData(
|
||||||
|
samples=samples,
|
||||||
|
sample_rate=sr,
|
||||||
|
channels=channels,
|
||||||
|
duration=duration,
|
||||||
|
file_path=str(fp),
|
||||||
|
format_name=fp.suffix.upper().lstrip("."),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ffprobe(fp: Path) -> dict:
|
||||||
|
"""Get audio metadata via ffprobe."""
|
||||||
|
cmd = [
|
||||||
|
"ffprobe", "-v", "error",
|
||||||
|
"-show_entries", "stream=sample_rate,channels,duration,codec_name",
|
||||||
|
"-of", "default=noprint_wrappers=1",
|
||||||
|
str(fp),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
info = {}
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
info[k] = v
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sample_rate": int(info.get("sample_rate", 0)),
|
||||||
|
"channels": int(info.get("channels", 0)),
|
||||||
|
"duration": float(info.get("duration", 0.0)),
|
||||||
|
"format": info.get("codec_name", "unknown"),
|
||||||
|
"frames": 0,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.warning("ffprobe failed for %s", fp.name)
|
||||||
|
return {
|
||||||
|
"sample_rate": 0,
|
||||||
|
"channels": 0,
|
||||||
|
"duration": 0.0,
|
||||||
|
"format": "unknown",
|
||||||
|
"frames": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Convenience function ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_audio(file_path: str | Path, target_sr: int = 0) -> AudioData:
|
||||||
|
"""Load an audio file into memory. Convenience wrapper around AudioLoader."""
|
||||||
|
return AudioLoader.load(file_path, target_sr)
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Click track / metronome generator for the backing track player.
|
||||||
|
|
||||||
|
Generates metronome click samples synchronized to the JACK transport
|
||||||
|
tempo. Supports configurable click sounds (strong/weak beats), count-in,
|
||||||
|
and time signature awareness. Output is a numpy float32 array suitable
|
||||||
|
for mixing into the backing track output buffer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Click sound generation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClickSound:
|
||||||
|
"""Parameters for a single click sound."""
|
||||||
|
frequency: float = 1000.0 # Hz
|
||||||
|
duration_ms: float = 15.0 # milliseconds
|
||||||
|
volume: float = 0.5 # 0.0 – 1.0
|
||||||
|
waveform: str = "sine" # "sine", "square", "noise", "click"
|
||||||
|
|
||||||
|
def generate(self, sample_rate: int) -> np.ndarray:
|
||||||
|
"""Generate a single click as a numpy array.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float32 numpy array of shape (n_samples,) with values in [-1, 1].
|
||||||
|
"""
|
||||||
|
n_samples = int(sample_rate * self.duration_ms / 1000.0)
|
||||||
|
n_samples = max(1, n_samples)
|
||||||
|
|
||||||
|
if self.waveform == "sine":
|
||||||
|
t = np.linspace(0, self.duration_ms / 1000.0, n_samples, endpoint=False,
|
||||||
|
dtype=np.float32)
|
||||||
|
samples = np.sin(2 * math.pi * self.frequency * t)
|
||||||
|
# Apply exponential decay envelope
|
||||||
|
env = np.exp(-t * (20.0 / (self.duration_ms / 1000.0 + 0.001)))
|
||||||
|
samples = samples * env.astype(np.float32)
|
||||||
|
|
||||||
|
elif self.waveform == "square":
|
||||||
|
t = np.linspace(0, self.duration_ms / 1000.0, n_samples, endpoint=False,
|
||||||
|
dtype=np.float32)
|
||||||
|
samples = np.sign(np.sin(2 * math.pi * self.frequency * t))
|
||||||
|
env = np.exp(-t * (15.0 / (self.duration_ms / 1000.0 + 0.001)))
|
||||||
|
samples = samples * env.astype(np.float32)
|
||||||
|
|
||||||
|
elif self.waveform == "noise":
|
||||||
|
rng = np.random.default_rng()
|
||||||
|
samples = rng.uniform(-1.0, 1.0, n_samples).astype(np.float32)
|
||||||
|
env = np.exp(-np.linspace(0, 1, n_samples, dtype=np.float32) * 10.0)
|
||||||
|
samples = samples * env
|
||||||
|
|
||||||
|
elif self.waveform == "click":
|
||||||
|
# Very short impulse — good for electronic music
|
||||||
|
samples = np.zeros(n_samples, dtype=np.float32)
|
||||||
|
samples[0] = 1.0
|
||||||
|
# Apply fast decay
|
||||||
|
decay = np.exp(-np.arange(n_samples, dtype=np.float32) * 0.5)
|
||||||
|
samples = samples * decay
|
||||||
|
|
||||||
|
else:
|
||||||
|
samples = np.zeros(n_samples, dtype=np.float32)
|
||||||
|
|
||||||
|
return (samples * self.volume).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Metronome ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Metronome:
|
||||||
|
"""Synced metronome / click track generator.
|
||||||
|
|
||||||
|
Generates click samples for each beat, synchronized to tempo
|
||||||
|
and transport position. Supports:
|
||||||
|
- Configurable strong/weak beat sounds
|
||||||
|
- Count-in bars before transport start
|
||||||
|
- Arbitrary time signatures
|
||||||
|
- Pre-roll for seamless buffer scheduling
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sample_rate: int = 48000,
|
||||||
|
click_volume: float = 0.5,
|
||||||
|
click_freq_strong: float = 1000.0,
|
||||||
|
click_freq_weak: float = 800.0,
|
||||||
|
click_duration_ms: float = 15.0,
|
||||||
|
):
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
|
||||||
|
# Strong beat (downbeat, beat 1)
|
||||||
|
self.strong_click = ClickSound(
|
||||||
|
frequency=click_freq_strong,
|
||||||
|
duration_ms=click_duration_ms,
|
||||||
|
volume=click_volume,
|
||||||
|
waveform="sine",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Weak beat (beats 2, 3, 4, ...)
|
||||||
|
self.weak_click = ClickSound(
|
||||||
|
frequency=click_freq_weak,
|
||||||
|
duration_ms=click_duration_ms * 0.7,
|
||||||
|
volume=click_volume * 0.8,
|
||||||
|
waveform="sine",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count-in settings
|
||||||
|
self.count_in_bars: int = 2
|
||||||
|
self.time_sig_num: int = 4 # beats per bar
|
||||||
|
self.time_sig_den: int = 4 # beat unit
|
||||||
|
|
||||||
|
# State
|
||||||
|
self._enabled: bool = True
|
||||||
|
self._count_in_active: bool = False
|
||||||
|
self._count_in_beat: int = 0
|
||||||
|
self._count_in_bar: int = 0
|
||||||
|
self._last_beat: int = -1
|
||||||
|
self._last_bar: int = -1
|
||||||
|
self._pre_generated_strong: np.ndarray | None = None
|
||||||
|
self._pre_generated_weak: np.ndarray | None = None
|
||||||
|
self._pre_gen_sr: int = 0
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
@enabled.setter
|
||||||
|
def enabled(self, value: bool) -> None:
|
||||||
|
self._enabled = value
|
||||||
|
|
||||||
|
def set_volume(self, volume: float) -> None:
|
||||||
|
"""Set click volume (0.0–1.0)."""
|
||||||
|
v = max(0.0, min(1.0, volume))
|
||||||
|
self.strong_click.volume = v
|
||||||
|
self.weak_click.volume = v * 0.8
|
||||||
|
|
||||||
|
def start_count_in(self, bars: int | None = None) -> None:
|
||||||
|
"""Start a count-in before playback.
|
||||||
|
|
||||||
|
The count-in beats can be generated via generate_clicks_for_position
|
||||||
|
until the count-in completes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bars: Number of bars to count in. Uses default if None.
|
||||||
|
"""
|
||||||
|
beats_per_bar = self.time_sig_num
|
||||||
|
total_bars = bars if bars is not None else self.count_in_bars
|
||||||
|
self._count_in_active = True
|
||||||
|
self._count_in_bar = 0
|
||||||
|
self._count_in_beat = 0
|
||||||
|
self._count_in_total_beats = total_bars * beats_per_bar
|
||||||
|
logger.info("Count-in started: %d bars (%d beats)",
|
||||||
|
total_bars, self._count_in_total_beats)
|
||||||
|
|
||||||
|
def stop_count_in(self) -> None:
|
||||||
|
"""Cancel count-in."""
|
||||||
|
self._count_in_active = False
|
||||||
|
|
||||||
|
def is_count_in_active(self) -> bool:
|
||||||
|
"""True if count-in is currently active."""
|
||||||
|
return self._count_in_active
|
||||||
|
|
||||||
|
def generate_click(
|
||||||
|
self,
|
||||||
|
beat: int, # 1-based beat number within bar
|
||||||
|
bar: int = 1, # 1-based bar number
|
||||||
|
is_count_in: bool = False,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Generate a click sample for a specific beat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
beat: Beat number (1 = first beat of bar).
|
||||||
|
bar: Bar number (1-based).
|
||||||
|
is_count_in: True if this is a count-in click.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float32 numpy array with click samples, or empty array.
|
||||||
|
"""
|
||||||
|
if not self._enabled and not is_count_in:
|
||||||
|
return np.array([], dtype=np.float32)
|
||||||
|
|
||||||
|
self._ensure_pre_generated()
|
||||||
|
|
||||||
|
# Beat 1 of each bar is the strong (downbeat) click
|
||||||
|
if beat == 1:
|
||||||
|
return self._pre_generated_strong.copy() # type: ignore[union-attr]
|
||||||
|
else:
|
||||||
|
return self._pre_generated_weak.copy() # type: ignore[union-attr]
|
||||||
|
|
||||||
|
def generate_clicks_for_position(
|
||||||
|
self,
|
||||||
|
position_seconds: float,
|
||||||
|
tempo: float,
|
||||||
|
bar: int,
|
||||||
|
beat: int,
|
||||||
|
buffer_size: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Generate click samples for a playback buffer.
|
||||||
|
|
||||||
|
Given the current transport position, tempo, and bar/beat,
|
||||||
|
determine which click(s) fall within the next buffer and
|
||||||
|
generate them at the correct offsets within the buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position_seconds: Current transport position in seconds.
|
||||||
|
tempo: Current tempo in BPM.
|
||||||
|
bar: Current bar (1-based).
|
||||||
|
beat: Current beat (1-based, 1.0 = start of beat).
|
||||||
|
buffer_size: Frames in the output buffer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float32 array of shape (buffer_size,) with clicks mixed in,
|
||||||
|
or shape (buffer_size, 2) for stereo (clicks centered).
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return np.zeros(buffer_size, dtype=np.float32)
|
||||||
|
|
||||||
|
beats_per_bar = self.time_sig_num
|
||||||
|
seconds_per_beat = 60.0 / tempo
|
||||||
|
samples_per_beat = int(seconds_per_beat * self.sample_rate)
|
||||||
|
output = np.zeros(buffer_size, dtype=np.float32)
|
||||||
|
|
||||||
|
if samples_per_beat <= 0:
|
||||||
|
return output
|
||||||
|
|
||||||
|
# Check if a new beat starts within this buffer
|
||||||
|
# beat_progress: 0.0–1.0 within the current beat
|
||||||
|
beat_progress = beat - int(beat)
|
||||||
|
samples_until_next_beat = int(
|
||||||
|
(1.0 - beat_progress) * samples_per_beat
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count-in logic
|
||||||
|
if self._count_in_active:
|
||||||
|
if self._count_in_beat >= self._count_in_total_beats:
|
||||||
|
# Count-in complete
|
||||||
|
self._count_in_active = False
|
||||||
|
logger.info("Count-in complete")
|
||||||
|
else:
|
||||||
|
# Generate count-in clicks for beats falling in this buffer
|
||||||
|
current_count_beat = self._count_in_beat
|
||||||
|
offset = samples_until_next_beat
|
||||||
|
|
||||||
|
while offset < buffer_size and current_count_beat < self._count_in_total_beats:
|
||||||
|
cb = (current_count_beat % beats_per_bar) + 1
|
||||||
|
click = self.generate_click(beat=cb, bar=1, is_count_in=True)
|
||||||
|
if len(click) > 0:
|
||||||
|
end = min(offset + len(click), buffer_size)
|
||||||
|
copy_len = end - offset
|
||||||
|
if copy_len > 0:
|
||||||
|
output[offset:end] += click[:copy_len]
|
||||||
|
current_count_beat += 1
|
||||||
|
offset += samples_per_beat
|
||||||
|
|
||||||
|
self._count_in_beat = current_count_beat
|
||||||
|
if current_count_beat >= self._count_in_total_beats:
|
||||||
|
self._count_in_active = False
|
||||||
|
|
||||||
|
self._last_beat = beat
|
||||||
|
self._last_bar = bar
|
||||||
|
return output
|
||||||
|
|
||||||
|
# Normal metronome operation
|
||||||
|
# Generate click for the upcoming beat if it starts within this buffer
|
||||||
|
if samples_until_next_beat < buffer_size:
|
||||||
|
next_beat = int(beat) + 1
|
||||||
|
next_bar = bar
|
||||||
|
if next_beat > beats_per_bar:
|
||||||
|
next_beat = 1
|
||||||
|
next_bar = bar + 1
|
||||||
|
|
||||||
|
cb = next_beat if next_beat <= beats_per_bar else 1
|
||||||
|
click = self.generate_click(beat=cb, bar=next_bar)
|
||||||
|
if len(click) > 0:
|
||||||
|
start_offset = max(0, samples_until_next_beat)
|
||||||
|
end = min(start_offset + len(click), buffer_size)
|
||||||
|
copy_len = end - start_offset
|
||||||
|
if copy_len > 0:
|
||||||
|
output[start_offset:end] += click[:copy_len]
|
||||||
|
|
||||||
|
# Check if additional beats fall within this buffer
|
||||||
|
offset = samples_until_next_beat + samples_per_beat
|
||||||
|
next_beat_accum = int(beat) + 1
|
||||||
|
next_bar_accum = bar
|
||||||
|
if next_beat_accum > beats_per_bar:
|
||||||
|
next_beat_accum = 1
|
||||||
|
next_bar_accum += 1
|
||||||
|
|
||||||
|
while offset < buffer_size:
|
||||||
|
cb = next_beat_accum if next_beat_accum <= beats_per_bar else 1
|
||||||
|
click = self.generate_click(beat=cb, bar=next_bar_accum)
|
||||||
|
if len(click) > 0:
|
||||||
|
end = min(offset + len(click), buffer_size)
|
||||||
|
copy_len = end - offset
|
||||||
|
if copy_len > 0:
|
||||||
|
output[offset:end] += click[:copy_len]
|
||||||
|
|
||||||
|
next_beat_accum += 1
|
||||||
|
if next_beat_accum > beats_per_bar:
|
||||||
|
next_beat_accum = 1
|
||||||
|
next_bar_accum += 1
|
||||||
|
offset += samples_per_beat
|
||||||
|
|
||||||
|
self._last_beat = beat
|
||||||
|
self._last_bar = bar
|
||||||
|
|
||||||
|
return output.astype(np.float32)
|
||||||
|
|
||||||
|
def generate_stereo_clicks(
|
||||||
|
self,
|
||||||
|
position_seconds: float,
|
||||||
|
tempo: float,
|
||||||
|
bar: int,
|
||||||
|
beat: int,
|
||||||
|
buffer_size: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Generate stereo click samples (centered).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float32 array of shape (buffer_size, 2).
|
||||||
|
"""
|
||||||
|
mono = self.generate_clicks_for_position(
|
||||||
|
position_seconds, tempo, bar, beat, buffer_size,
|
||||||
|
)
|
||||||
|
stereo = np.zeros((buffer_size, 2), dtype=np.float32)
|
||||||
|
if len(mono) > 0:
|
||||||
|
stereo[:, 0] = mono
|
||||||
|
stereo[:, 1] = mono
|
||||||
|
return stereo
|
||||||
|
|
||||||
|
def _ensure_pre_generated(self) -> None:
|
||||||
|
"""Pre-generate click samples if the sample rate changed."""
|
||||||
|
if self._pre_gen_sr != self.sample_rate or self._pre_generated_strong is None:
|
||||||
|
self._pre_generated_strong = self.strong_click.generate(self.sample_rate)
|
||||||
|
self._pre_generated_weak = self.weak_click.generate(self.sample_rate)
|
||||||
|
self._pre_gen_sr = self.sample_rate
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
"""Backing track player — main orchestrator.
|
||||||
|
|
||||||
|
The BackingTrackPlayer ties together the audio loader, playlist manager,
|
||||||
|
JACK transport, and metronome into a cohesive backing track playback engine.
|
||||||
|
It provides the primary API for:
|
||||||
|
|
||||||
|
- Loading tracks into memory
|
||||||
|
- Starting/stopping/pausing playback
|
||||||
|
- Per-track volume, pan, mute, solo control
|
||||||
|
- Loop, one-shot, and segue modes
|
||||||
|
- JACK transport synchronization
|
||||||
|
- Click track / metronome output
|
||||||
|
- Count-in before playback start
|
||||||
|
|
||||||
|
Integrates with the mixer's DSPEngine via the ParameterRegistry
|
||||||
|
callback for MIDI/OSC control of transport and track parameters.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
MIDI/OSC → ParameterRegistry → BackingTrackPlayer.handle_parameter()
|
||||||
|
├── Transport control (play, stop, tempo)
|
||||||
|
├── Track control (volume, pan, mute, solo)
|
||||||
|
└── Playlist navigation (next, prev, select)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..midi.types import (
|
||||||
|
MixerParameter,
|
||||||
|
ParameterType,
|
||||||
|
ParameterCategory,
|
||||||
|
)
|
||||||
|
from .types import (
|
||||||
|
PlayMode,
|
||||||
|
TrackState,
|
||||||
|
Track,
|
||||||
|
Playlist,
|
||||||
|
BackingPlayerConfig,
|
||||||
|
)
|
||||||
|
from .loader import AudioLoader, AudioData, load_audio
|
||||||
|
from .playlist import PlaylistManager
|
||||||
|
from .transport import JACKTransport, TransportState, JACKTransportState
|
||||||
|
from .metronome import Metronome
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackingTrackPlayer:
|
||||||
|
"""Synchronized backing track playback engine.
|
||||||
|
|
||||||
|
Manages loading, playback, and control of backing tracks. Integrates
|
||||||
|
with JACK transport for synchronization and provides per-track mixer
|
||||||
|
controls (volume, pan, mute, solo, loop modes).
|
||||||
|
|
||||||
|
Thread-safe for concurrent access from MIDI callbacks and UI.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
config = BackingPlayerConfig()
|
||||||
|
player = BackingTrackPlayer(config)
|
||||||
|
player.load_track("backing_track_1", "/path/to/song.wav")
|
||||||
|
player.play()
|
||||||
|
player.set_volume("backing_track_1", 0.8)
|
||||||
|
player.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Init ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def __init__(self, config: BackingPlayerConfig | None = None):
|
||||||
|
self.config = config or BackingPlayerConfig()
|
||||||
|
|
||||||
|
# Components
|
||||||
|
self.loader = AudioLoader()
|
||||||
|
self.playlists = PlaylistManager()
|
||||||
|
self.transport = JACKTransport(
|
||||||
|
sample_rate=self.config.sample_rate,
|
||||||
|
auto_poll=True,
|
||||||
|
)
|
||||||
|
self.metronome = Metronome(
|
||||||
|
sample_rate=self.config.sample_rate,
|
||||||
|
click_volume=self.config.click_volume,
|
||||||
|
click_freq_strong=self.config.click_freq_strong,
|
||||||
|
click_freq_weak=self.config.click_freq_weak,
|
||||||
|
click_duration_ms=self.config.click_duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure metronome
|
||||||
|
self.metronome.time_sig_num = self.config.time_signature_num
|
||||||
|
self.metronome.time_sig_den = self.config.time_signature_den
|
||||||
|
self.metronome.count_in_bars = self.config.count_in_bars
|
||||||
|
|
||||||
|
# Loaded audio data cache: track_id → AudioData
|
||||||
|
self._audio_cache: dict[str, AudioData] = {}
|
||||||
|
|
||||||
|
# Transport integration
|
||||||
|
self._sync_to_transport: bool = self.config.jack_transport_enabled
|
||||||
|
self._transport_state: TransportState = TransportState.STOPPED
|
||||||
|
|
||||||
|
# Playback state
|
||||||
|
self._playing: bool = False
|
||||||
|
self._paused: bool = False
|
||||||
|
self._active_track_id: str | None = None
|
||||||
|
self._playback_position: float = 0.0 # seconds into active track
|
||||||
|
self._count_in_remaining: float = 0.0 # seconds of count-in left
|
||||||
|
|
||||||
|
# Thread safety
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# DSP integration
|
||||||
|
self._dsp_engine = self.config.dsp_engine
|
||||||
|
self._param_callbacks: list = []
|
||||||
|
|
||||||
|
# Bind transport callbacks
|
||||||
|
if self._sync_to_transport:
|
||||||
|
self.transport.on_transport_change(self._on_transport_change)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"BackingTrackPlayer initialized (sr=%d, buffer=%d, JACK=%s)",
|
||||||
|
self.config.sample_rate, self.config.buffer_size,
|
||||||
|
"enabled" if self.transport.available else "unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# -- Track loading --
|
||||||
|
|
||||||
|
def load_track(self, track_id: str, file_path: str | None = None) -> bool:
|
||||||
|
"""Load a track's audio data into memory.
|
||||||
|
|
||||||
|
If file_path is provided, updates the track's file_path.
|
||||||
|
The track must already exist in the active playlist.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if loaded successfully.
|
||||||
|
"""
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl is None:
|
||||||
|
logger.warning("No active playlist")
|
||||||
|
return False
|
||||||
|
|
||||||
|
track = self.playlists.get_track(track_id)
|
||||||
|
if track is None:
|
||||||
|
logger.warning("Track not found: %s", track_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
track.file_path = file_path
|
||||||
|
|
||||||
|
if not track.file_path:
|
||||||
|
logger.warning("No file path for track: %s", track_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio = self.loader.load(track.file_path, self.config.sample_rate)
|
||||||
|
self._audio_cache[track_id] = audio
|
||||||
|
|
||||||
|
# Update track metadata
|
||||||
|
track.duration = audio.duration
|
||||||
|
track.sample_rate = audio.sample_rate
|
||||||
|
track.channels = audio.channels
|
||||||
|
track.loaded = True
|
||||||
|
track.state = TrackState.IDLE
|
||||||
|
|
||||||
|
logger.info("Loaded track %s: %.1fs, %d ch, %d Hz",
|
||||||
|
track_id, audio.duration, audio.channels, audio.sample_rate)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load track %s: %s", track_id, e)
|
||||||
|
track.state = TrackState.ERROR
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_playlist_tracks(self, playlist_name: str | None = None) -> int:
|
||||||
|
"""Load all tracks in a playlist into memory. Returns count loaded."""
|
||||||
|
pl = self.playlists.active if playlist_name is None else self.playlists.get(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for track in pl.tracks:
|
||||||
|
if not track.loaded and track.file_path:
|
||||||
|
if self.load_track(track.id):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def unload_track(self, track_id: str) -> bool:
|
||||||
|
"""Free a track's audio data from memory."""
|
||||||
|
if track_id in self._audio_cache:
|
||||||
|
del self._audio_cache[track_id]
|
||||||
|
track = self.playlists.get_track(track_id)
|
||||||
|
if track:
|
||||||
|
track.loaded = False
|
||||||
|
track.state = TrackState.IDLE
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_loaded(self, track_id: str) -> bool:
|
||||||
|
"""Check if a track is loaded into memory."""
|
||||||
|
return track_id in self._audio_cache
|
||||||
|
|
||||||
|
# -- Playback control --
|
||||||
|
|
||||||
|
def play(self, track_id: str | None = None, count_in: bool = True) -> bool:
|
||||||
|
"""Start or resume playback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track_id: Specific track to play (default: active playlist track).
|
||||||
|
count_in: If True, pre-roll with count-in clicks before playback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if playback started.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl is None:
|
||||||
|
logger.warning("No active playlist")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Resolve track
|
||||||
|
if track_id:
|
||||||
|
for i, t in enumerate(pl.tracks):
|
||||||
|
if t.id == track_id:
|
||||||
|
pl.current_index = i
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning("Track not found: %s", track_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
track = pl.current_track
|
||||||
|
if track is None:
|
||||||
|
logger.warning("No track selected")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Load if needed
|
||||||
|
if not track.loaded and track.file_path:
|
||||||
|
if not self.load_track(track.id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if track_id not in self._audio_cache:
|
||||||
|
logger.warning("Track audio not loaded: %s", track.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._active_track_id = track.id
|
||||||
|
track.state = TrackState.PLAYING
|
||||||
|
|
||||||
|
# Reset position to cue point
|
||||||
|
self._playback_position = track.cue_point
|
||||||
|
|
||||||
|
# Count-in
|
||||||
|
if count_in and self.config.count_in_bars > 0:
|
||||||
|
self.metronome.start_count_in(self.config.count_in_bars)
|
||||||
|
beats = self.config.count_in_bars * self.config.time_signature_num
|
||||||
|
tempo = self.transport.state.tempo
|
||||||
|
self._count_in_remaining = beats * (60.0 / tempo)
|
||||||
|
self._playing = False # Will become True after count-in
|
||||||
|
else:
|
||||||
|
self._count_in_remaining = 0.0
|
||||||
|
self._playing = True
|
||||||
|
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
# Sync JACK transport
|
||||||
|
if self._sync_to_transport and self.transport.available:
|
||||||
|
self.transport.start()
|
||||||
|
|
||||||
|
logger.info("Playing track %s (count-in=%s)", track.id,
|
||||||
|
"yes" if self._count_in_remaining > 0 else "no")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
"""Stop playback."""
|
||||||
|
with self._lock:
|
||||||
|
self._playing = False
|
||||||
|
self._paused = False
|
||||||
|
self._count_in_remaining = 0.0
|
||||||
|
self.metronome.stop_count_in()
|
||||||
|
|
||||||
|
if self._active_track_id:
|
||||||
|
track = self.playlists.get_track(self._active_track_id)
|
||||||
|
if track:
|
||||||
|
track.state = TrackState.STOPPED
|
||||||
|
track.position = self._playback_position
|
||||||
|
|
||||||
|
if self._sync_to_transport and self.transport.available:
|
||||||
|
self.transport.stop()
|
||||||
|
|
||||||
|
logger.info("Playback stopped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def pause(self) -> bool:
|
||||||
|
"""Pause playback. Resume with play()."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._playing and self._count_in_remaining <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._playing = False
|
||||||
|
self._paused = True
|
||||||
|
|
||||||
|
if self._active_track_id:
|
||||||
|
track = self.playlists.get_track(self._active_track_id)
|
||||||
|
if track:
|
||||||
|
track.state = TrackState.PAUSED
|
||||||
|
track.position = self._playback_position
|
||||||
|
|
||||||
|
logger.info("Playback paused at %.2fs", self._playback_position)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def resume(self) -> bool:
|
||||||
|
"""Resume paused playback."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._paused:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._playing = True
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
if self._active_track_id:
|
||||||
|
track = self.playlists.get_track(self._active_track_id)
|
||||||
|
if track:
|
||||||
|
track.state = TrackState.PLAYING
|
||||||
|
|
||||||
|
logger.info("Playback resumed from %.2fs", self._playback_position)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_play(self) -> bool:
|
||||||
|
"""Toggle between play and stop/pause."""
|
||||||
|
if self._playing:
|
||||||
|
return self.pause() or self.stop()
|
||||||
|
if self._paused:
|
||||||
|
return self.resume()
|
||||||
|
return self.play()
|
||||||
|
|
||||||
|
def seek(self, seconds: float) -> bool:
|
||||||
|
"""Seek to a position in the current track."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._active_track_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
track = self.playlists.get_track(self._active_track_id)
|
||||||
|
if track is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
audio = self._audio_cache.get(self._active_track_id)
|
||||||
|
if audio is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
seconds = max(0.0, min(seconds, audio.duration))
|
||||||
|
self._playback_position = seconds
|
||||||
|
track.position = seconds
|
||||||
|
|
||||||
|
if self._sync_to_transport and self.transport.available:
|
||||||
|
self.transport.locate_seconds(seconds)
|
||||||
|
|
||||||
|
logger.debug("Seeked to %.2fs", seconds)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def next_track(self) -> bool:
|
||||||
|
"""Play the next track in the playlist."""
|
||||||
|
with self._lock:
|
||||||
|
was_playing = self._playing or self._paused
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
track = pl.next_track()
|
||||||
|
if track is None:
|
||||||
|
logger.info("End of playlist reached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if was_playing:
|
||||||
|
self.play(track.id)
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def prev_track(self) -> bool:
|
||||||
|
"""Play the previous track in the playlist."""
|
||||||
|
with self._lock:
|
||||||
|
was_playing = self._playing or self._paused
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
track = pl.prev_track()
|
||||||
|
if track is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if was_playing:
|
||||||
|
self.play(track.id)
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def select_track(self, index: int) -> bool:
|
||||||
|
"""Select and play a track by playlist index."""
|
||||||
|
with self._lock:
|
||||||
|
was_playing = self._playing or self._paused
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
track = pl.get_track(index)
|
||||||
|
if track is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
pl.current_index = index
|
||||||
|
if was_playing:
|
||||||
|
self.play(track.id)
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- Track controls --
|
||||||
|
|
||||||
|
def set_volume(self, track_id: str, volume: float) -> bool:
|
||||||
|
"""Set per-track volume (0.0–1.0 linear)."""
|
||||||
|
return self.playlists.set_volume(track_id, volume)
|
||||||
|
|
||||||
|
def set_pan(self, track_id: str, pan: float) -> bool:
|
||||||
|
"""Set per-track pan (0.0 = L, 0.5 = C, 1.0 = R)."""
|
||||||
|
return self.playlists.set_pan(track_id, pan)
|
||||||
|
|
||||||
|
def set_mute(self, track_id: str, muted: bool) -> bool:
|
||||||
|
"""Set per-track mute."""
|
||||||
|
return self.playlists.set_mute(track_id, muted)
|
||||||
|
|
||||||
|
def set_solo(self, track_id: str, soloed: bool) -> bool:
|
||||||
|
"""Set per-track solo."""
|
||||||
|
return self.playlists.set_solo(track_id, soloed)
|
||||||
|
|
||||||
|
def set_play_mode(self, track_id: str, mode: PlayMode) -> bool:
|
||||||
|
"""Set playback mode (one_shot, loop, segue)."""
|
||||||
|
return self.playlists.set_play_mode(track_id, mode)
|
||||||
|
|
||||||
|
def set_loop_region(self, track_id: str, start: float, end: float = 0.0) -> bool:
|
||||||
|
"""Set loop region for a track."""
|
||||||
|
return self.playlists.set_loop_region(track_id, start, end)
|
||||||
|
|
||||||
|
# -- Transport control --
|
||||||
|
|
||||||
|
def set_tempo(self, bpm: float) -> bool:
|
||||||
|
"""Set the tempo in BPM (syncs to JACK transport if available)."""
|
||||||
|
bpm = max(20.0, min(300.0, bpm))
|
||||||
|
return self.transport.set_tempo(bpm)
|
||||||
|
|
||||||
|
# -- Metronome --
|
||||||
|
|
||||||
|
def set_metronome_enabled(self, enabled: bool) -> None:
|
||||||
|
"""Enable/disable the metronome click."""
|
||||||
|
self.metronome.enabled = enabled
|
||||||
|
self.config.metronome_enabled = enabled
|
||||||
|
|
||||||
|
def set_click_volume(self, volume: float) -> None:
|
||||||
|
"""Set metronome click volume (0.0–1.0)."""
|
||||||
|
self.metronome.set_volume(volume)
|
||||||
|
self.config.click_volume = volume
|
||||||
|
|
||||||
|
# -- Audio buffer generation (called by JACK process callback) --
|
||||||
|
|
||||||
|
def process_buffer(self, buffer_size: int | None = None) -> np.ndarray:
|
||||||
|
"""Generate the next audio buffer for JACK output.
|
||||||
|
|
||||||
|
This is the primary audio processing callback. It renders the
|
||||||
|
active track's audio at the current transport position, applies
|
||||||
|
per-track controls (volume, pan, mute), layers metronome clicks,
|
||||||
|
and handles loop/segue logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer_size: Frames to generate (default: config.buffer_size).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float32 numpy array of shape (buffer_size, 2) — stereo output.
|
||||||
|
"""
|
||||||
|
n_frames = buffer_size or self.config.buffer_size
|
||||||
|
|
||||||
|
# Get current transport state
|
||||||
|
transport = self.transport.get_state()
|
||||||
|
tempo = transport.tempo
|
||||||
|
|
||||||
|
# Default: silence
|
||||||
|
output = np.zeros((n_frames, 2), dtype=np.float32)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Handle count-in
|
||||||
|
if self._count_in_remaining > 0:
|
||||||
|
count_in_seconds = n_frames / self.config.sample_rate
|
||||||
|
self._count_in_remaining -= count_in_seconds
|
||||||
|
|
||||||
|
# Generate count-in clicks
|
||||||
|
clicks = self.metronome.generate_stereo_clicks(
|
||||||
|
self._playback_position, tempo,
|
||||||
|
bar=1, beat=1,
|
||||||
|
buffer_size=n_frames,
|
||||||
|
)
|
||||||
|
output += clicks
|
||||||
|
|
||||||
|
if self._count_in_remaining <= 0:
|
||||||
|
self._count_in_remaining = 0.0
|
||||||
|
self._playing = True
|
||||||
|
self.metronome.stop_count_in()
|
||||||
|
logger.info("Count-in complete, playback starting")
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
if not self._playing or self._active_track_id is None:
|
||||||
|
# Still generate metronome if enabled
|
||||||
|
if self.metronome.enabled:
|
||||||
|
clicks = self.metronome.generate_stereo_clicks(
|
||||||
|
self._playback_position, tempo,
|
||||||
|
bar=1, beat=1,
|
||||||
|
buffer_size=n_frames,
|
||||||
|
)
|
||||||
|
output += clicks
|
||||||
|
return output
|
||||||
|
|
||||||
|
audio = self._audio_cache.get(self._active_track_id)
|
||||||
|
track = self.playlists.get_track(self._active_track_id)
|
||||||
|
|
||||||
|
if audio is None or track is None:
|
||||||
|
return output
|
||||||
|
|
||||||
|
if track.muted:
|
||||||
|
return output
|
||||||
|
|
||||||
|
# Calculate position within the audio buffer
|
||||||
|
pos_samples = int(self._playback_position * self.config.sample_rate)
|
||||||
|
end_samples = pos_samples + n_frames
|
||||||
|
total_frames = audio.num_frames
|
||||||
|
|
||||||
|
if pos_samples >= total_frames:
|
||||||
|
# Reached end of file
|
||||||
|
self._handle_track_end(track)
|
||||||
|
return output
|
||||||
|
|
||||||
|
# Extract the slice
|
||||||
|
available = min(n_frames, total_frames - pos_samples)
|
||||||
|
chunk = audio.samples[pos_samples:pos_samples + available]
|
||||||
|
|
||||||
|
# Build stereo output from chunk
|
||||||
|
if audio.channels == 1:
|
||||||
|
# Mono → stereo (center panned)
|
||||||
|
chunk_stereo = np.zeros((available, 2), dtype=np.float32)
|
||||||
|
chunk_stereo[:, 0] = chunk[:available]
|
||||||
|
chunk_stereo[:, 1] = chunk[:available]
|
||||||
|
else:
|
||||||
|
# Already stereo
|
||||||
|
chunk_stereo = np.zeros((available, 2), dtype=np.float32)
|
||||||
|
chunk_stereo[:, 0] = chunk[:available, 0]
|
||||||
|
chunk_stereo[:, 1] = chunk[:available, 1]
|
||||||
|
|
||||||
|
# Apply pan
|
||||||
|
if track.pan != 0.5:
|
||||||
|
left_gain = np.cos(track.pan * np.pi / 2).astype(np.float32)
|
||||||
|
right_gain = np.sin(track.pan * np.pi / 2).astype(np.float32)
|
||||||
|
chunk_stereo[:, 0] *= left_gain
|
||||||
|
chunk_stereo[:, 1] *= right_gain
|
||||||
|
|
||||||
|
# Apply volume
|
||||||
|
chunk_stereo *= track.volume
|
||||||
|
|
||||||
|
# Apply fade-in
|
||||||
|
if track.fade_in > 0 and self._playback_position < track.fade_in:
|
||||||
|
fade_samples = int(track.fade_in * self.config.sample_rate)
|
||||||
|
fade_pos = int(self._playback_position * self.config.sample_rate)
|
||||||
|
for i in range(available):
|
||||||
|
sample_idx = fade_pos + i
|
||||||
|
if sample_idx < fade_samples:
|
||||||
|
gain = sample_idx / fade_samples
|
||||||
|
chunk_stereo[i] *= gain
|
||||||
|
|
||||||
|
# Apply fade-out
|
||||||
|
if track.fade_out > 0:
|
||||||
|
time_remaining = track.duration - self._playback_position
|
||||||
|
if time_remaining < track.fade_out:
|
||||||
|
fade_samples = int(track.fade_out * self.config.sample_rate)
|
||||||
|
fade_pos_end = total_frames
|
||||||
|
for i in range(available):
|
||||||
|
sample_idx = pos_samples + i
|
||||||
|
remaining = fade_pos_end - sample_idx
|
||||||
|
if remaining < fade_samples:
|
||||||
|
gain = remaining / max(1, fade_samples)
|
||||||
|
chunk_stereo[i] *= gain
|
||||||
|
|
||||||
|
# Place chunk into output buffer
|
||||||
|
output[:available] += chunk_stereo
|
||||||
|
|
||||||
|
# Advance position
|
||||||
|
self._playback_position += available / self.config.sample_rate
|
||||||
|
track.position = self._playback_position
|
||||||
|
|
||||||
|
# Metronome clicks
|
||||||
|
if self.metronome.enabled:
|
||||||
|
clicks = self.metronome.generate_stereo_clicks(
|
||||||
|
self._playback_position, tempo,
|
||||||
|
bar=1, beat=1, # TODO: accurate bar/beat from transport
|
||||||
|
buffer_size=n_frames,
|
||||||
|
)
|
||||||
|
output += clicks
|
||||||
|
|
||||||
|
# Check if we reached the end
|
||||||
|
if pos_samples + n_frames >= total_frames:
|
||||||
|
self._handle_track_end(track)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
# -- DSP integration (ParameterRegistry callback) --
|
||||||
|
|
||||||
|
def handle_parameter(self, param: MixerParameter, value: float) -> bool:
|
||||||
|
"""Handle a parameter change from the DSP engine / MIDI.
|
||||||
|
|
||||||
|
This is the callback registered with the ParameterRegistry.
|
||||||
|
Dispatches transport commands and per-track control changes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the parameter was handled by this player.
|
||||||
|
"""
|
||||||
|
if param.category != ParameterCategory.TRANSPORT:
|
||||||
|
# Also handle track-level channel parameters
|
||||||
|
if param.category == ParameterCategory.CHANNEL and param.channel >= 0:
|
||||||
|
return self._handle_track_param(param, value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
ptype = param.param_type
|
||||||
|
|
||||||
|
if ptype == ParameterType.PLAY:
|
||||||
|
if value > 0.5:
|
||||||
|
return self.play()
|
||||||
|
else:
|
||||||
|
return self.stop()
|
||||||
|
|
||||||
|
elif ptype == ParameterType.STOP:
|
||||||
|
if value > 0.5:
|
||||||
|
return self.stop()
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif ptype == ParameterType.TEMPO:
|
||||||
|
return self.set_tempo(value)
|
||||||
|
|
||||||
|
elif ptype == ParameterType.TAP_TEMPO:
|
||||||
|
# Tap tempo — value is ignored; actual tap tempo logic
|
||||||
|
# would be handled by a higher-level controller
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif ptype == ParameterType.LOOP:
|
||||||
|
if self._active_track_id:
|
||||||
|
return self.set_play_mode(
|
||||||
|
self._active_track_id,
|
||||||
|
PlayMode.LOOP if value > 0.5 else PlayMode.ONE_SHOT,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- State query --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return self._playing
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
return self._paused
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_track(self) -> Track | None:
|
||||||
|
if self._active_track_id:
|
||||||
|
return self.playlists.get_track(self._active_track_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position(self) -> float:
|
||||||
|
"""Current playback position in seconds."""
|
||||||
|
return self._playback_position
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
"""Get full player state as a dict for serialization."""
|
||||||
|
with self._lock:
|
||||||
|
track_state = None
|
||||||
|
if self._active_track_id:
|
||||||
|
track = self.playlists.get_track(self._active_track_id)
|
||||||
|
if track:
|
||||||
|
track_state = {
|
||||||
|
"id": track.id,
|
||||||
|
"title": track.title,
|
||||||
|
"state": track.state.value,
|
||||||
|
"position": track.position,
|
||||||
|
"duration": track.duration,
|
||||||
|
"volume": track.volume,
|
||||||
|
"pan": track.pan,
|
||||||
|
"muted": track.muted,
|
||||||
|
"soloed": track.soloed,
|
||||||
|
"play_mode": track.play_mode.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"playing": self._playing,
|
||||||
|
"paused": self._paused,
|
||||||
|
"active_track": track_state,
|
||||||
|
"position": self._playback_position,
|
||||||
|
"count_in_remaining": self._count_in_remaining,
|
||||||
|
"transport": {
|
||||||
|
"tempo": self.transport.state.tempo,
|
||||||
|
"state": self.transport.state.state.value,
|
||||||
|
"jack_available": self.transport.available,
|
||||||
|
},
|
||||||
|
"metronome_enabled": self.metronome.enabled,
|
||||||
|
"playlist": self.playlists.to_dict() if self.playlists.active else {},
|
||||||
|
"loaded_tracks": list(self._audio_cache.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_track_end(self, track: Track) -> None:
|
||||||
|
"""Handle when a track reaches its end."""
|
||||||
|
mode = track.play_mode
|
||||||
|
|
||||||
|
if mode == PlayMode.LOOP:
|
||||||
|
# Loop back
|
||||||
|
if track.has_loop_region:
|
||||||
|
self._playback_position = track.loop_start
|
||||||
|
else:
|
||||||
|
self._playback_position = track.cue_point
|
||||||
|
track.state = TrackState.PLAYING
|
||||||
|
logger.debug("Looping track %s", track.id)
|
||||||
|
|
||||||
|
elif mode == PlayMode.SEGUE:
|
||||||
|
# Auto-advance to next track
|
||||||
|
track.state = TrackState.FINISHED
|
||||||
|
logger.info("Track %s finished, auto-advancing", track.id)
|
||||||
|
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl and self.config.auto_advance:
|
||||||
|
next_t = pl.next_track()
|
||||||
|
if next_t:
|
||||||
|
if not next_t.loaded:
|
||||||
|
self.load_track(next_t.id)
|
||||||
|
self._active_track_id = next_t.id
|
||||||
|
self._playback_position = next_t.cue_point
|
||||||
|
next_t.state = TrackState.PLAYING
|
||||||
|
logger.info("Segue to %s", next_t.id)
|
||||||
|
else:
|
||||||
|
self._playing = False
|
||||||
|
track.state = TrackState.FINISHED
|
||||||
|
logger.info("End of playlist")
|
||||||
|
|
||||||
|
else: # ONE_SHOT
|
||||||
|
track.state = TrackState.FINISHED
|
||||||
|
self._playing = False
|
||||||
|
logger.info("Track %s finished (one-shot)", track.id)
|
||||||
|
|
||||||
|
def _handle_track_param(self, param: MixerParameter, value: float) -> bool:
|
||||||
|
"""Handle a channel-level parameter that maps to a track.
|
||||||
|
|
||||||
|
Maps mixer channel parameters to backing tracks. Channel 0
|
||||||
|
maps to the active backing track (simplified; could be extended).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if handled.
|
||||||
|
"""
|
||||||
|
# For now, map mixer channel 16+ to backing tracks 0..N
|
||||||
|
# (channels 0–15 are regular mixer channels)
|
||||||
|
track_idx = param.channel - 16
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl is None or track_idx < 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
track = pl.get_track(track_idx)
|
||||||
|
if track is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ptype = param.param_type
|
||||||
|
|
||||||
|
if ptype == ParameterType.VOLUME:
|
||||||
|
track.volume = max(0.0, min(1.0, value))
|
||||||
|
return True
|
||||||
|
elif ptype == ParameterType.PAN:
|
||||||
|
track.pan = max(0.0, min(1.0, (value + 1.0) / 2.0)) # -1..1 → 0..1
|
||||||
|
return True
|
||||||
|
elif ptype == ParameterType.MUTE:
|
||||||
|
track.muted = value >= 0.5
|
||||||
|
return True
|
||||||
|
elif ptype == ParameterType.SOLO:
|
||||||
|
track.soloed = value >= 0.5
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_transport_change(self, state: JACKTransportState) -> None:
|
||||||
|
"""Callback for JACK transport state changes."""
|
||||||
|
with self._lock:
|
||||||
|
if state.state == TransportState.STOPPED and self._playing:
|
||||||
|
# JACK transport stopped externally — pause our playback
|
||||||
|
self._playing = False
|
||||||
|
self._paused = True
|
||||||
|
logger.info("JACK transport stopped, pausing playback")
|
||||||
|
|
||||||
|
elif state.state == TransportState.ROLLING and self._paused:
|
||||||
|
# JACK transport started — resume if we were paused
|
||||||
|
# (but only if it wasn't us who started it)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Clean shutdown: stop playback, disconnect JACK, free audio."""
|
||||||
|
self.stop()
|
||||||
|
if self._sync_to_transport:
|
||||||
|
self.transport.stop_polling()
|
||||||
|
self._audio_cache.clear()
|
||||||
|
logger.info("BackingTrackPlayer shut down")
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Playlist / setlist manager for backing tracks.
|
||||||
|
|
||||||
|
Manages an ordered list of backing tracks with navigation (next/prev/jump),
|
||||||
|
add/remove/reorder, and per-track control state. Ties into the backing
|
||||||
|
track player for coordinated playback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .types import PlayMode, Track, TrackState, Playlist
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistManager:
|
||||||
|
"""Manages a setlist of backing tracks.
|
||||||
|
|
||||||
|
Provides CRUD operations, ordering, and playback navigation.
|
||||||
|
Thread-safe for concurrent access from UI and transport callbacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._playlists: dict[str, Playlist] = {}
|
||||||
|
self._active_playlist_id: str | None = None
|
||||||
|
|
||||||
|
# ── Playlist CRUD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create(self, name: str) -> Playlist:
|
||||||
|
"""Create a new empty playlist."""
|
||||||
|
pl = Playlist(name=name)
|
||||||
|
self._playlists[name] = pl
|
||||||
|
if self._active_playlist_id is None:
|
||||||
|
self._active_playlist_id = name
|
||||||
|
logger.info("Created playlist: %s", name)
|
||||||
|
return pl
|
||||||
|
|
||||||
|
def get(self, name: str) -> Playlist | None:
|
||||||
|
"""Get a playlist by name."""
|
||||||
|
return self._playlists.get(name)
|
||||||
|
|
||||||
|
def delete(self, name: str) -> bool:
|
||||||
|
"""Delete a playlist. Cannot delete the active playlist."""
|
||||||
|
if name not in self._playlists:
|
||||||
|
return False
|
||||||
|
if name == self._active_playlist_id:
|
||||||
|
logger.warning("Cannot delete active playlist: %s", name)
|
||||||
|
return False
|
||||||
|
del self._playlists[name]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_names(self) -> list[str]:
|
||||||
|
"""Get all playlist names."""
|
||||||
|
return list(self._playlists.keys())
|
||||||
|
|
||||||
|
def set_active(self, name: str) -> bool:
|
||||||
|
"""Set the active playlist by name."""
|
||||||
|
if name not in self._playlists:
|
||||||
|
logger.warning("Playlist not found: %s", name)
|
||||||
|
return False
|
||||||
|
self._active_playlist_id = name
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> Playlist | None:
|
||||||
|
"""The currently active playlist."""
|
||||||
|
if self._active_playlist_id:
|
||||||
|
return self._playlists.get(self._active_playlist_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Track management (on active playlist) ────────────────────────────
|
||||||
|
|
||||||
|
def add_track(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
title: str = "",
|
||||||
|
play_mode: PlayMode = PlayMode.ONE_SHOT,
|
||||||
|
playlist_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add a track to the playlist. Returns the track ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the audio file.
|
||||||
|
title: Display name (default: file name).
|
||||||
|
play_mode: Playback mode.
|
||||||
|
playlist_name: Target playlist (default: active).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new track's ID string.
|
||||||
|
"""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
raise ValueError("No active playlist")
|
||||||
|
|
||||||
|
track_id = f"bt_{len(pl.tracks):03d}"
|
||||||
|
title = title or Path(file_path).stem
|
||||||
|
|
||||||
|
track = Track(
|
||||||
|
id=track_id,
|
||||||
|
title=title,
|
||||||
|
file_path=file_path,
|
||||||
|
play_mode=play_mode,
|
||||||
|
)
|
||||||
|
pl.tracks.append(track)
|
||||||
|
logger.info("Added track %s: %s", track_id, track.title)
|
||||||
|
return track_id
|
||||||
|
|
||||||
|
def remove_track(self, track_id: str, playlist_name: str | None = None) -> bool:
|
||||||
|
"""Remove a track by ID."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for i, t in enumerate(pl.tracks):
|
||||||
|
if t.id == track_id:
|
||||||
|
pl.tracks.pop(i)
|
||||||
|
if pl.current_index >= len(pl.tracks):
|
||||||
|
pl.current_index = len(pl.tracks) - 1
|
||||||
|
logger.info("Removed track %s", track_id)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def move_track(
|
||||||
|
self,
|
||||||
|
track_id: str,
|
||||||
|
new_index: int,
|
||||||
|
playlist_name: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Move a track to a new position in the playlist."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for i, t in enumerate(pl.tracks):
|
||||||
|
if t.id == track_id:
|
||||||
|
track = pl.tracks.pop(i)
|
||||||
|
new_index = max(0, min(new_index, len(pl.tracks)))
|
||||||
|
pl.tracks.insert(new_index, track)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear(self, playlist_name: str | None = None) -> int:
|
||||||
|
"""Remove all tracks. Returns number removed."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return 0
|
||||||
|
count = len(pl.tracks)
|
||||||
|
pl.tracks.clear()
|
||||||
|
pl.current_index = -1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_track(self, track_id: str, playlist_name: str | None = None) -> Track | None:
|
||||||
|
"""Get a track by ID."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return None
|
||||||
|
for t in pl.tracks:
|
||||||
|
if t.id == track_id:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Track controls ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_volume(self, track_id: str, volume: float) -> bool:
|
||||||
|
"""Set per-track volume (0.0–1.0)."""
|
||||||
|
t = self.get_track(track_id)
|
||||||
|
if t:
|
||||||
|
t.volume = max(0.0, min(1.0, volume))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_pan(self, track_id: str, pan: float) -> bool:
|
||||||
|
"""Set per-track pan (0.0 = left, 0.5 = center, 1.0 = right)."""
|
||||||
|
t = self.get_track(track_id)
|
||||||
|
if t:
|
||||||
|
t.pan = max(0.0, min(1.0, pan))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_mute(self, track_id: str, muted: bool) -> bool:
|
||||||
|
"""Set per-track mute."""
|
||||||
|
t = self.get_track(track_id)
|
||||||
|
if t:
|
||||||
|
t.muted = muted
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_solo(self, track_id: str, soloed: bool) -> bool:
|
||||||
|
"""Set per-track solo."""
|
||||||
|
t = self.get_track(track_id)
|
||||||
|
if t:
|
||||||
|
t.soloed = soloed
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_play_mode(self, track_id: str, mode: PlayMode) -> bool:
|
||||||
|
"""Set playback mode."""
|
||||||
|
t = self.get_track(track_id)
|
||||||
|
if t:
|
||||||
|
t.play_mode = mode
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_cue_point(self, track_id: str, seconds: float) -> bool:
|
||||||
|
"""Set start cue point."""
|
||||||
|
t = self.get_track(track_id)
|
||||||
|
if t:
|
||||||
|
t.cue_point = max(0.0, seconds)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_loop_region(self, track_id: str, start: float, end: float = 0.0) -> bool:
|
||||||
|
"""Set loop region in seconds."""
|
||||||
|
t = self.get_track(track_id)
|
||||||
|
if t:
|
||||||
|
t.loop_start = max(0.0, start)
|
||||||
|
t.loop_end = max(0.0, end)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Playback navigation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def select_track(self, index: int, playlist_name: str | None = None) -> Track | None:
|
||||||
|
"""Select a track by index (0-based)."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None or not (0 <= index < len(pl.tracks)):
|
||||||
|
return None
|
||||||
|
pl.current_index = index
|
||||||
|
return pl.tracks[index]
|
||||||
|
|
||||||
|
def next_track(self, playlist_name: str | None = None) -> Track | None:
|
||||||
|
"""Advance to next track."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return None
|
||||||
|
return pl.next_track()
|
||||||
|
|
||||||
|
def prev_track(self, playlist_name: str | None = None) -> Track | None:
|
||||||
|
"""Go to previous track."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return None
|
||||||
|
return pl.prev_track()
|
||||||
|
|
||||||
|
# ── Serialization ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def to_dict(self, playlist_name: str | None = None) -> dict:
|
||||||
|
"""Serialize a playlist to a dict."""
|
||||||
|
pl = self._resolve_playlist(playlist_name)
|
||||||
|
if pl is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"name": pl.name,
|
||||||
|
"current_index": pl.current_index,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"title": t.title,
|
||||||
|
"artist": t.artist,
|
||||||
|
"file_path": t.file_path,
|
||||||
|
"duration": t.duration,
|
||||||
|
"sample_rate": t.sample_rate,
|
||||||
|
"channels": t.channels,
|
||||||
|
"play_mode": t.play_mode.value,
|
||||||
|
"volume": t.volume,
|
||||||
|
"pan": t.pan,
|
||||||
|
"muted": t.muted,
|
||||||
|
"soloed": t.soloed,
|
||||||
|
"cue_point": t.cue_point,
|
||||||
|
"loop_start": t.loop_start,
|
||||||
|
"loop_end": t.loop_end,
|
||||||
|
"fade_in": t.fade_in,
|
||||||
|
"fade_out": t.fade_out,
|
||||||
|
}
|
||||||
|
for t in pl.tracks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_json(self, playlist_name: str | None = None, indent: int = 2) -> str:
|
||||||
|
"""Serialize to JSON string."""
|
||||||
|
return json.dumps(self.to_dict(playlist_name), indent=indent)
|
||||||
|
|
||||||
|
def from_dict(self, data: dict) -> Playlist:
|
||||||
|
"""Restore a playlist from a dict."""
|
||||||
|
pl = Playlist(
|
||||||
|
name=data.get("name", "Untitled"),
|
||||||
|
current_index=data.get("current_index", -1),
|
||||||
|
)
|
||||||
|
for td in data.get("tracks", []):
|
||||||
|
t = Track(
|
||||||
|
id=td.get("id", ""),
|
||||||
|
title=td.get("title", ""),
|
||||||
|
artist=td.get("artist", ""),
|
||||||
|
file_path=td.get("file_path", ""),
|
||||||
|
duration=td.get("duration", 0.0),
|
||||||
|
sample_rate=td.get("sample_rate", 0),
|
||||||
|
channels=td.get("channels", 0),
|
||||||
|
play_mode=PlayMode(td.get("play_mode", "one_shot")),
|
||||||
|
volume=td.get("volume", 1.0),
|
||||||
|
pan=td.get("pan", 0.5),
|
||||||
|
muted=td.get("muted", False),
|
||||||
|
soloed=td.get("soloed", False),
|
||||||
|
cue_point=td.get("cue_point", 0.0),
|
||||||
|
loop_start=td.get("loop_start", 0.0),
|
||||||
|
loop_end=td.get("loop_end", 0.0),
|
||||||
|
fade_in=td.get("fade_in", 0.0),
|
||||||
|
fade_out=td.get("fade_out", 0.0),
|
||||||
|
)
|
||||||
|
pl.tracks.append(t)
|
||||||
|
|
||||||
|
self._playlists[pl.name] = pl
|
||||||
|
if self._active_playlist_id is None:
|
||||||
|
self._active_playlist_id = pl.name
|
||||||
|
return pl
|
||||||
|
|
||||||
|
def from_json(self, json_str: str) -> Playlist:
|
||||||
|
"""Restore a playlist from JSON string."""
|
||||||
|
return self.from_dict(json.loads(json_str))
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolve_playlist(self, name: str | None) -> Playlist | None:
|
||||||
|
"""Get playlist by name, falling back to active."""
|
||||||
|
if name:
|
||||||
|
return self._playlists.get(name)
|
||||||
|
return self.active
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""JACK Transport synchronization for the backing track player.
|
||||||
|
|
||||||
|
Interacts with the JACK transport system via jack_transport CLI and
|
||||||
|
jack_metro for metronome sync. Exposes transport state (tempo, position,
|
||||||
|
rolling state) and provides tempo change notifications.
|
||||||
|
|
||||||
|
On systems without a running JACK server, all operations degrade
|
||||||
|
gracefully — transport state returns safe defaults and CLI calls
|
||||||
|
are silently skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transport state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TransportState(StrEnum):
|
||||||
|
"""JACK transport state."""
|
||||||
|
STOPPED = "stopped"
|
||||||
|
ROLLING = "rolling" # playing
|
||||||
|
STARTING = "starting" # in count-in / pre-roll
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JACKTransportState:
|
||||||
|
"""Snapshot of JACK transport state."""
|
||||||
|
state: TransportState = TransportState.UNKNOWN
|
||||||
|
tempo: float = 120.0 # BPM
|
||||||
|
position: float = 0.0 # seconds (frame / sample_rate)
|
||||||
|
frame: int = 0 # transport frame
|
||||||
|
sample_rate: int = 48000
|
||||||
|
bar: int = 0 # current bar (1-based)
|
||||||
|
beat: int = 0 # current beat within bar (1-based)
|
||||||
|
tick: int = 0 # current tick within beat (0-based)
|
||||||
|
beats_per_bar: float = 4.0
|
||||||
|
beat_type: float = 4.0
|
||||||
|
updated_at: float = 0.0 # monotonic timestamp of this snapshot
|
||||||
|
|
||||||
|
|
||||||
|
# ── JACK Transport client ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class JACKTransport:
|
||||||
|
"""JACK transport synchronization client.
|
||||||
|
|
||||||
|
Communicates with the JACK server via CLI tools. Designed to run
|
||||||
|
on a Raspberry Pi with jackd running. Degrades gracefully when
|
||||||
|
JACK is not available.
|
||||||
|
|
||||||
|
Thread-safe: uses a lock for state and supports callbacks for
|
||||||
|
transport changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sample_rate: int = 48000,
|
||||||
|
poll_interval: float = 0.1,
|
||||||
|
auto_poll: bool = False,
|
||||||
|
):
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._poll_interval = poll_interval
|
||||||
|
self._state = JACKTransportState(sample_rate=sample_rate)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._polling = False
|
||||||
|
self._poll_thread: threading.Thread | None = None
|
||||||
|
self._callbacks: list[Callable[[JACKTransportState], None]] = []
|
||||||
|
|
||||||
|
# Check JACK availability
|
||||||
|
self._jack_available = self._check_jack()
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""True if JACK server is running and reachable."""
|
||||||
|
return self._jack_available
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> JACKTransportState:
|
||||||
|
"""Current transport state snapshot."""
|
||||||
|
with self._lock:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def get_state(self) -> JACKTransportState:
|
||||||
|
"""Poll and return current transport state."""
|
||||||
|
if not self._jack_available:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
st = self._poll_transport()
|
||||||
|
with self._lock:
|
||||||
|
old_state = self._state.state
|
||||||
|
old_tempo = self._state.tempo
|
||||||
|
self._state = st
|
||||||
|
|
||||||
|
# Notify callbacks on state change
|
||||||
|
if st.state != old_state or abs(st.tempo - old_tempo) > 0.01:
|
||||||
|
self._notify_callbacks(st)
|
||||||
|
|
||||||
|
return st
|
||||||
|
|
||||||
|
# ── Transport control ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start JACK transport rolling."""
|
||||||
|
return self._jack_cmd(["jack_transport", "start"])
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
"""Stop JACK transport."""
|
||||||
|
return self._jack_cmd(["jack_transport", "stop"])
|
||||||
|
|
||||||
|
def toggle(self) -> bool:
|
||||||
|
"""Toggle play/stop."""
|
||||||
|
st = self.get_state()
|
||||||
|
if st.state == TransportState.ROLLING:
|
||||||
|
return self.stop()
|
||||||
|
return self.start()
|
||||||
|
|
||||||
|
def locate(self, frame: int) -> bool:
|
||||||
|
"""Seek transport to a specific frame."""
|
||||||
|
if not self._jack_available:
|
||||||
|
with self._lock:
|
||||||
|
self._state.frame = frame
|
||||||
|
self._state.position = frame / self._sample_rate
|
||||||
|
return True
|
||||||
|
return self._jack_cmd(["jack_transport", "locate", str(frame)])
|
||||||
|
|
||||||
|
def locate_seconds(self, seconds: float) -> bool:
|
||||||
|
"""Seek transport to a time in seconds."""
|
||||||
|
return self.locate(int(seconds * self._sample_rate))
|
||||||
|
|
||||||
|
def set_tempo(self, bpm: float) -> bool:
|
||||||
|
"""Set the JACK transport tempo (BPM).
|
||||||
|
|
||||||
|
Uses jack_transport tempo if available, otherwise sets locally.
|
||||||
|
"""
|
||||||
|
if not self._jack_available:
|
||||||
|
with self._lock:
|
||||||
|
self._state.tempo = bpm
|
||||||
|
return True
|
||||||
|
# jack_transport doesn't have a direct tempo set; use jack_property
|
||||||
|
ok = self._jack_cmd(
|
||||||
|
["jack_property", "-s", "-p", str(bpm), "tempo", "double"]
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
with self._lock:
|
||||||
|
self._state.tempo = bpm
|
||||||
|
return ok
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_transport_change(self, callback: Callable[[JACKTransportState], None]) -> None:
|
||||||
|
"""Register a callback for transport state changes."""
|
||||||
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_callback(self, callback: Callable) -> None:
|
||||||
|
"""Remove a previously registered callback."""
|
||||||
|
try:
|
||||||
|
self._callbacks.remove(callback)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Polling (background) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_polling(self) -> None:
|
||||||
|
"""Start background transport polling thread."""
|
||||||
|
if self._polling or not self._jack_available:
|
||||||
|
return
|
||||||
|
self._polling = True
|
||||||
|
self._poll_thread = threading.Thread(
|
||||||
|
target=self._poll_loop, daemon=True, name="jack-transport-poll"
|
||||||
|
)
|
||||||
|
self._poll_thread.start()
|
||||||
|
logger.info("JACK transport polling started")
|
||||||
|
|
||||||
|
def stop_polling(self) -> None:
|
||||||
|
"""Stop background polling thread."""
|
||||||
|
self._polling = False
|
||||||
|
if self._poll_thread:
|
||||||
|
self._poll_thread.join(timeout=2.0)
|
||||||
|
self._poll_thread = None
|
||||||
|
logger.info("JACK transport polling stopped")
|
||||||
|
|
||||||
|
# ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _check_jack(self) -> bool:
|
||||||
|
"""Check if JACK server is running."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jack_transport"],
|
||||||
|
capture_output=True, text=True, timeout=3,
|
||||||
|
)
|
||||||
|
# Even if it returns non-zero, if we get output it's likely
|
||||||
|
# just printing usage because we didn't pass a subcommand
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||||
|
logger.debug("JACK transport not available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _poll_transport(self) -> JACKTransportState:
|
||||||
|
"""Query JACK transport state via CLI."""
|
||||||
|
st = JACKTransportState(
|
||||||
|
state=TransportState.UNKNOWN,
|
||||||
|
sample_rate=self._sample_rate,
|
||||||
|
updated_at=time.monotonic(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._jack_available:
|
||||||
|
# Return last known state with updated timestamp
|
||||||
|
with self._lock:
|
||||||
|
s = self._state
|
||||||
|
s.updated_at = time.monotonic()
|
||||||
|
return s
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Query tempo
|
||||||
|
tempo_result = subprocess.run(
|
||||||
|
["jack_property", "-l"],
|
||||||
|
capture_output=True, text=True, timeout=3,
|
||||||
|
)
|
||||||
|
tempo = 120.0
|
||||||
|
if tempo_result.returncode == 0:
|
||||||
|
for line in tempo_result.stdout.strip().split("\n"):
|
||||||
|
if "tempo" in line.lower():
|
||||||
|
try:
|
||||||
|
# Parse "tempo double 120.0" format
|
||||||
|
parts = line.split()
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p == "tempo" and i + 2 < len(parts):
|
||||||
|
tempo = float(parts[i + 2])
|
||||||
|
break
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
st.tempo = tempo
|
||||||
|
|
||||||
|
# Check if transport is rolling
|
||||||
|
rolling_result = subprocess.run(
|
||||||
|
["jack_transport"],
|
||||||
|
capture_output=True, text=True, timeout=3,
|
||||||
|
)
|
||||||
|
output = rolling_result.stdout.lower()
|
||||||
|
if "rolling" in output:
|
||||||
|
st.state = TransportState.ROLLING
|
||||||
|
elif "stopped" in output:
|
||||||
|
st.state = TransportState.STOPPED
|
||||||
|
|
||||||
|
# Try to get frame position (jack_transport doesn't always
|
||||||
|
# expose this easily; jack-showtime or custom tool)
|
||||||
|
try:
|
||||||
|
frame_result = subprocess.run(
|
||||||
|
["jack_transport", "dump"],
|
||||||
|
capture_output=True, text=True, timeout=3,
|
||||||
|
)
|
||||||
|
# Parse frame from output if available
|
||||||
|
for line in frame_result.stdout.strip().split("\n"):
|
||||||
|
if "frame" in line.lower():
|
||||||
|
try:
|
||||||
|
st.frame = int(line.split()[-1])
|
||||||
|
st.position = st.frame / self._sample_rate
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("JACK transport query timed out")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("JACK transport query error: %s", e)
|
||||||
|
|
||||||
|
return st
|
||||||
|
|
||||||
|
def _jack_cmd(self, cmd: list[str]) -> bool:
|
||||||
|
"""Run a JACK CLI command. Returns True on success."""
|
||||||
|
if not self._jack_available:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
|
||||||
|
logger.debug("JACK command failed: %s — %s", cmd, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _notify_callbacks(self, state: JACKTransportState) -> None:
|
||||||
|
"""Notify all registered callbacks of a transport change."""
|
||||||
|
for cb in self._callbacks:
|
||||||
|
try:
|
||||||
|
cb(state)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Transport callback error")
|
||||||
|
|
||||||
|
def _poll_loop(self) -> None:
|
||||||
|
"""Background poll loop."""
|
||||||
|
while self._polling:
|
||||||
|
self.get_state()
|
||||||
|
time.sleep(self._poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Convenience function ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def jack_transport_state() -> JACKTransportState:
|
||||||
|
"""Quick one-shot query of current JACK transport state."""
|
||||||
|
transport = JACKTransport()
|
||||||
|
return transport.get_state()
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Backing track player types — enums and dataclasses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ── Playback modes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PlayMode(enum.StrEnum):
|
||||||
|
"""Playback mode for a backing track."""
|
||||||
|
ONE_SHOT = "one_shot" # play once, stop at end
|
||||||
|
LOOP = "loop" # loop continuously
|
||||||
|
SEGUE = "segue" # play once, then auto-advance to next track
|
||||||
|
|
||||||
|
|
||||||
|
class TrackState(enum.StrEnum):
|
||||||
|
"""Current state of a track in the player."""
|
||||||
|
IDLE = "idle" # loaded, not playing
|
||||||
|
PLAYING = "playing" # actively playing
|
||||||
|
PAUSED = "paused" # paused mid-playback
|
||||||
|
STOPPED = "stopped" # manually stopped
|
||||||
|
FINISHED = "finished" # reached end (one-shot/segue)
|
||||||
|
ERROR = "error" # load or playback error
|
||||||
|
|
||||||
|
|
||||||
|
# ── Backing track ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Track:
|
||||||
|
"""A single backing track entry in a playlist.
|
||||||
|
|
||||||
|
Holds the file reference, playback mode, and per-track mixer
|
||||||
|
controls that can be automated via MIDI or OSC.
|
||||||
|
"""
|
||||||
|
# Identity
|
||||||
|
id: str # unique track id (e.g. "bt_001")
|
||||||
|
title: str = "" # display name
|
||||||
|
artist: str = "" # optional artist
|
||||||
|
file_path: str = "" # path to audio file
|
||||||
|
|
||||||
|
# Audio metadata (populated after load)
|
||||||
|
duration: float = 0.0 # seconds
|
||||||
|
sample_rate: int = 0 # Hz
|
||||||
|
channels: int = 0 # 1=mono, 2=stereo
|
||||||
|
loaded: bool = False # True when audio data is in memory
|
||||||
|
|
||||||
|
# Playback mode
|
||||||
|
play_mode: PlayMode = PlayMode.ONE_SHOT
|
||||||
|
|
||||||
|
# Per-track mixer controls (0.0–1.0 unless noted)
|
||||||
|
volume: float = 1.0 # 0.0 – 1.0 linear
|
||||||
|
pan: float = 0.5 # 0.0 (L) – 1.0 (R), 0.5 = center
|
||||||
|
muted: bool = False
|
||||||
|
soloed: bool = False
|
||||||
|
|
||||||
|
# Transport control
|
||||||
|
cue_point: float = 0.0 # start offset in seconds
|
||||||
|
loop_start: float = 0.0 # loop region start (seconds)
|
||||||
|
loop_end: float = 0.0 # loop region end (0 = end of file)
|
||||||
|
fade_in: float = 0.0 # fade-in duration (seconds)
|
||||||
|
fade_out: float = 0.0 # fade-out duration (seconds)
|
||||||
|
|
||||||
|
# State (runtime)
|
||||||
|
state: TrackState = TrackState.IDLE
|
||||||
|
position: float = 0.0 # current playback position (seconds)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_name(self) -> str:
|
||||||
|
"""Just the file name, not the full path."""
|
||||||
|
return Path(self.file_path).name if self.file_path else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_loop_region(self) -> bool:
|
||||||
|
"""True if a custom loop region is defined."""
|
||||||
|
return self.loop_end > 0 and self.loop_end > self.loop_start
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_loop_end(self) -> float:
|
||||||
|
"""The effective end of loop (file duration if not set)."""
|
||||||
|
if self.loop_end > 0:
|
||||||
|
return self.loop_end
|
||||||
|
return self.duration
|
||||||
|
|
||||||
|
|
||||||
|
# ── Playlist / Setlist ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Playlist:
|
||||||
|
"""An ordered collection of backing tracks (a setlist)."""
|
||||||
|
name: str = "Untitled Playlist"
|
||||||
|
tracks: list[Track] = field(default_factory=list)
|
||||||
|
current_index: int = -1 # index of active track (-1 = none)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_track(self) -> Track | None:
|
||||||
|
"""The currently active track, or None."""
|
||||||
|
if 0 <= self.current_index < len(self.tracks):
|
||||||
|
return self.tracks[self.current_index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def track_count(self) -> int:
|
||||||
|
return len(self.tracks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_duration(self) -> float:
|
||||||
|
"""Sum of all track durations."""
|
||||||
|
return sum(t.duration for t in self.tracks)
|
||||||
|
|
||||||
|
def get_track(self, index: int) -> Track | None:
|
||||||
|
"""Get track at index, or None if out of bounds."""
|
||||||
|
if 0 <= index < len(self.tracks):
|
||||||
|
return self.tracks[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def next_track(self) -> Track | None:
|
||||||
|
"""Advance to next track and return it, or None at end."""
|
||||||
|
if self.current_index + 1 < len(self.tracks):
|
||||||
|
self.current_index += 1
|
||||||
|
return self.tracks[self.current_index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def prev_track(self) -> Track | None:
|
||||||
|
"""Go to previous track and return it, or None at start."""
|
||||||
|
if self.current_index > 0:
|
||||||
|
self.current_index -= 1
|
||||||
|
return self.tracks[self.current_index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Player configuration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackingPlayerConfig:
|
||||||
|
"""Configuration for the backing track player."""
|
||||||
|
# Audio
|
||||||
|
sample_rate: int = 48000 # output sample rate
|
||||||
|
buffer_size: int = 256 # frames per buffer
|
||||||
|
channels: int = 2 # stereo output
|
||||||
|
|
||||||
|
# JACK
|
||||||
|
jack_client_name: str = "pi_mixer_backing"
|
||||||
|
jack_port_prefix: str = "pi_mixer" # prefix for JACK port names
|
||||||
|
jack_transport_enabled: bool = True
|
||||||
|
|
||||||
|
# Metronome
|
||||||
|
metronome_enabled: bool = True
|
||||||
|
click_volume: float = 0.5 # 0.0 – 1.0
|
||||||
|
click_freq_strong: float = 1000.0 # Hz for downbeat
|
||||||
|
click_freq_weak: float = 800.0 # Hz for off-beats
|
||||||
|
click_duration_ms: float = 15.0 # click tone duration
|
||||||
|
|
||||||
|
# Count-in
|
||||||
|
count_in_bars: int = 2 # bars of count-in before playback
|
||||||
|
time_signature_num: int = 4 # e.g. 4/4
|
||||||
|
time_signature_den: int = 4
|
||||||
|
|
||||||
|
# Playlist
|
||||||
|
auto_advance: bool = True # auto-advance on track finish (segue)
|
||||||
|
|
||||||
|
# DSP integration
|
||||||
|
dsp_engine: object | None = None # reference to DSPEngine for parameter dispatch
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""MIDI subsystem for Raspberry Pi RT Audio Mixer.
|
||||||
|
|
||||||
|
Provides USB MIDI device discovery, mapping engine, learn mode,
|
||||||
|
clock sync, and JACK MIDI bridge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
MIDIMessage,
|
||||||
|
MIDIMessageType,
|
||||||
|
MIDIMapping,
|
||||||
|
MixerParameter,
|
||||||
|
ParameterCategory,
|
||||||
|
ParameterType,
|
||||||
|
)
|
||||||
|
from .midi_engine import MIDIEngine
|
||||||
|
from .midi_learn import MIDILearn, LearnState
|
||||||
|
from .midi_clock import MIDIClock
|
||||||
|
from .mapping_store import (
|
||||||
|
save_mappings,
|
||||||
|
load_mappings,
|
||||||
|
list_sessions,
|
||||||
|
delete_session,
|
||||||
|
)
|
||||||
|
from .jack_midi_bridge import JACKMIDIBridge
|
||||||
|
from .device_discovery import discover_all, MIDIDevice
|
||||||
|
from .controllers import (
|
||||||
|
KNOWN_CONTROLLERS,
|
||||||
|
find_controller,
|
||||||
|
find_controller_by_alsa,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MIDIMessage",
|
||||||
|
"MIDIMessageType",
|
||||||
|
"MIDIMapping",
|
||||||
|
"MixerParameter",
|
||||||
|
"ParameterCategory",
|
||||||
|
"ParameterType",
|
||||||
|
"MIDIEngine",
|
||||||
|
"MIDILearn",
|
||||||
|
"LearnState",
|
||||||
|
"MIDIClock",
|
||||||
|
"save_mappings",
|
||||||
|
"load_mappings",
|
||||||
|
"list_sessions",
|
||||||
|
"delete_session",
|
||||||
|
"JACKMIDIBridge",
|
||||||
|
"discover_all",
|
||||||
|
"MIDIDevice",
|
||||||
|
"KNOWN_CONTROLLERS",
|
||||||
|
"find_controller",
|
||||||
|
"find_controller_by_alsa",
|
||||||
|
]
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""Supported MIDI controller database.
|
||||||
|
|
||||||
|
Pre-defined profiles for common USB MIDI controllers. Each profile
|
||||||
|
maps physical controls to MIDI CC/NRPN numbers. Profiles are used
|
||||||
|
to auto-populate initial mappings and for device identification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ControllerControl:
|
||||||
|
"""Describes one physical control on a MIDI controller."""
|
||||||
|
name: str
|
||||||
|
cc_number: int # MIDI CC number (or -1 for NRPN)
|
||||||
|
channel: int = 0 # MIDI channel (0–15, -1 = omni)
|
||||||
|
control_type: str = "fader" # fader, knob, button, encoder, transport
|
||||||
|
is_nrpn: bool = False
|
||||||
|
nrpn_number: int = 0
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ControllerProfile:
|
||||||
|
"""Profile describing a known MIDI controller's control layout."""
|
||||||
|
manufacturer: str
|
||||||
|
model: str
|
||||||
|
usb_vendor_id: int # USB VID (hex)
|
||||||
|
usb_product_id: int # USB PID (hex)
|
||||||
|
alsa_client_pattern: str = "" # Substring to match in ALSA client name
|
||||||
|
num_faders: int = 0
|
||||||
|
num_knobs: int = 0
|
||||||
|
num_buttons: int = 0
|
||||||
|
has_transport: bool = False
|
||||||
|
has_jog_wheel: bool = False
|
||||||
|
has_scribble_strips: bool = False
|
||||||
|
controls: list[ControllerControl] = field(default_factory=list)
|
||||||
|
midi_channel_count: int = 1
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
def find_control(self, cc: int, channel: int = 0) -> ControllerControl | None:
|
||||||
|
for ctrl in self.controls:
|
||||||
|
if ctrl.cc_number == cc and (ctrl.channel == channel or ctrl.channel < 0):
|
||||||
|
return ctrl
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Known controllers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
KNOWN_CONTROLLERS: list[ControllerProfile] = [
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="Behringer",
|
||||||
|
model="X-Touch Compact",
|
||||||
|
usb_vendor_id=0x1397,
|
||||||
|
usb_product_id=0x00B4,
|
||||||
|
alsa_client_pattern="X-TOUCH COMPACT",
|
||||||
|
num_faders=9,
|
||||||
|
num_knobs=16,
|
||||||
|
num_buttons=39,
|
||||||
|
has_transport=True,
|
||||||
|
has_jog_wheel=False,
|
||||||
|
midi_channel_count=1,
|
||||||
|
notes="MCU-compatible mode preferred. Use MIDI channel 1.",
|
||||||
|
controls=[
|
||||||
|
# 9 motorised faders
|
||||||
|
ControllerControl("Fader 1", 70, 0, "fader"),
|
||||||
|
ControllerControl("Fader 2", 71, 0, "fader"),
|
||||||
|
ControllerControl("Fader 3", 72, 0, "fader"),
|
||||||
|
ControllerControl("Fader 4", 73, 0, "fader"),
|
||||||
|
ControllerControl("Fader 5", 74, 0, "fader"),
|
||||||
|
ControllerControl("Fader 6", 75, 0, "fader"),
|
||||||
|
ControllerControl("Fader 7", 76, 0, "fader"),
|
||||||
|
ControllerControl("Fader 8", 77, 0, "fader"),
|
||||||
|
ControllerControl("Master", 78, 0, "fader"),
|
||||||
|
# 16 rotary encoders (top row)
|
||||||
|
ControllerControl("Encoder 1", 10, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 2", 11, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 3", 12, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 4", 13, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 5", 14, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 6", 15, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 7", 16, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 8", 17, 0, "encoder"),
|
||||||
|
# Bottom encoders
|
||||||
|
ControllerControl("Encoder 9", 18, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 10", 19, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 11", 20, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 12", 21, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 13", 22, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 14", 23, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 15", 24, 0, "encoder"),
|
||||||
|
ControllerControl("Encoder 16", 25, 0, "encoder"),
|
||||||
|
# Buttons per channel
|
||||||
|
ControllerControl("Rec 1", 0, 0, "button"),
|
||||||
|
ControllerControl("Rec 2", 1, 0, "button"),
|
||||||
|
ControllerControl("Rec 3", 2, 0, "button"),
|
||||||
|
ControllerControl("Rec 4", 3, 0, "button"),
|
||||||
|
ControllerControl("Rec 5", 4, 0, "button"),
|
||||||
|
ControllerControl("Rec 6", 5, 0, "button"),
|
||||||
|
ControllerControl("Rec 7", 6, 0, "button"),
|
||||||
|
ControllerControl("Rec 8", 7, 0, "button"),
|
||||||
|
ControllerControl("Solo 1", 8, 0, "button"),
|
||||||
|
ControllerControl("Solo 2", 9, 0, "button"),
|
||||||
|
ControllerControl("Solo 3", 10, 0, "button"),
|
||||||
|
ControllerControl("Solo 4", 11, 0, "button"),
|
||||||
|
# Transport
|
||||||
|
ControllerControl("Play", 114, 0, "transport"),
|
||||||
|
ControllerControl("Stop", 115, 0, "transport"),
|
||||||
|
ControllerControl("Rec", 116, 0, "transport"),
|
||||||
|
ControllerControl("Rewind", 117, 0, "transport"),
|
||||||
|
ControllerControl("FFwd", 118, 0, "transport"),
|
||||||
|
ControllerControl("Loop", 119, 0, "transport"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="Behringer",
|
||||||
|
model="X-Touch (MCU mode)",
|
||||||
|
usb_vendor_id=0x1397,
|
||||||
|
usb_product_id=0x00B5,
|
||||||
|
alsa_client_pattern="X-TOUCH",
|
||||||
|
num_faders=9,
|
||||||
|
num_knobs=8,
|
||||||
|
num_buttons=92,
|
||||||
|
has_transport=True,
|
||||||
|
has_jog_wheel=True,
|
||||||
|
has_scribble_strips=True,
|
||||||
|
midi_channel_count=1,
|
||||||
|
notes="Standard Mackie Control Universal protocol. 9 motorised faders, V-Pots, jog wheel.",
|
||||||
|
controls=[
|
||||||
|
ControllerControl("Fader 1", 0, 0, "fader"),
|
||||||
|
ControllerControl("Fader 2", 1, 0, "fader"),
|
||||||
|
ControllerControl("Fader 3", 2, 0, "fader"),
|
||||||
|
ControllerControl("Fader 4", 3, 0, "fader"),
|
||||||
|
ControllerControl("Fader 5", 4, 0, "fader"),
|
||||||
|
ControllerControl("Fader 6", 5, 0, "fader"),
|
||||||
|
ControllerControl("Fader 7", 6, 0, "fader"),
|
||||||
|
ControllerControl("Fader 8", 7, 0, "fader"),
|
||||||
|
ControllerControl("Master", 8, 0, "fader"),
|
||||||
|
# V-Pots (relative encoders)
|
||||||
|
ControllerControl("V-Pot 1", 16, 0, "encoder"),
|
||||||
|
ControllerControl("V-Pot 2", 17, 0, "encoder"),
|
||||||
|
ControllerControl("V-Pot 3", 18, 0, "encoder"),
|
||||||
|
ControllerControl("V-Pot 4", 19, 0, "encoder"),
|
||||||
|
ControllerControl("V-Pot 5", 20, 0, "encoder"),
|
||||||
|
ControllerControl("V-Pot 6", 21, 0, "encoder"),
|
||||||
|
ControllerControl("V-Pot 7", 22, 0, "encoder"),
|
||||||
|
ControllerControl("V-Pot 8", 23, 0, "encoder"),
|
||||||
|
# Transport
|
||||||
|
ControllerControl("Play", 94, 0, "transport"),
|
||||||
|
ControllerControl("Stop", 93, 0, "transport"),
|
||||||
|
ControllerControl("Rec", 95, 0, "transport"),
|
||||||
|
ControllerControl("Rewind", 91, 0, "transport"),
|
||||||
|
ControllerControl("FFwd", 92, 0, "transport"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="FaderFox",
|
||||||
|
model="UC4",
|
||||||
|
usb_vendor_id=0x16D0,
|
||||||
|
usb_product_id=0x0D27,
|
||||||
|
alsa_client_pattern="Faderfox UC4",
|
||||||
|
num_faders=8,
|
||||||
|
num_knobs=0,
|
||||||
|
num_buttons=32,
|
||||||
|
has_transport=False,
|
||||||
|
midi_channel_count=1,
|
||||||
|
notes="Class-compliant USB MIDI. No drivers needed on Linux.",
|
||||||
|
controls=[
|
||||||
|
ControllerControl("Fader 1", 16, 0, "fader"),
|
||||||
|
ControllerControl("Fader 2", 17, 0, "fader"),
|
||||||
|
ControllerControl("Fader 3", 18, 0, "fader"),
|
||||||
|
ControllerControl("Fader 4", 19, 0, "fader"),
|
||||||
|
ControllerControl("Fader 5", 20, 0, "fader"),
|
||||||
|
ControllerControl("Fader 6", 21, 0, "fader"),
|
||||||
|
ControllerControl("Fader 7", 22, 0, "fader"),
|
||||||
|
ControllerControl("Fader 8", 23, 0, "fader"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="Akai",
|
||||||
|
model="MIDImix",
|
||||||
|
usb_vendor_id=0x09E8,
|
||||||
|
usb_product_id=0x0031,
|
||||||
|
alsa_client_pattern="MIDImix",
|
||||||
|
num_faders=9,
|
||||||
|
num_knobs=24,
|
||||||
|
num_buttons=16,
|
||||||
|
has_transport=False,
|
||||||
|
midi_channel_count=1,
|
||||||
|
notes="8 channel strips + master. 3 knobs per channel. Bank buttons send CC.",
|
||||||
|
controls=[
|
||||||
|
# 8 channel faders
|
||||||
|
ControllerControl("Fader 1", 19, 0, "fader"),
|
||||||
|
ControllerControl("Fader 2", 23, 0, "fader"),
|
||||||
|
ControllerControl("Fader 3", 27, 0, "fader"),
|
||||||
|
ControllerControl("Fader 4", 31, 0, "fader"),
|
||||||
|
ControllerControl("Fader 5", 49, 0, "fader"),
|
||||||
|
ControllerControl("Fader 6", 53, 0, "fader"),
|
||||||
|
ControllerControl("Fader 7", 57, 0, "fader"),
|
||||||
|
ControllerControl("Fader 8", 61, 0, "fader"),
|
||||||
|
ControllerControl("Master", 62, 0, "fader"),
|
||||||
|
# Knobs row 1 per channel
|
||||||
|
ControllerControl("Knob 1A", 16, 0, "knob"),
|
||||||
|
ControllerControl("Knob 2A", 20, 0, "knob"),
|
||||||
|
ControllerControl("Knob 3A", 24, 0, "knob"),
|
||||||
|
ControllerControl("Knob 4A", 28, 0, "knob"),
|
||||||
|
ControllerControl("Knob 5A", 46, 0, "knob"),
|
||||||
|
ControllerControl("Knob 6A", 50, 0, "knob"),
|
||||||
|
ControllerControl("Knob 7A", 54, 0, "knob"),
|
||||||
|
ControllerControl("Knob 8A", 58, 0, "knob"),
|
||||||
|
# Knobs row 2
|
||||||
|
ControllerControl("Knob 1B", 17, 0, "knob"),
|
||||||
|
ControllerControl("Knob 2B", 21, 0, "knob"),
|
||||||
|
ControllerControl("Knob 3B", 25, 0, "knob"),
|
||||||
|
ControllerControl("Knob 4B", 29, 0, "knob"),
|
||||||
|
ControllerControl("Knob 5B", 47, 0, "knob"),
|
||||||
|
ControllerControl("Knob 6B", 51, 0, "knob"),
|
||||||
|
ControllerControl("Knob 7B", 55, 0, "knob"),
|
||||||
|
ControllerControl("Knob 8B", 59, 0, "knob"),
|
||||||
|
# Knobs row 3
|
||||||
|
ControllerControl("Knob 1C", 18, 0, "knob"),
|
||||||
|
ControllerControl("Knob 2C", 22, 0, "knob"),
|
||||||
|
ControllerControl("Knob 3C", 26, 0, "knob"),
|
||||||
|
ControllerControl("Knob 4C", 30, 0, "knob"),
|
||||||
|
ControllerControl("Knob 5C", 48, 0, "knob"),
|
||||||
|
ControllerControl("Knob 6C", 52, 0, "knob"),
|
||||||
|
ControllerControl("Knob 7C", 56, 0, "knob"),
|
||||||
|
ControllerControl("Knob 8C", 60, 0, "knob"),
|
||||||
|
# Mute/Solo buttons (momentary)
|
||||||
|
ControllerControl("Mute 1", 1, 0, "button"),
|
||||||
|
ControllerControl("Mute 2", 4, 0, "button"),
|
||||||
|
ControllerControl("Mute 3", 7, 0, "button"),
|
||||||
|
ControllerControl("Mute 4", 10, 0, "button"),
|
||||||
|
ControllerControl("Mute 5", 13, 0, "button"),
|
||||||
|
ControllerControl("Mute 6", 16, 0, "button"),
|
||||||
|
ControllerControl("Mute 7", 19, 0, "button"),
|
||||||
|
ControllerControl("Mute 8", 22, 0, "button"),
|
||||||
|
ControllerControl("Solo 1", 2, 0, "button"),
|
||||||
|
ControllerControl("Solo 2", 3, 0, "button"),
|
||||||
|
ControllerControl("Solo 3", 8, 0, "button"),
|
||||||
|
ControllerControl("Solo 4", 9, 0, "button"),
|
||||||
|
ControllerControl("Solo 5", 14, 0, "button"),
|
||||||
|
ControllerControl("Solo 6", 15, 0, "button"),
|
||||||
|
ControllerControl("Solo 7", 20, 0, "button"),
|
||||||
|
ControllerControl("Solo 8", 21, 0, "button"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="Arturia",
|
||||||
|
model="BeatStep",
|
||||||
|
usb_vendor_id=0x1C75,
|
||||||
|
usb_product_id=0x0208,
|
||||||
|
alsa_client_pattern="Arturia BeatStep",
|
||||||
|
num_faders=0,
|
||||||
|
num_knobs=17,
|
||||||
|
num_buttons=16,
|
||||||
|
has_transport=True,
|
||||||
|
midi_channel_count=1,
|
||||||
|
notes="17 endless encoders + 16 pads. Good for transport and parameter tweaking.",
|
||||||
|
controls=[
|
||||||
|
ControllerControl("Knob 1", 74, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 2", 71, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 3", 76, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 4", 77, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 5", 93, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 6", 73, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 7", 75, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 8", 79, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 9", 72, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 10", 80, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 11", 81, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 12", 82, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 13", 83, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 14", 84, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 15", 85, 0, "encoder"),
|
||||||
|
ControllerControl("Knob 16", 86, 0, "encoder"),
|
||||||
|
ControllerControl("Big Knob", 112, 0, "encoder"),
|
||||||
|
# Transport
|
||||||
|
ControllerControl("Play", 116, 0, "transport"),
|
||||||
|
ControllerControl("Stop", 117, 0, "transport"),
|
||||||
|
ControllerControl("Rec", 118, 0, "transport"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="Novation",
|
||||||
|
model="Launch Control XL",
|
||||||
|
usb_vendor_id=0x1235,
|
||||||
|
usb_product_id=0x0061,
|
||||||
|
alsa_client_pattern="Launch Control XL",
|
||||||
|
num_faders=8,
|
||||||
|
num_knobs=24,
|
||||||
|
num_buttons=16,
|
||||||
|
has_transport=False,
|
||||||
|
midi_channel_count=1,
|
||||||
|
notes="8 faders, 24 knobs (3 rows x 8), 16 buttons. Highly customisable.",
|
||||||
|
controls=[
|
||||||
|
ControllerControl("Fader 1", 77, 0, "fader"),
|
||||||
|
ControllerControl("Fader 2", 78, 0, "fader"),
|
||||||
|
ControllerControl("Fader 3", 79, 0, "fader"),
|
||||||
|
ControllerControl("Fader 4", 80, 0, "fader"),
|
||||||
|
ControllerControl("Fader 5", 81, 0, "fader"),
|
||||||
|
ControllerControl("Fader 6", 82, 0, "fader"),
|
||||||
|
ControllerControl("Fader 7", 83, 0, "fader"),
|
||||||
|
ControllerControl("Fader 8", 84, 0, "fader"),
|
||||||
|
# Knobs row 1
|
||||||
|
ControllerControl("Knob 1A", 13, 0, "knob"),
|
||||||
|
ControllerControl("Knob 2A", 14, 0, "knob"),
|
||||||
|
ControllerControl("Knob 3A", 15, 0, "knob"),
|
||||||
|
ControllerControl("Knob 4A", 16, 0, "knob"),
|
||||||
|
ControllerControl("Knob 5A", 17, 0, "knob"),
|
||||||
|
ControllerControl("Knob 6A", 18, 0, "knob"),
|
||||||
|
ControllerControl("Knob 7A", 19, 0, "knob"),
|
||||||
|
ControllerControl("Knob 8A", 20, 0, "knob"),
|
||||||
|
# Knobs row 2
|
||||||
|
ControllerControl("Knob 1B", 29, 0, "knob"),
|
||||||
|
ControllerControl("Knob 2B", 30, 0, "knob"),
|
||||||
|
ControllerControl("Knob 3B", 31, 0, "knob"),
|
||||||
|
ControllerControl("Knob 4B", 32, 0, "knob"),
|
||||||
|
ControllerControl("Knob 5B", 33, 0, "knob"),
|
||||||
|
ControllerControl("Knob 6B", 34, 0, "knob"),
|
||||||
|
ControllerControl("Knob 7B", 35, 0, "knob"),
|
||||||
|
ControllerControl("Knob 8B", 36, 0, "knob"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
ControllerProfile(
|
||||||
|
manufacturer="Korg",
|
||||||
|
model="nanoKONTROL2",
|
||||||
|
usb_vendor_id=0x0944,
|
||||||
|
usb_product_id=0x0117,
|
||||||
|
alsa_client_pattern="nanoKONTROL2",
|
||||||
|
num_faders=8,
|
||||||
|
num_knobs=8,
|
||||||
|
num_buttons=32,
|
||||||
|
has_transport=True,
|
||||||
|
midi_channel_count=1,
|
||||||
|
notes="Compact USB controller. 8 faders, 8 knobs, 24 buttons, transport. Class-compliant.",
|
||||||
|
controls=[
|
||||||
|
ControllerControl("Fader 1", 0, 0, "fader"),
|
||||||
|
ControllerControl("Fader 2", 1, 0, "fader"),
|
||||||
|
ControllerControl("Fader 3", 2, 0, "fader"),
|
||||||
|
ControllerControl("Fader 4", 3, 0, "fader"),
|
||||||
|
ControllerControl("Fader 5", 4, 0, "fader"),
|
||||||
|
ControllerControl("Fader 6", 5, 0, "fader"),
|
||||||
|
ControllerControl("Fader 7", 6, 0, "fader"),
|
||||||
|
ControllerControl("Fader 8", 7, 0, "fader"),
|
||||||
|
ControllerControl("Knob 1", 16, 0, "knob"),
|
||||||
|
ControllerControl("Knob 2", 17, 0, "knob"),
|
||||||
|
ControllerControl("Knob 3", 18, 0, "knob"),
|
||||||
|
ControllerControl("Knob 4", 19, 0, "knob"),
|
||||||
|
ControllerControl("Knob 5", 20, 0, "knob"),
|
||||||
|
ControllerControl("Knob 6", 21, 0, "knob"),
|
||||||
|
ControllerControl("Knob 7", 22, 0, "knob"),
|
||||||
|
ControllerControl("Knob 8", 23, 0, "knob"),
|
||||||
|
ControllerControl("Solo 1", 32, 0, "button"),
|
||||||
|
ControllerControl("Solo 2", 33, 0, "button"),
|
||||||
|
ControllerControl("Solo 3", 34, 0, "button"),
|
||||||
|
ControllerControl("Solo 4", 35, 0, "button"),
|
||||||
|
ControllerControl("Solo 5", 36, 0, "button"),
|
||||||
|
ControllerControl("Solo 6", 37, 0, "button"),
|
||||||
|
ControllerControl("Solo 7", 38, 0, "button"),
|
||||||
|
ControllerControl("Solo 8", 39, 0, "button"),
|
||||||
|
ControllerControl("Mute 1", 48, 0, "button"),
|
||||||
|
ControllerControl("Mute 2", 49, 0, "button"),
|
||||||
|
ControllerControl("Mute 3", 50, 0, "button"),
|
||||||
|
ControllerControl("Mute 4", 51, 0, "button"),
|
||||||
|
ControllerControl("Mute 5", 52, 0, "button"),
|
||||||
|
ControllerControl("Mute 6", 53, 0, "button"),
|
||||||
|
ControllerControl("Mute 7", 54, 0, "button"),
|
||||||
|
ControllerControl("Mute 8", 55, 0, "button"),
|
||||||
|
ControllerControl("Rec 1", 64, 0, "button"),
|
||||||
|
ControllerControl("Rec 2", 65, 0, "button"),
|
||||||
|
ControllerControl("Rec 3", 66, 0, "button"),
|
||||||
|
ControllerControl("Rec 4", 67, 0, "button"),
|
||||||
|
ControllerControl("Rec 5", 68, 0, "button"),
|
||||||
|
ControllerControl("Rec 6", 69, 0, "button"),
|
||||||
|
ControllerControl("Rec 7", 70, 0, "button"),
|
||||||
|
ControllerControl("Rec 8", 71, 0, "button"),
|
||||||
|
# Transport
|
||||||
|
ControllerControl("Play", 41, 0, "transport"),
|
||||||
|
ControllerControl("Stop", 42, 0, "transport"),
|
||||||
|
ControllerControl("Rec", 43, 0, "transport"),
|
||||||
|
ControllerControl("Rewind", 44, 0, "transport"),
|
||||||
|
ControllerControl("FFwd", 45, 0, "transport"),
|
||||||
|
ControllerControl("Loop", 46, 0, "transport"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_controller(usb_vid: int, usb_pid: int) -> ControllerProfile | None:
|
||||||
|
"""Look up a known controller by USB vendor/product IDs."""
|
||||||
|
for c in KNOWN_CONTROLLERS:
|
||||||
|
if c.usb_vendor_id == usb_vid and c.usb_product_id == usb_pid:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_controller_by_alsa(client_name: str) -> ControllerProfile | None:
|
||||||
|
"""Look up a known controller by ALSA client name substring match."""
|
||||||
|
name_upper = client_name.upper()
|
||||||
|
for c in KNOWN_CONTROLLERS:
|
||||||
|
if c.alsa_client_pattern and c.alsa_client_pattern.upper() in name_upper:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""USB MIDI device discovery, enumeration, and hotplug notification.
|
||||||
|
|
||||||
|
Uses udev for hotplug detection and pyudev for programmatic device
|
||||||
|
enumeration. Falls back to ALSA sequencer client listing if pyudev
|
||||||
|
is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MIDIDevice:
|
||||||
|
"""Represents a discovered USB MIDI device.
|
||||||
|
|
||||||
|
Populated from udev or ALSA sequencer enumeration.
|
||||||
|
"""
|
||||||
|
device_node: str # e.g. /dev/snd/midiC1D0 or /dev/midi1
|
||||||
|
manufacturer: str = ""
|
||||||
|
product: str = ""
|
||||||
|
serial: str = ""
|
||||||
|
usb_vendor_id: int = 0
|
||||||
|
usb_product_id: int = 0
|
||||||
|
alsa_client_id: int = -1 # ALSA sequencer client ID
|
||||||
|
alsa_client_name: str = ""
|
||||||
|
num_ports: int = 0
|
||||||
|
is_online: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
parts = []
|
||||||
|
if self.manufacturer:
|
||||||
|
parts.append(self.manufacturer)
|
||||||
|
if self.product:
|
||||||
|
parts.append(self.product)
|
||||||
|
if parts:
|
||||||
|
return " ".join(parts)
|
||||||
|
if self.alsa_client_name:
|
||||||
|
return self.alsa_client_name
|
||||||
|
return os.path.basename(self.device_node)
|
||||||
|
|
||||||
|
|
||||||
|
# ── udev-based discovery ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse_udev_device(dev) -> MIDIDevice | None:
|
||||||
|
"""Extract MIDIDevice from a pyudev Device object."""
|
||||||
|
try:
|
||||||
|
node = dev.device_node
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# USB parent info
|
||||||
|
vid, pid = 0, 0
|
||||||
|
manufacturer, product, serial = "", "", ""
|
||||||
|
try:
|
||||||
|
usb_dev = dev.find_parent("usb", "usb_device")
|
||||||
|
if usb_dev:
|
||||||
|
vid = int(usb_dev.get("ID_VENDOR_ID", "0"), 16)
|
||||||
|
pid = int(usb_dev.get("ID_MODEL_ID", "0"), 16)
|
||||||
|
manufacturer = usb_dev.get("ID_VENDOR_FROM_DATABASE", "") or usb_dev.get("ID_VENDOR", "")
|
||||||
|
product = usb_dev.get("ID_MODEL_FROM_DATABASE", "") or usb_dev.get("ID_MODEL", "")
|
||||||
|
serial = usb_dev.get("ID_SERIAL_SHORT", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return MIDIDevice(
|
||||||
|
device_node=node,
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
product=product,
|
||||||
|
serial=serial,
|
||||||
|
usb_vendor_id=vid,
|
||||||
|
usb_product_id=pid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_udev() -> list[MIDIDevice]:
|
||||||
|
"""Enumerate USB MIDI devices using pyudev.
|
||||||
|
|
||||||
|
Returns all raw MIDI device nodes (subsystem=sound with midi capability).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import pyudev
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("pyudev not available, falling back to ALSA enumeration")
|
||||||
|
return []
|
||||||
|
|
||||||
|
devices: list[MIDIDevice] = []
|
||||||
|
context = pyudev.Context()
|
||||||
|
|
||||||
|
for dev in context.list_devices(subsystem="sound"):
|
||||||
|
devtype = dev.get("DEVTYPE", "")
|
||||||
|
# ALSA rawmidi devices
|
||||||
|
if devtype == "rawmidi" or "midi" in dev.sys_name.lower():
|
||||||
|
d = _parse_udev_device(dev)
|
||||||
|
if d:
|
||||||
|
devices.append(d)
|
||||||
|
|
||||||
|
# Also check /dev/midi* devices
|
||||||
|
for dev in context.list_devices(subsystem="sound"):
|
||||||
|
if "midi" in dev.get("DEVNAME", "").lower():
|
||||||
|
d = _parse_udev_device(dev)
|
||||||
|
if d and d not in devices:
|
||||||
|
devices.append(d)
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
# ── ALSA sequencer-based discovery ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def discover_alsa_seq() -> list[MIDIDevice]:
|
||||||
|
"""Enumerate MIDI devices via ALSA sequencer clients.
|
||||||
|
|
||||||
|
Parses /proc/asound/seq/clients (no external deps).
|
||||||
|
"""
|
||||||
|
devices: list[MIDIDevice] = []
|
||||||
|
clients_path = "/proc/asound/seq/clients"
|
||||||
|
|
||||||
|
if not os.path.exists(clients_path):
|
||||||
|
return devices
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(clients_path) as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("Client"):
|
||||||
|
continue
|
||||||
|
# Format: "Client 128 : "USB MIDI Device" [type=kernel]"
|
||||||
|
parts = line.split(":", 1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
client_part = parts[0].strip()
|
||||||
|
if not client_part.startswith("Client "):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
client_id = int(client_part.split()[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name_part = parts[1].strip().strip('"')
|
||||||
|
# Extract port count from the bracket part
|
||||||
|
num_ports = 1
|
||||||
|
if "[" in name_part:
|
||||||
|
name_part, _ = name_part.rsplit("[", 1)
|
||||||
|
name_part = name_part.strip()
|
||||||
|
|
||||||
|
devices.append(MIDIDevice(
|
||||||
|
device_node=f"alsa:client:{client_id}",
|
||||||
|
alsa_client_id=client_id,
|
||||||
|
alsa_client_name=name_part,
|
||||||
|
num_ports=num_ports,
|
||||||
|
))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to read ALSA seq clients: %s", exc)
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def discover_all() -> list[MIDIDevice]:
|
||||||
|
"""Enumerate all available MIDI input devices.
|
||||||
|
|
||||||
|
Tries udev first (richer metadata), falls back to ALSA sequencer.
|
||||||
|
"""
|
||||||
|
devices = discover_udev()
|
||||||
|
if not devices:
|
||||||
|
devices = discover_alsa_seq()
|
||||||
|
|
||||||
|
# Enrich ALSA-only devices with seq client IDs
|
||||||
|
for dev in devices:
|
||||||
|
if dev.alsa_client_id < 0:
|
||||||
|
_enrich_alsa_id(dev)
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_alsa_id(dev: MIDIDevice) -> None:
|
||||||
|
"""Try to find ALSA sequencer client ID for a udev-discovered device."""
|
||||||
|
seq_devs = discover_alsa_seq()
|
||||||
|
for sd in seq_devs:
|
||||||
|
if sd.alsa_client_name and (
|
||||||
|
dev.product in sd.alsa_client_name
|
||||||
|
or dev.manufacturer in sd.alsa_client_name
|
||||||
|
):
|
||||||
|
dev.alsa_client_id = sd.alsa_client_id
|
||||||
|
dev.alsa_client_name = sd.alsa_client_name
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hotplug monitoring ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DeviceCallback = Callable[[MIDIDevice], None] # called on add
|
||||||
|
DeviceRemoveCallback = Callable[[str], None] # called with device_node on remove
|
||||||
|
|
||||||
|
|
||||||
|
class MIDIHotplugMonitor:
|
||||||
|
"""Monitors udev for USB MIDI device add/remove events.
|
||||||
|
|
||||||
|
Uses pyudev.Monitor when available; polls /proc/asound/seq/clients
|
||||||
|
as a fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._monitor = None
|
||||||
|
self._observer = None
|
||||||
|
self._on_add: list[DeviceCallback] = []
|
||||||
|
self._on_remove: list[DeviceRemoveCallback] = []
|
||||||
|
self._running = False
|
||||||
|
self._known_devices: dict[str, MIDIDevice] = {}
|
||||||
|
|
||||||
|
def on_add(self, callback: DeviceCallback) -> None:
|
||||||
|
self._on_add.append(callback)
|
||||||
|
|
||||||
|
def on_remove(self, callback: DeviceRemoveCallback) -> None:
|
||||||
|
self._on_remove.append(callback)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start monitoring for hotplug events (non-blocking)."""
|
||||||
|
self._running = True
|
||||||
|
try:
|
||||||
|
import pyudev
|
||||||
|
self._monitor = pyudev.Monitor.from_netlink(pyudev.Context())
|
||||||
|
self._monitor.filter_by(subsystem="sound")
|
||||||
|
import pyudev_monitor # noqa: F811
|
||||||
|
from pyudev import MonitorObserver
|
||||||
|
self._observer = MonitorObserver(self._monitor, self._handle_udev_event)
|
||||||
|
self._observer.start()
|
||||||
|
logger.info("MIDI hotplug monitor started (pyudev)")
|
||||||
|
except ImportError:
|
||||||
|
logger.info("pyudev not available; hotplug requires polling")
|
||||||
|
self._monitor = None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._observer:
|
||||||
|
self._observer.stop()
|
||||||
|
self._observer = None
|
||||||
|
logger.info("MIDI hotplug monitor stopped")
|
||||||
|
|
||||||
|
def _handle_udev_event(self, device) -> None:
|
||||||
|
action = device.get("ACTION", "")
|
||||||
|
if action == "add":
|
||||||
|
d = _parse_udev_device(device)
|
||||||
|
if d and d.device_node not in self._known_devices:
|
||||||
|
self._known_devices[d.device_node] = d
|
||||||
|
for cb in self._on_add:
|
||||||
|
try:
|
||||||
|
cb(d)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Hotplug add callback failed: %s", exc)
|
||||||
|
elif action == "remove":
|
||||||
|
node = device.get("DEVNAME", "")
|
||||||
|
if node:
|
||||||
|
self._known_devices.pop(node, None)
|
||||||
|
for cb in self._on_remove:
|
||||||
|
try:
|
||||||
|
cb(node)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Hotplug remove callback failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def poll_once(self) -> list[MIDIDevice]:
|
||||||
|
"""Poll-based check for new/removed devices. Returns newly added devices.
|
||||||
|
|
||||||
|
Call periodically (~1-2 Hz) as fallback when udev monitoring isn't available.
|
||||||
|
"""
|
||||||
|
current = discover_all()
|
||||||
|
current_nodes = {d.device_node for d in current}
|
||||||
|
known_nodes = set(self._known_devices.keys())
|
||||||
|
|
||||||
|
added = [d for d in current if d.device_node not in known_nodes]
|
||||||
|
removed = known_nodes - current_nodes
|
||||||
|
|
||||||
|
self._known_devices = {d.device_node: d for d in current}
|
||||||
|
|
||||||
|
for d in added:
|
||||||
|
for cb in self._on_add:
|
||||||
|
try:
|
||||||
|
cb(d)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Poll add callback failed: %s", exc)
|
||||||
|
|
||||||
|
for node in removed:
|
||||||
|
for cb in self._on_remove:
|
||||||
|
try:
|
||||||
|
cb(node)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Poll remove callback failed: %s", exc)
|
||||||
|
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
# ── Convenience ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def print_devices() -> None:
|
||||||
|
"""Print a human-readable device list to stdout."""
|
||||||
|
devices = discover_all()
|
||||||
|
if not devices:
|
||||||
|
print("No MIDI devices found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{'Node':<30} {'Name':<40} {'VID:PID':<12} {'ALSA':<8}")
|
||||||
|
print("-" * 90)
|
||||||
|
for d in devices:
|
||||||
|
vidpid = f"{d.usb_vendor_id:04x}:{d.usb_product_id:04x}" if d.usb_vendor_id else "-"
|
||||||
|
alsa = str(d.alsa_client_id) if d.alsa_client_id > 0 else "-"
|
||||||
|
name = d.display_name[:38]
|
||||||
|
print(f"{d.device_node:<30} {name:<40} {vidpid:<12} {alsa:<8}")
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""JACK MIDI bridge — create JACK MIDI ports for Carla consumption.
|
||||||
|
|
||||||
|
Bridges mapped MIDI events from the engine into JACK MIDI output ports.
|
||||||
|
Carla (or any JACK MIDI-aware application) can connect to these ports
|
||||||
|
and route MIDI to soft-synths, samplers, and effects.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
1. Direct JACK client (requires jack-client Python module)
|
||||||
|
2. ALSA sequencer bridge (uses a2jmidid for ALSA → JACK bridging)
|
||||||
|
|
||||||
|
The direct JACK client mode is preferred as it avoids the extra a2jmidid
|
||||||
|
daemon, but requires the `jack` Python package (pip install JACK-Client).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum number of JACK MIDI ports to create
|
||||||
|
DEFAULT_NUM_PORTS = 4
|
||||||
|
|
||||||
|
|
||||||
|
class JACKMIDIBridge:
|
||||||
|
"""Creates JACK MIDI output ports and writes MIDI events to them.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
bridge = JACKMIDIBridge(client_name="rpi-mixer")
|
||||||
|
bridge.start()
|
||||||
|
# ... after engine processes a mapping ...
|
||||||
|
bridge.send_midi(port_index=0, event=[0x90, 60, 100])
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client_name: str = "rpi-mixer",
|
||||||
|
num_ports: int = DEFAULT_NUM_PORTS,
|
||||||
|
port_name_prefix: str = "midi_out",
|
||||||
|
):
|
||||||
|
self.client_name = client_name
|
||||||
|
self.num_ports = num_ports
|
||||||
|
self.port_name_prefix = port_name_prefix
|
||||||
|
self._client = None
|
||||||
|
self._ports: list[Any] = []
|
||||||
|
self._alsa_client = None
|
||||||
|
self._alsa_ports: list[int] = []
|
||||||
|
self._running = False
|
||||||
|
self._activated = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Activate JACK client and register MIDI output ports.
|
||||||
|
|
||||||
|
Returns True on success, False if JACK is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import jack
|
||||||
|
self._client = jack.Client(self.client_name)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning(
|
||||||
|
"jack-client Python module not available. "
|
||||||
|
"Install with: pip install JACK-Client. "
|
||||||
|
"Falling back to ALSA sequencer output."
|
||||||
|
)
|
||||||
|
return self._start_alsa_fallback()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to create JACK client: %s", exc)
|
||||||
|
return self._start_alsa_fallback()
|
||||||
|
|
||||||
|
# Register MIDI output ports
|
||||||
|
with self._lock:
|
||||||
|
for i in range(self.num_ports):
|
||||||
|
port_name = f"{self.port_name_prefix}_{i}"
|
||||||
|
try:
|
||||||
|
port = self._client.midi_outports.register(port_name)
|
||||||
|
self._ports.append(port)
|
||||||
|
logger.info("Registered JACK MIDI port: %s:%s", self.client_name, port_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to register port %s: %s", port_name, exc)
|
||||||
|
|
||||||
|
if not self._ports:
|
||||||
|
logger.error("No JACK MIDI ports registered")
|
||||||
|
return self._start_alsa_fallback()
|
||||||
|
|
||||||
|
# Activate the client
|
||||||
|
try:
|
||||||
|
self._client.activate()
|
||||||
|
self._activated = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to activate JACK client: %s", exc)
|
||||||
|
return self._start_alsa_fallback()
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
logger.info("JACK MIDI bridge active: %d ports on '%s'", len(self._ports), self.client_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _start_alsa_fallback(self) -> bool:
|
||||||
|
"""Fallback: use ALSA sequencer output (bridged via a2jmidid).
|
||||||
|
|
||||||
|
This creates ALSA sequencer ports that a2jmidid automatically
|
||||||
|
bridges to JACK MIDI. No direct JACK dependency needed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import alsaseq
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("alsaseq not available either — MIDI output disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._alsa_client = alsaseq.SequencerClient(
|
||||||
|
self.client_name,
|
||||||
|
client_type=alsaseq.SEQ_CLIENT_DUPLEX
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
for i in range(self.num_ports):
|
||||||
|
port_name = f"{self.port_name_prefix}_{i}"
|
||||||
|
port_id = self._alsa_client.create_port(
|
||||||
|
port_name,
|
||||||
|
caps=alsaseq.SEQ_PORT_CAP_WRITE | alsaseq.SEQ_PORT_CAP_SUBS_WRITE,
|
||||||
|
type=alsaseq.SEQ_PORT_TYPE_MIDI_GENERIC,
|
||||||
|
)
|
||||||
|
self._alsa_ports.append(port_id)
|
||||||
|
logger.info("Created ALSA seq port: %s:%s (id=%d)", self.client_name, port_name, port_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to create ALSA sequencer client: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
logger.info("ALSA sequencer MIDI bridge active (connect via a2jmidid)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Deactivate the JACK client and clean up ports."""
|
||||||
|
self._running = False
|
||||||
|
if self._client and self._activated:
|
||||||
|
try:
|
||||||
|
self._client.deactivate()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Error deactivating JACK client: %s", exc)
|
||||||
|
self._client = None
|
||||||
|
self._ports.clear()
|
||||||
|
self._activated = False
|
||||||
|
logger.info("JACK MIDI bridge stopped")
|
||||||
|
|
||||||
|
# ── MIDI output ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_midi(self, port_index: int, event: bytes | list[int], timestamp: float = 0.0) -> bool:
|
||||||
|
"""Send a raw MIDI event to a JACK MIDI port.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port_index: Which output port (0..num_ports-1).
|
||||||
|
event: Raw MIDI bytes (e.g., [0x90, 0x3C, 0x64] for note on).
|
||||||
|
timestamp: JACK frame time offset (0.0 = as soon as possible).
|
||||||
|
|
||||||
|
Returns True if the event was queued successfully.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(event, list):
|
||||||
|
event = bytes(event)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if port_index < 0 or port_index >= len(self._ports):
|
||||||
|
logger.debug("Invalid port index %d (have %d ports)", port_index, len(self._ports))
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
port = self._ports[port_index]
|
||||||
|
port.write_midi_event(timestamp, event)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("MIDI write to port %d failed: %s", port_index, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_midi_burst(self, port_index: int, events: list[bytes | list[int]]) -> int:
|
||||||
|
"""Send multiple MIDI events atomically (same JACK cycle).
|
||||||
|
|
||||||
|
Returns number of events successfully queued.
|
||||||
|
"""
|
||||||
|
sent = 0
|
||||||
|
for event in events:
|
||||||
|
if self.send_midi(port_index, event):
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|
||||||
|
# ── Port info ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port_count(self) -> int:
|
||||||
|
return len(self._ports)
|
||||||
|
|
||||||
|
def port_name(self, index: int) -> str:
|
||||||
|
if 0 <= index < len(self._ports):
|
||||||
|
return f"{self.client_name}:{self.port_name_prefix}_{index}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self._running and self._activated
|
||||||
|
|
||||||
|
|
||||||
|
# ── Utility: generate common MIDI messages ──────────────────────────────────
|
||||||
|
|
||||||
|
def midi_note_on(channel: int, note: int, velocity: int = 100) -> bytes:
|
||||||
|
"""Build a Note On message (3 bytes)."""
|
||||||
|
return bytes([0x90 | (channel & 0x0F), note & 0x7F, velocity & 0x7F])
|
||||||
|
|
||||||
|
|
||||||
|
def midi_note_off(channel: int, note: int) -> bytes:
|
||||||
|
"""Build a Note Off message (3 bytes)."""
|
||||||
|
return bytes([0x80 | (channel & 0x0F), note & 0x7F, 0])
|
||||||
|
|
||||||
|
|
||||||
|
def midi_cc(channel: int, controller: int, value: int) -> bytes:
|
||||||
|
"""Build a Control Change message (3 bytes)."""
|
||||||
|
return bytes([0xB0 | (channel & 0x0F), controller & 0x7F, value & 0x7F])
|
||||||
|
|
||||||
|
|
||||||
|
def midi_pitch_bend(channel: int, value: int) -> bytes:
|
||||||
|
"""Build a Pitch Bend message (3 bytes, 14-bit value)."""
|
||||||
|
value = max(0, min(16383, value))
|
||||||
|
lsb = value & 0x7F
|
||||||
|
msb = (value >> 7) & 0x7F
|
||||||
|
return bytes([0xE0 | (channel & 0x0F), lsb, msb])
|
||||||
|
|
||||||
|
|
||||||
|
def midi_program_change(channel: int, program: int) -> bytes:
|
||||||
|
"""Build a Program Change message (2 bytes)."""
|
||||||
|
return bytes([0xC0 | (channel & 0x0F), program & 0x7F])
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""MIDI mapping persistence — save/load mappings as JSON.
|
||||||
|
|
||||||
|
Mappings are stored per-session (a "session" is one mapping configuration
|
||||||
|
file). The file format is designed for human readability and version control
|
||||||
|
friendliness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .types import MIDIMapping, MIDIMessageType, ParameterType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_MAPPINGS_DIR = Path.home() / ".config" / "rpi-mixer" / "mappings"
|
||||||
|
DEFAULT_SESSION_NAME = "default"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Serialisation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def mapping_to_dict(m: MIDIMapping) -> dict[str, Any]:
|
||||||
|
"""Serialise one mapping to a plain dict."""
|
||||||
|
return {
|
||||||
|
"midi": {
|
||||||
|
"type": m.msg_type.name,
|
||||||
|
"channel": m.channel,
|
||||||
|
"controller": m.controller,
|
||||||
|
"is_nrpn": m.is_nrpn,
|
||||||
|
"nrpn_number": m.nrpn_number,
|
||||||
|
"source_device": m.source_device or None,
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"parameter": m.param_type.value,
|
||||||
|
"channel": m.param_channel,
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"midi_min": m.midi_min,
|
||||||
|
"midi_max": m.midi_max,
|
||||||
|
"param_min": m.param_min,
|
||||||
|
"param_max": m.param_max,
|
||||||
|
"invert": m.invert,
|
||||||
|
"curve": m.curve,
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"label": m.label or None,
|
||||||
|
"enabled": m.enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mapping_from_dict(d: dict[str, Any]) -> MIDIMapping:
|
||||||
|
"""Deserialise one mapping from a dict."""
|
||||||
|
midi = d.get("midi", {})
|
||||||
|
target = d.get("target", {})
|
||||||
|
value = d.get("value", {})
|
||||||
|
meta = d.get("meta", {})
|
||||||
|
|
||||||
|
msg_type = MIDIMessageType[midi.get("type", "CONTROL_CHANGE")]
|
||||||
|
|
||||||
|
return MIDIMapping(
|
||||||
|
msg_type=msg_type,
|
||||||
|
channel=midi.get("channel", 0),
|
||||||
|
controller=midi.get("controller", 0),
|
||||||
|
is_nrpn=midi.get("is_nrpn", False),
|
||||||
|
nrpn_number=midi.get("nrpn_number", 0),
|
||||||
|
source_device=midi.get("source_device") or "",
|
||||||
|
param_type=ParameterType(target.get("parameter", "volume")),
|
||||||
|
param_channel=target.get("channel", -1),
|
||||||
|
midi_min=value.get("midi_min", 0),
|
||||||
|
midi_max=value.get("midi_max", 127),
|
||||||
|
param_min=value.get("param_min", 0.0),
|
||||||
|
param_max=value.get("param_max", 1.0),
|
||||||
|
invert=value.get("invert", False),
|
||||||
|
curve=value.get("curve", "linear"),
|
||||||
|
label=meta.get("label") or "",
|
||||||
|
enabled=meta.get("enabled", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session file I/O ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _session_path(session_name: str = DEFAULT_SESSION_NAME) -> Path:
|
||||||
|
"""Resolve the JSON file path for a session."""
|
||||||
|
return DEFAULT_MAPPINGS_DIR / f"{session_name}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def save_mappings(
|
||||||
|
mappings: list[MIDIMapping],
|
||||||
|
session_name: str = DEFAULT_SESSION_NAME,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Persist mapping list to JSON.
|
||||||
|
|
||||||
|
Returns the path written.
|
||||||
|
"""
|
||||||
|
path = _session_path(session_name)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
doc: dict[str, Any] = {
|
||||||
|
"version": 1,
|
||||||
|
"session": session_name,
|
||||||
|
"updated": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"metadata": metadata or {},
|
||||||
|
"mappings": [mapping_to_dict(m) for m in mappings],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Atomic write: write to temp then rename
|
||||||
|
tmp = path.with_suffix(".tmp")
|
||||||
|
with open(tmp, "w") as fh:
|
||||||
|
json.dump(doc, fh, indent=2, sort_keys=True)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
logger.info("Saved %d mappings to %s", len(mappings), path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def load_mappings(session_name: str = DEFAULT_SESSION_NAME) -> list[MIDIMapping]:
|
||||||
|
"""Load mapping list from JSON.
|
||||||
|
|
||||||
|
Returns empty list if the file doesn't exist.
|
||||||
|
"""
|
||||||
|
path = _session_path(session_name)
|
||||||
|
if not path.exists():
|
||||||
|
logger.info("No mapping file at %s", path)
|
||||||
|
return []
|
||||||
|
|
||||||
|
with open(path) as fh:
|
||||||
|
doc = json.load(fh)
|
||||||
|
|
||||||
|
version = doc.get("version", 0)
|
||||||
|
if version != 1:
|
||||||
|
logger.warning("Unknown mapping format version %d in %s", version, path)
|
||||||
|
|
||||||
|
raw = doc.get("mappings", [])
|
||||||
|
mappings = [mapping_from_dict(m) for m in raw]
|
||||||
|
logger.info("Loaded %d mappings from %s", len(mappings), path)
|
||||||
|
return mappings
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions() -> list[str]:
|
||||||
|
"""Return names of all saved mapping sessions."""
|
||||||
|
if not DEFAULT_MAPPINGS_DIR.exists():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
p.stem for p in DEFAULT_MAPPINGS_DIR.glob("*.json")
|
||||||
|
if p.stem != "default" or True # Include default
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session(session_name: str) -> bool:
|
||||||
|
"""Delete a mapping session file. Returns True if successful."""
|
||||||
|
path = _session_path(session_name)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
logger.info("Deleted mapping session: %s", session_name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""MIDI Clock synchronisation.
|
||||||
|
|
||||||
|
Receives MIDI clock (timing clock) messages and derives tempo (BPM)
|
||||||
|
from the timing pulse stream. MIDI clock sends 24 pulses per quarter
|
||||||
|
note (PPQN). Also handles START, STOP, CONTINUE for transport sync.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- BPM detection from clock pulse timing
|
||||||
|
- Tempo averaging (moving window) to smooth jitter
|
||||||
|
- Song Position Pointer (SPP) for locate
|
||||||
|
- Transport state tracking (stopped, playing)
|
||||||
|
- MIDI clock output for master clock generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PPQN = 24 # MIDI clock pulses per quarter note
|
||||||
|
DEFAULT_WINDOW = 96 # 4 quarter notes at 24 PPQN
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MIDIClockState:
|
||||||
|
"""Current state of MIDI clock reception."""
|
||||||
|
running: bool = False # Transport is playing
|
||||||
|
bpm: float = 120.0 # Current estimated tempo
|
||||||
|
bpm_raw: float = 120.0 # Raw instantaneous BPM (no smoothing)
|
||||||
|
bpm_stable: bool = False # Tempo estimate has converged
|
||||||
|
song_position: float = 0.0 # Current song position in beats
|
||||||
|
last_pulse_time: float = 0.0 # monotonic timestamp of last clock pulse
|
||||||
|
pulse_count: int = 0 # Total clock pulses received
|
||||||
|
ppqn: int = PPQN
|
||||||
|
|
||||||
|
# Moving window for tempo averaging
|
||||||
|
_pulse_intervals: collections.deque = field(default_factory=lambda: collections.deque(maxlen=DEFAULT_WINDOW))
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.running = False
|
||||||
|
self.bpm = 120.0
|
||||||
|
self.bpm_raw = 120.0
|
||||||
|
self.bpm_stable = False
|
||||||
|
self.song_position = 0.0
|
||||||
|
self.last_pulse_time = 0.0
|
||||||
|
self.pulse_count = 0
|
||||||
|
self._pulse_intervals.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class MIDIClock:
|
||||||
|
"""MIDI clock receiver and tempo estimator.
|
||||||
|
|
||||||
|
Feed clock messages via process_message(). BPM is derived from
|
||||||
|
the inter-pulse timing using a moving average window.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
clock = MIDIClock()
|
||||||
|
clock.on_tempo_change = lambda bpm: print(f"Tempo: {bpm:.1f}")
|
||||||
|
clock.on_transport = lambda event: print(f"Transport: {event}")
|
||||||
|
# Feed messages:
|
||||||
|
clock.process_message(msg)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, window_size: int = DEFAULT_WINDOW):
|
||||||
|
self.state = MIDIClockState()
|
||||||
|
self.state._pulse_intervals = collections.deque(maxlen=window_size)
|
||||||
|
self._on_tempo_change: list = []
|
||||||
|
self._on_transport: list = []
|
||||||
|
self._on_beat: list = [] # Fires every quarter note (every 24 pulses)
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_tempo_change(self, callback):
|
||||||
|
"""callback(bpm: float, raw_bpm: float, stable: bool)."""
|
||||||
|
self._on_tempo_change.append(callback)
|
||||||
|
|
||||||
|
def on_transport(self, callback):
|
||||||
|
"""callback(event: str) where event is 'start', 'stop', 'continue'."""
|
||||||
|
self._on_transport.append(callback)
|
||||||
|
|
||||||
|
def on_beat(self, callback):
|
||||||
|
"""callback(beat: int) — fires every quarter note (24 pulses)."""
|
||||||
|
self._on_beat.append(callback)
|
||||||
|
|
||||||
|
# ── Message processing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def process_message(self, status_byte: int) -> None:
|
||||||
|
"""Process a MIDI system realtime message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_byte: The raw MIDI status byte (0xF8–0xFC).
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
if status_byte == 0xF8: # Timing Clock
|
||||||
|
self._handle_clock(now)
|
||||||
|
elif status_byte == 0xFA: # Start
|
||||||
|
self._handle_start(now)
|
||||||
|
elif status_byte == 0xFB: # Continue
|
||||||
|
self._handle_continue(now)
|
||||||
|
elif status_byte == 0xFC: # Stop
|
||||||
|
self._handle_stop()
|
||||||
|
elif status_byte == 0xF2: # Song Position Pointer (handled separately)
|
||||||
|
pass # SPP requires two data bytes, handled upstream
|
||||||
|
|
||||||
|
def process_song_position(self, beats: int) -> None:
|
||||||
|
"""Handle Song Position Pointer (SPP).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
beats: Song position in MIDI beats (0–16383, where 1 beat = 6 clock pulses).
|
||||||
|
"""
|
||||||
|
self.state.song_position = beats
|
||||||
|
logger.debug("Song position: %.1f beats", beats)
|
||||||
|
|
||||||
|
# ── Internal handlers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_clock(self, now: float) -> None:
|
||||||
|
s = self.state
|
||||||
|
s.pulse_count += 1
|
||||||
|
|
||||||
|
if s.last_pulse_time > 0:
|
||||||
|
interval = now - s.last_pulse_time
|
||||||
|
if interval > 0 and interval < 2.0: # Sanity: ignore >2s gaps
|
||||||
|
s._pulse_intervals.append(interval)
|
||||||
|
|
||||||
|
if len(s._pulse_intervals) >= 4: # Need at least a few pulses
|
||||||
|
avg_interval = sum(s._pulse_intervals) / len(s._pulse_intervals)
|
||||||
|
if avg_interval > 0:
|
||||||
|
# 24 pulses per quarter note, BPM = 60 / (seconds per quarter note)
|
||||||
|
# seconds per quarter note = avg_interval * 24
|
||||||
|
s.bpm_raw = 60.0 / (avg_interval * s.ppqn)
|
||||||
|
s.bpm = s.bpm_raw
|
||||||
|
s.bpm_stable = len(s._pulse_intervals) >= ((s._pulse_intervals.maxlen or DEFAULT_WINDOW) // 2)
|
||||||
|
|
||||||
|
s.last_pulse_time = now
|
||||||
|
|
||||||
|
# Beat callback every 24 pulses
|
||||||
|
if s.pulse_count % s.ppqn == 0:
|
||||||
|
beat = s.pulse_count // s.ppqn
|
||||||
|
for cb in self._on_beat:
|
||||||
|
try:
|
||||||
|
cb(beat)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Beat callback failed: %s", exc)
|
||||||
|
|
||||||
|
# Fire tempo change on beat boundaries (not every pulse)
|
||||||
|
if s.bpm_stable:
|
||||||
|
for cb in self._on_tempo_change:
|
||||||
|
try:
|
||||||
|
cb(s.bpm, s.bpm_raw, s.bpm_stable)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Tempo callback failed: %s", exc)
|
||||||
|
|
||||||
|
def _handle_start(self, now: float) -> None:
|
||||||
|
self.state.running = True
|
||||||
|
self.state.song_position = 0.0
|
||||||
|
self.state.pulse_count = 0
|
||||||
|
self.state.last_pulse_time = now
|
||||||
|
self.state._pulse_intervals.clear()
|
||||||
|
logger.info("MIDI START — transport running")
|
||||||
|
for cb in self._on_transport:
|
||||||
|
try:
|
||||||
|
cb("start")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Transport callback failed: %s", exc)
|
||||||
|
|
||||||
|
def _handle_continue(self, now: float) -> None:
|
||||||
|
self.state.running = True
|
||||||
|
self.state.last_pulse_time = now
|
||||||
|
logger.info("MIDI CONTINUE — transport running")
|
||||||
|
for cb in self._on_transport:
|
||||||
|
try:
|
||||||
|
cb("continue")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Transport callback failed: %s", exc)
|
||||||
|
|
||||||
|
def _handle_stop(self) -> None:
|
||||||
|
self.state.running = False
|
||||||
|
logger.info("MIDI STOP — transport stopped")
|
||||||
|
for cb in self._on_transport:
|
||||||
|
try:
|
||||||
|
cb("stop")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Transport callback failed: %s", exc)
|
||||||
|
|
||||||
|
# ── Master clock output (for generating MIDI clock) ──────────────────
|
||||||
|
|
||||||
|
def generate_clock_pulse(self) -> float | None:
|
||||||
|
"""Generate next clock pulse interval for master output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Seconds until next pulse, or None if transport not running.
|
||||||
|
"""
|
||||||
|
if not self.state.running:
|
||||||
|
return None
|
||||||
|
if self.state.bpm <= 0:
|
||||||
|
return None
|
||||||
|
# Seconds per quarter note = 60 / BPM
|
||||||
|
# Interval per pulse = (60 / BPM) / PPQN
|
||||||
|
return (60.0 / self.state.bpm) / self.state.ppqn
|
||||||
|
|
||||||
|
# ── Query ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tempo(self) -> float:
|
||||||
|
return self.state.bpm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self.state.running
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.state.reset()
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Core MIDI processing engine.
|
||||||
|
|
||||||
|
Reads MIDI events from input ports (ALSA sequencer or rtmidi),
|
||||||
|
applies mapping rules, dispatches parameter changes downstream,
|
||||||
|
and optionally forwards mapped MIDI to output ports.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
USB MIDI device → ALSA seq / rtmidi input → MappingEngine
|
||||||
|
→ ParameterRegistry callbacks (DSP / UI)
|
||||||
|
→ [optional] JACK MIDI / ALSA seq output ports
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
MIDIMessage,
|
||||||
|
MIDIMessageType,
|
||||||
|
MIDIMapping,
|
||||||
|
ParameterType,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Callback shape: (mapping: MIDIMapping, scaled_value: float, raw_msg: MIDIMessage) -> None
|
||||||
|
MappingCallback = Callable[[MIDIMapping, float, MIDIMessage], None]
|
||||||
|
|
||||||
|
|
||||||
|
class MIDIEngine:
|
||||||
|
"""Core MIDI routing and mapping engine.
|
||||||
|
|
||||||
|
Maintains a list of active mappings, reads MIDI events from one or
|
||||||
|
more backends, matches events against mappings, and dispatches
|
||||||
|
scaled parameter values to registered callbacks.
|
||||||
|
|
||||||
|
Thread-safe for concurrent event input and mapping updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "rpi-mixer-midi"):
|
||||||
|
self.name = name
|
||||||
|
self._mappings: list[MIDIMapping] = []
|
||||||
|
self._mappings_lock = threading.Lock()
|
||||||
|
self._callbacks: list[MappingCallback] = []
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# NRPN state machine per (source_device, channel)
|
||||||
|
# NRPN uses CC 99 (MSB), CC 98 (LSB), CC 6 (data MSB), CC 38 (data LSB), CC 96 (increment), CC 97 (decrement)
|
||||||
|
self._nrpn_state: dict[tuple[str, int], dict[str, int | None]] = defaultdict(
|
||||||
|
lambda: {"msb": None, "lsb": None, "data_msb": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
self.event_count: int = 0
|
||||||
|
self.mapped_count: int = 0
|
||||||
|
self.uptime_start: float = 0.0
|
||||||
|
|
||||||
|
# ── Mapping CRUD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_mappings(self, mappings: list[MIDIMapping]) -> None:
|
||||||
|
"""Replace the entire mapping table atomically."""
|
||||||
|
with self._mappings_lock:
|
||||||
|
self._mappings = list(mappings)
|
||||||
|
logger.info("Loaded %d mappings", len(mappings))
|
||||||
|
|
||||||
|
def add_mapping(self, mapping: MIDIMapping) -> None:
|
||||||
|
with self._mappings_lock:
|
||||||
|
self._mappings.append(mapping)
|
||||||
|
logger.info("Added mapping: %s", mapping.label or f"CC{mapping.controller}→{mapping.param_type.value}")
|
||||||
|
|
||||||
|
def remove_mapping(self, mapping: MIDIMapping) -> bool:
|
||||||
|
with self._mappings_lock:
|
||||||
|
for i, m in enumerate(self._mappings):
|
||||||
|
if (
|
||||||
|
m.msg_type == mapping.msg_type
|
||||||
|
and m.channel == mapping.channel
|
||||||
|
and m.controller == mapping.controller
|
||||||
|
and m.param_type == mapping.param_type
|
||||||
|
and m.param_channel == mapping.param_channel
|
||||||
|
):
|
||||||
|
self._mappings.pop(i)
|
||||||
|
logger.info("Removed mapping: %s", mapping.label or f"CC{mapping.controller}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_mappings(self) -> list[MIDIMapping]:
|
||||||
|
with self._mappings_lock:
|
||||||
|
return list(self._mappings)
|
||||||
|
|
||||||
|
def find_mappings_for(self, param_type: ParameterType, channel: int = -1) -> list[MIDIMapping]:
|
||||||
|
"""Return all mappings targeting a specific parameter."""
|
||||||
|
with self._mappings_lock:
|
||||||
|
return [m for m in self._mappings if m.param_type == param_type and m.param_channel == channel]
|
||||||
|
|
||||||
|
# ── Callback management ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_mapped(self, callback: MappingCallback) -> None:
|
||||||
|
"""Register a callback for mapped parameter changes."""
|
||||||
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_callback(self, callback: MappingCallback) -> None:
|
||||||
|
if callback in self._callbacks:
|
||||||
|
self._callbacks.remove(callback)
|
||||||
|
|
||||||
|
# ── Event processing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def process_event(self, msg: MIDIMessage) -> list[tuple[MIDIMapping, float]]:
|
||||||
|
"""Process one MIDI event through the mapping table.
|
||||||
|
|
||||||
|
Handles NRPN state machine transparently: when a complete NRPN
|
||||||
|
value arrives (via CC 6/38/96/97 after parameter select via
|
||||||
|
CC 98/99), constructs a synthetic MIDIMessage with is_nrpn=True
|
||||||
|
and the full 14-bit value.
|
||||||
|
|
||||||
|
Returns list of (mapping, scaled_value) for all matched mappings.
|
||||||
|
"""
|
||||||
|
self.event_count += 1
|
||||||
|
results: list[tuple[MIDIMapping, float]] = []
|
||||||
|
|
||||||
|
# Handle NRPN state machine
|
||||||
|
resolved = self._handle_nrpn_state(msg)
|
||||||
|
if resolved is None:
|
||||||
|
return results # Intermediate NRPN message, not a value
|
||||||
|
msg = resolved
|
||||||
|
|
||||||
|
with self._mappings_lock:
|
||||||
|
mappings = list(self._mappings)
|
||||||
|
|
||||||
|
for mapping in mappings:
|
||||||
|
if not mapping.enabled:
|
||||||
|
continue
|
||||||
|
if mapping.matches(msg) and msg.value is not None:
|
||||||
|
scaled = mapping.scale_value(msg.value)
|
||||||
|
results.append((mapping, scaled))
|
||||||
|
self.mapped_count += 1
|
||||||
|
|
||||||
|
# Fire callbacks
|
||||||
|
for mapping, scaled in results:
|
||||||
|
for cb in self._callbacks:
|
||||||
|
try:
|
||||||
|
cb(mapping, scaled, msg)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Mapping callback failed: %s", exc)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _handle_nrpn_state(self, msg: MIDIMessage) -> MIDIMessage | None:
|
||||||
|
"""Track NRPN parameter selection and return synthetic value messages.
|
||||||
|
|
||||||
|
NRPN protocol:
|
||||||
|
1. CC 99 (NRPN MSB) — parameter number high 7 bits
|
||||||
|
2. CC 98 (NRPN LSB) — parameter number low 7 bits
|
||||||
|
3. CC 6 (Data Entry MSB) — value high 7 bits
|
||||||
|
4. CC 38 (Data Entry LSB) — value low 7 bits (optional)
|
||||||
|
Or CC 96 (Increment) / CC 97 (Decrement) for relative changes
|
||||||
|
|
||||||
|
Returns None for intermediate messages (99, 98, 38 without data MSB).
|
||||||
|
Returns a synthetic MIDIMessage with is_nrpn=True when a complete
|
||||||
|
value arrives.
|
||||||
|
"""
|
||||||
|
if msg.msg_type != MIDIMessageType.CONTROL_CHANGE or msg.controller is None:
|
||||||
|
return msg # Not a CC message
|
||||||
|
|
||||||
|
key = (msg.source_device, msg.channel)
|
||||||
|
state = self._nrpn_state[key]
|
||||||
|
|
||||||
|
if msg.controller == 99: # NRPN MSB
|
||||||
|
state["msb"] = msg.value
|
||||||
|
return None
|
||||||
|
elif msg.controller == 98: # NRPN LSB
|
||||||
|
state["lsb"] = msg.value
|
||||||
|
return None
|
||||||
|
elif msg.controller == 6: # Data Entry MSB
|
||||||
|
# A value just arrived — build synthetic NRPN message
|
||||||
|
nrpn_msb = state.get("msb")
|
||||||
|
nrpn_lsb = state.get("lsb")
|
||||||
|
if nrpn_msb is None or nrpn_lsb is None:
|
||||||
|
# NRPN parameter not fully selected yet
|
||||||
|
state["data_msb"] = msg.value
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Combine MSB + LSB for 14-bit value (if CC 38 follows, it refines LSB)
|
||||||
|
value = (msg.value << 7) if msg.value is not None else 0
|
||||||
|
state["data_msb"] = msg.value
|
||||||
|
return None # Wait for CC 38 or treat as 7-bit
|
||||||
|
elif msg.controller == 38: # Data Entry LSB
|
||||||
|
# Completes the 14-bit value
|
||||||
|
nrpn_msb = state.get("msb")
|
||||||
|
nrpn_lsb = state.get("lsb")
|
||||||
|
data_msb = state.get("data_msb")
|
||||||
|
if nrpn_msb is None or nrpn_lsb is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = ((data_msb or 0) << 7) | (msg.value or 0)
|
||||||
|
return MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=msg.channel,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_msb=nrpn_msb,
|
||||||
|
nrpn_lsb=nrpn_lsb,
|
||||||
|
value=value,
|
||||||
|
timestamp=msg.timestamp,
|
||||||
|
source_device=msg.source_device,
|
||||||
|
)
|
||||||
|
elif msg.controller == 96: # Data Increment
|
||||||
|
nrpn_msb = state.get("msb")
|
||||||
|
nrpn_lsb = state.get("lsb")
|
||||||
|
if nrpn_msb is None or nrpn_lsb is None:
|
||||||
|
return None
|
||||||
|
return MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=msg.channel,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_msb=nrpn_msb,
|
||||||
|
nrpn_lsb=nrpn_lsb,
|
||||||
|
value=1, # Increment by 1
|
||||||
|
timestamp=msg.timestamp,
|
||||||
|
source_device=msg.source_device,
|
||||||
|
)
|
||||||
|
elif msg.controller == 97: # Data Decrement
|
||||||
|
nrpn_msb = state.get("msb")
|
||||||
|
nrpn_lsb = state.get("lsb")
|
||||||
|
if nrpn_msb is None or nrpn_lsb is None:
|
||||||
|
return None
|
||||||
|
return MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=msg.channel,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_msb=nrpn_msb,
|
||||||
|
nrpn_lsb=nrpn_lsb,
|
||||||
|
value=-1, # Decrement by 1
|
||||||
|
timestamp=msg.timestamp,
|
||||||
|
source_device=msg.source_device,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Regular CC — reset NRPN state for this channel
|
||||||
|
state["msb"] = None
|
||||||
|
state["lsb"] = None
|
||||||
|
state["data_msb"] = None
|
||||||
|
return msg # Regular CC, pass through
|
||||||
|
|
||||||
|
return msg # Shouldn't reach here, but just in case
|
||||||
|
|
||||||
|
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._running = True
|
||||||
|
self.uptime_start = time.monotonic()
|
||||||
|
logger.info("MIDI engine started")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
logger.info("MIDI engine stopped (events: %d, mapped: %d)", self.event_count, self.mapped_count)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict:
|
||||||
|
uptime = time.monotonic() - self.uptime_start if self.uptime_start else 0
|
||||||
|
return {
|
||||||
|
"events_total": self.event_count,
|
||||||
|
"events_mapped": self.mapped_count,
|
||||||
|
"mappings_active": len(self._mappings),
|
||||||
|
"uptime_seconds": round(uptime, 1),
|
||||||
|
"events_per_second": round(self.event_count / uptime, 1) if uptime > 0 else 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""MIDI Learn Mode — state machine for interactive mapping assignment.
|
||||||
|
|
||||||
|
Learn mode listens for incoming MIDI messages and associates the
|
||||||
|
last-received message pattern with a user-selected mixer parameter.
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
IDLE → LISTENING (user selects parameter to learn) → LISTENING (waits for MIDI)
|
||||||
|
→ CAPTURED (message received, awaiting confirmation)
|
||||||
|
→ IDLE (confirmed/discarded)
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Single learn: assign one CC to one parameter
|
||||||
|
- Batch learn: rapid-fire assignment (activate, wiggle knob, next param, repeat)
|
||||||
|
- NRPN learn: detects NRPN parameter numbers automatically
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
MIDIMessage,
|
||||||
|
MIDIMessageType,
|
||||||
|
MIDIMapping,
|
||||||
|
ParameterType,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LearnState(enum.Enum):
|
||||||
|
IDLE = "idle"
|
||||||
|
LISTENING = "listening" # Waiting for MIDI input
|
||||||
|
CAPTURED = "captured" # MIDI received, awaiting confirmation
|
||||||
|
BATCH = "batch" # Batch mode: auto-confirm and stay listening
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LearnSession:
|
||||||
|
"""Tracks the state of one learn operation."""
|
||||||
|
|
||||||
|
state: LearnState = LearnState.IDLE
|
||||||
|
|
||||||
|
# What parameter are we learning for?
|
||||||
|
param_type: ParameterType | None = None
|
||||||
|
param_channel: int = -1
|
||||||
|
param_label: str = ""
|
||||||
|
|
||||||
|
# Captured MIDI source
|
||||||
|
captured_msg: MIDIMessage | None = None
|
||||||
|
captured_cc: int = -1
|
||||||
|
captured_channel: int = 0
|
||||||
|
captured_is_nrpn: bool = False
|
||||||
|
captured_nrpn: int = 0
|
||||||
|
captured_device: str = ""
|
||||||
|
|
||||||
|
# Batch mode
|
||||||
|
batch_index: int = 0 # Current position in the batch parameter list
|
||||||
|
batch_params: list[tuple[ParameterType, int, str]] = field(default_factory=list)
|
||||||
|
batch_mappings: list[MIDIMapping] = field(default_factory=list)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset to idle, discarding any captured state."""
|
||||||
|
self.state = LearnState.IDLE
|
||||||
|
self.captured_msg = None
|
||||||
|
self.captured_cc = -1
|
||||||
|
self.captured_channel = 0
|
||||||
|
self.captured_is_nrpn = False
|
||||||
|
self.captured_nrpn = 0
|
||||||
|
self.captured_device = ""
|
||||||
|
self.batch_index = 0
|
||||||
|
self.batch_params.clear()
|
||||||
|
self.batch_mappings.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class MIDILearn:
|
||||||
|
"""Learn mode manager.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
learn = MIDILearn()
|
||||||
|
learn.on_mapped = engine.on_mapped # Forward learned mappings
|
||||||
|
|
||||||
|
# Single learn
|
||||||
|
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||||
|
# ... wait for MIDI event (call learn.feed_message(msg))
|
||||||
|
mapping = learn.confirm() # → MIDIMapping if captured
|
||||||
|
|
||||||
|
# Batch learn
|
||||||
|
learn.start_batch([
|
||||||
|
(ParameterType.VOLUME, 0, "CH1 Vol"),
|
||||||
|
(ParameterType.PAN, 0, "CH1 Pan"),
|
||||||
|
(ParameterType.MUTE, 0, "CH1 Mute"),
|
||||||
|
])
|
||||||
|
# feed each message... each capture auto-advances
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.session = LearnSession()
|
||||||
|
self._on_learned: list = [] # callbacks receiving (mapping,)
|
||||||
|
|
||||||
|
def on_learned(self, callback):
|
||||||
|
"""Register callback: callback(MIDIMapping) on confirmation."""
|
||||||
|
self._on_learned.append(callback)
|
||||||
|
|
||||||
|
# ── Learn lifecycle ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_learn(self, param_type: ParameterType, channel: int = -1, label: str = "") -> None:
|
||||||
|
"""Begin single learn mode for one parameter."""
|
||||||
|
self.session.reset()
|
||||||
|
self.session.state = LearnState.LISTENING
|
||||||
|
self.session.param_type = param_type
|
||||||
|
self.session.param_channel = channel
|
||||||
|
self.session.param_label = label
|
||||||
|
logger.info("Learn mode: listening for %s CH%d", param_type.value, channel)
|
||||||
|
|
||||||
|
def start_batch(self, params: list[tuple[ParameterType, int, str]]) -> None:
|
||||||
|
"""Begin batch learn mode for a sequence of parameters.
|
||||||
|
|
||||||
|
Each incoming MIDI message is auto-mapped to the current parameter
|
||||||
|
and the session advances to the next. Confirmation is automatic.
|
||||||
|
"""
|
||||||
|
self.session.reset()
|
||||||
|
self.session.state = LearnState.BATCH
|
||||||
|
self.session.batch_params = list(params)
|
||||||
|
self.session.batch_index = 0
|
||||||
|
self._set_batch_target()
|
||||||
|
logger.info("Learn mode: batch of %d params", len(params))
|
||||||
|
|
||||||
|
def _set_batch_target(self) -> None:
|
||||||
|
"""Update the current batch target."""
|
||||||
|
if self.session.batch_index < len(self.session.batch_params):
|
||||||
|
pt, ch, label = self.session.batch_params[self.session.batch_index]
|
||||||
|
self.session.param_type = pt
|
||||||
|
self.session.param_channel = ch
|
||||||
|
self.session.param_label = label
|
||||||
|
|
||||||
|
def feed_message(self, msg: MIDIMessage) -> MIDIMapping | None:
|
||||||
|
"""Accept an incoming MIDI message for learn capture.
|
||||||
|
|
||||||
|
Returns a MIDIMapping if the message triggered a mapping confirmation
|
||||||
|
(single confirm or batch auto-assign). Returns None if still listening
|
||||||
|
or if the message was ignored.
|
||||||
|
"""
|
||||||
|
if self.session.state not in (LearnState.LISTENING, LearnState.BATCH, LearnState.CAPTURED):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Only care about CC and NRPN messages (and notes for transport triggers)
|
||||||
|
if msg.msg_type not in (MIDIMessageType.CONTROL_CHANGE, MIDIMessageType.NOTE_ON, MIDIMessageType.NOTE_OFF):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.session.state in (LearnState.LISTENING, LearnState.BATCH):
|
||||||
|
self._capture(msg)
|
||||||
|
|
||||||
|
if self.session.state == LearnState.CAPTURED:
|
||||||
|
# In single mode, we now have the capture; caller must confirm()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.session.state == LearnState.BATCH:
|
||||||
|
# Auto-confirm and advance
|
||||||
|
return self._batch_confirm()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _capture(self, msg: MIDIMessage) -> None:
|
||||||
|
"""Store the incoming MIDI message as the captured source."""
|
||||||
|
# Determine CC or NRPN
|
||||||
|
if msg.is_nrpn:
|
||||||
|
cc = -1
|
||||||
|
is_nrpn = True
|
||||||
|
nrpn = msg.nrpn_number or 0
|
||||||
|
else:
|
||||||
|
cc = msg.controller if msg.controller is not None else -1
|
||||||
|
is_nrpn = False
|
||||||
|
nrpn = 0
|
||||||
|
|
||||||
|
self.session.captured_msg = msg
|
||||||
|
self.session.captured_cc = cc
|
||||||
|
self.session.captured_channel = msg.channel
|
||||||
|
self.session.captured_is_nrpn = is_nrpn
|
||||||
|
self.session.captured_nrpn = nrpn
|
||||||
|
self.session.captured_device = msg.source_device
|
||||||
|
|
||||||
|
if self.session.state == LearnState.LISTENING:
|
||||||
|
self.session.state = LearnState.CAPTURED
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Learn captured: ch=%d %s=%d device=%s",
|
||||||
|
msg.channel,
|
||||||
|
"NRPN" if is_nrpn else "CC",
|
||||||
|
nrpn if is_nrpn else cc,
|
||||||
|
msg.source_device,
|
||||||
|
)
|
||||||
|
|
||||||
|
def confirm(self) -> MIDIMapping | None:
|
||||||
|
"""Confirm the captured mapping in single-learn mode.
|
||||||
|
|
||||||
|
Returns the created MIDIMapping, or None if nothing captured.
|
||||||
|
"""
|
||||||
|
if self.session.state != LearnState.CAPTURED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mapping = self._build_mapping()
|
||||||
|
self.session.reset()
|
||||||
|
logger.info("Learn confirmed: %s", mapping.label or f"CC{mapping.controller}")
|
||||||
|
self._notify(mapping)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
def _batch_confirm(self) -> MIDIMapping | None:
|
||||||
|
"""Auto-confirm in batch mode and advance to next parameter."""
|
||||||
|
mapping = self._build_mapping()
|
||||||
|
self.session.batch_mappings.append(mapping)
|
||||||
|
logger.info("Batch learn [%d/%d]: %s",
|
||||||
|
self.session.batch_index + 1,
|
||||||
|
len(self.session.batch_params),
|
||||||
|
mapping.label or f"CC{mapping.controller}")
|
||||||
|
self._notify(mapping)
|
||||||
|
|
||||||
|
self.session.batch_index += 1
|
||||||
|
if self.session.batch_index >= len(self.session.batch_params):
|
||||||
|
# Batch complete
|
||||||
|
self.session.state = LearnState.IDLE
|
||||||
|
logger.info("Batch learn complete: %d mappings", len(self.session.batch_mappings))
|
||||||
|
return mapping # Return last one
|
||||||
|
|
||||||
|
self._set_batch_target()
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
def discard(self) -> None:
|
||||||
|
"""Discard the current capture and return to idle."""
|
||||||
|
self.session.reset()
|
||||||
|
logger.info("Learn discarded")
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""Alias for discard."""
|
||||||
|
self.discard()
|
||||||
|
|
||||||
|
# ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_mapping(self) -> MIDIMapping:
|
||||||
|
"""Construct a MIDIMapping from captured state."""
|
||||||
|
s = self.session
|
||||||
|
msg_type = s.captured_msg.msg_type if s.captured_msg else MIDIMessageType.CONTROL_CHANGE
|
||||||
|
return MIDIMapping(
|
||||||
|
msg_type=msg_type,
|
||||||
|
channel=s.captured_channel,
|
||||||
|
controller=s.captured_cc,
|
||||||
|
is_nrpn=s.captured_is_nrpn,
|
||||||
|
nrpn_number=s.captured_nrpn,
|
||||||
|
source_device=s.captured_device,
|
||||||
|
param_type=s.param_type or ParameterType.VOLUME,
|
||||||
|
param_channel=s.param_channel,
|
||||||
|
label=s.param_label or self._auto_label(),
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auto_label(self) -> str:
|
||||||
|
"""Generate a human-readable label for the mapping."""
|
||||||
|
s = self.session
|
||||||
|
pt = s.param_type.value if s.param_type else "param"
|
||||||
|
ch = f"CH{s.param_channel}" if s.param_channel >= 0 else "Master"
|
||||||
|
src = f"CC{s.captured_cc}" if not s.captured_is_nrpn else f"NRPN{s.captured_nrpn}"
|
||||||
|
return f"{ch} {pt} ← {src}"
|
||||||
|
|
||||||
|
def _notify(self, mapping: MIDIMapping) -> None:
|
||||||
|
for cb in self._on_learned:
|
||||||
|
try:
|
||||||
|
cb(mapping)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Learn callback failed: %s", exc)
|
||||||
|
|
||||||
|
# ── Query state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_idle(self) -> bool:
|
||||||
|
return self.session.state == LearnState.IDLE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_listening(self) -> bool:
|
||||||
|
return self.session.state == LearnState.LISTENING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_captured(self) -> bool:
|
||||||
|
return self.session.state == LearnState.CAPTURED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_batch(self) -> bool:
|
||||||
|
return self.session.state == LearnState.BATCH
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_text(self) -> str:
|
||||||
|
s = self.session
|
||||||
|
pt = s.param_type
|
||||||
|
pt_val = pt.value if pt else "?"
|
||||||
|
pl = s.param_label or pt_val
|
||||||
|
if s.state == LearnState.IDLE:
|
||||||
|
return "Idle"
|
||||||
|
if s.state == LearnState.LISTENING:
|
||||||
|
return f"Listening for {pl}..."
|
||||||
|
if s.state == LearnState.CAPTURED:
|
||||||
|
src = f"CC{s.captured_cc}" if not s.captured_is_nrpn else f"NRPN{s.captured_nrpn}"
|
||||||
|
return f"Captured {src} -> {pl} (confirm/discard)"
|
||||||
|
if s.state == LearnState.BATCH:
|
||||||
|
idx = s.batch_index + 1
|
||||||
|
total = len(self.session.batch_params)
|
||||||
|
return f"Batch [{idx}/{total}] {pl}..."
|
||||||
|
return "Unknown"
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""MIDI types, enums, and message classes for the Raspberry Pi RT Audio Mixer.
|
||||||
|
|
||||||
|
Defines the canonical MIDI message representation, mixer parameter
|
||||||
|
taxonomy, and mapping data structures used throughout the MIDI subsystem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── MIDI protocol constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MIDIMessageType(enum.IntEnum):
|
||||||
|
"""MIDI channel voice message status nibbles (upper 4 bits)."""
|
||||||
|
NOTE_OFF = 0x8
|
||||||
|
NOTE_ON = 0x9
|
||||||
|
POLY_PRESSURE = 0xA
|
||||||
|
CONTROL_CHANGE = 0xB # CC
|
||||||
|
PROGRAM_CHANGE = 0xC
|
||||||
|
CHANNEL_PRESSURE = 0xD
|
||||||
|
PITCH_BEND = 0xE
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_status(cls, status_byte: int) -> MIDIMessageType:
|
||||||
|
return cls(status_byte >> 4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def channel(status_byte: int) -> int:
|
||||||
|
return status_byte & 0x0F
|
||||||
|
|
||||||
|
|
||||||
|
class MIDISystemMessage(enum.IntEnum):
|
||||||
|
"""System message types (status byte when channel nibble is ignored)."""
|
||||||
|
SYSEX_START = 0xF0
|
||||||
|
MTC_QUARTER = 0xF1 # MIDI Time Code quarter frame
|
||||||
|
SONG_POSITION = 0xF2
|
||||||
|
SONG_SELECT = 0xF3
|
||||||
|
TUNE_REQUEST = 0xF6
|
||||||
|
SYSEX_END = 0xF7
|
||||||
|
TIMING_CLOCK = 0xF8
|
||||||
|
START = 0xFA
|
||||||
|
CONTINUE = 0xFB
|
||||||
|
STOP = 0xFC
|
||||||
|
ACTIVE_SENSE = 0xFE
|
||||||
|
SYSTEM_RESET = 0xFF
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mixer parameter taxonomy ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ParameterCategory(enum.StrEnum):
|
||||||
|
"""Top-level categories of mixer parameters."""
|
||||||
|
CHANNEL = "channel"
|
||||||
|
MASTER = "master"
|
||||||
|
FX = "fx"
|
||||||
|
ROUTING = "routing"
|
||||||
|
TRANSPORT = "transport"
|
||||||
|
UTILITY = "utility"
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterType(enum.StrEnum):
|
||||||
|
"""Concrete mixer parameter types.
|
||||||
|
|
||||||
|
Each type implies a value range and interpolation model.
|
||||||
|
"""
|
||||||
|
# Channel strip
|
||||||
|
VOLUME = "volume" # dB or linear 0.0–1.0
|
||||||
|
PAN = "pan" # -1.0 (L) to 1.0 (R)
|
||||||
|
MUTE = "mute" # boolean
|
||||||
|
SOLO = "solo" # boolean
|
||||||
|
GAIN = "gain" # dB pre-fader
|
||||||
|
PHASE_INVERT = "phase_invert" # boolean
|
||||||
|
|
||||||
|
# EQ (3-band parametric)
|
||||||
|
EQ_LOW_FREQ = "eq_low_freq"
|
||||||
|
EQ_LOW_GAIN = "eq_low_gain"
|
||||||
|
EQ_LOW_Q = "eq_low_q"
|
||||||
|
EQ_MID_FREQ = "eq_mid_freq"
|
||||||
|
EQ_MID_GAIN = "eq_mid_gain"
|
||||||
|
EQ_MID_Q = "eq_mid_q"
|
||||||
|
EQ_HIGH_FREQ = "eq_high_freq"
|
||||||
|
EQ_HIGH_GAIN = "eq_high_gain"
|
||||||
|
EQ_HIGH_Q = "eq_high_q"
|
||||||
|
EQ_ENABLE = "eq_enable"
|
||||||
|
|
||||||
|
# Dynamics
|
||||||
|
COMP_THRESHOLD = "comp_threshold"
|
||||||
|
COMP_RATIO = "comp_ratio"
|
||||||
|
COMP_ATTACK = "comp_attack"
|
||||||
|
COMP_RELEASE = "comp_release"
|
||||||
|
COMP_GAIN = "comp_gain"
|
||||||
|
GATE_THRESHOLD = "gate_threshold"
|
||||||
|
GATE_RANGE = "gate_range"
|
||||||
|
|
||||||
|
# FX sends
|
||||||
|
FX_SEND_A = "fx_send_a"
|
||||||
|
FX_SEND_B = "fx_send_b"
|
||||||
|
FX_SEND_C = "fx_send_c"
|
||||||
|
FX_SEND_D = "fx_send_d"
|
||||||
|
FX_RETURN_A = "fx_return_a"
|
||||||
|
FX_RETURN_B = "fx_return_b"
|
||||||
|
FX_RETURN_C = "fx_return_c"
|
||||||
|
FX_RETURN_D = "fx_return_d"
|
||||||
|
|
||||||
|
# Master
|
||||||
|
MASTER_VOLUME = "master_volume"
|
||||||
|
MASTER_MUTE = "master_mute"
|
||||||
|
MASTER_DIM = "master_dim"
|
||||||
|
MONITOR_VOLUME = "monitor_volume"
|
||||||
|
PHONES_VOLUME = "phones_volume"
|
||||||
|
|
||||||
|
# Transport
|
||||||
|
PLAY = "play"
|
||||||
|
STOP = "stop"
|
||||||
|
RECORD = "record"
|
||||||
|
LOOP = "loop"
|
||||||
|
TEMPO = "tempo"
|
||||||
|
TAP_TEMPO = "tap_tempo"
|
||||||
|
|
||||||
|
# Utility
|
||||||
|
SNAPSHOT_LOAD = "snapshot_load"
|
||||||
|
SNAPSHOT_SAVE = "snapshot_save"
|
||||||
|
SCENE_NEXT = "scene_next"
|
||||||
|
SCENE_PREV = "scene_prev"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data classes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MIDIMessage:
|
||||||
|
"""Canonical MIDI message.
|
||||||
|
|
||||||
|
Normalises CC, NRPN, note, pitch bend, and system realtime messages
|
||||||
|
into a single representation for the mapping engine.
|
||||||
|
"""
|
||||||
|
msg_type: MIDIMessageType
|
||||||
|
channel: int # 0–15
|
||||||
|
controller: int | None = None # CC number (0–127) or note number
|
||||||
|
value: int | None = None # 0–127 for CC, 0–16383 for pitch bend
|
||||||
|
is_nrpn: bool = False
|
||||||
|
nrpn_msb: int | None = None # NRPN parameter number MSB (CC 99)
|
||||||
|
nrpn_lsb: int | None = None # NRPN parameter number LSB (CC 98)
|
||||||
|
timestamp: float = 0.0 # seconds (monotonic)
|
||||||
|
source_device: str = "" # ALSA client name or USB device node
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cc(self) -> bool:
|
||||||
|
return self.msg_type == MIDIMessageType.CONTROL_CHANGE and not self.is_nrpn
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nrpn_number(self) -> int | None:
|
||||||
|
"""Full 14-bit NRPN number (MSB << 7 | LSB)."""
|
||||||
|
if self.is_nrpn and self.nrpn_msb is not None and self.nrpn_lsb is not None:
|
||||||
|
return (self.nrpn_msb << 7) | self.nrpn_lsb
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value_normalised(self) -> float:
|
||||||
|
"""Value scaled to 0.0–1.0 for CC, -1.0–1.0 for pitch bend."""
|
||||||
|
if self.value is None:
|
||||||
|
return 0.0
|
||||||
|
if self.msg_type == MIDIMessageType.PITCH_BEND:
|
||||||
|
return (self.value - 8192) / 8192.0 # -1.0 to ~1.0
|
||||||
|
return self.value / 127.0
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
base = f"ch={self.channel}"
|
||||||
|
if self.is_nrpn:
|
||||||
|
base += f" NRPN={self.nrpn_number}"
|
||||||
|
elif self.controller is not None:
|
||||||
|
base += f" CC={self.controller}"
|
||||||
|
if self.value is not None:
|
||||||
|
base += f" val={self.value}"
|
||||||
|
if self.source_device:
|
||||||
|
base += f" src={self.source_device}"
|
||||||
|
return f"<MIDI {self.msg_type.name} {base}>"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MixerParameter:
|
||||||
|
"""A bindable mixer parameter."""
|
||||||
|
param_type: ParameterType
|
||||||
|
category: ParameterCategory
|
||||||
|
channel: int = -1 # -1 = master / global
|
||||||
|
label: str = ""
|
||||||
|
min_val: float = 0.0
|
||||||
|
max_val: float = 1.0
|
||||||
|
default_val: float = 0.5
|
||||||
|
step: float = 0.0 # 0 = continuous; >0 = stepped
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_label(self) -> str:
|
||||||
|
if self.channel >= 0:
|
||||||
|
return f"CH{self.channel} {self.label or self.param_type.value}"
|
||||||
|
return self.label or self.param_type.value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MIDIMapping:
|
||||||
|
"""A single mapping from a MIDI message pattern to a mixer parameter."""
|
||||||
|
# MIDI source pattern (match criteria)
|
||||||
|
msg_type: MIDIMessageType = MIDIMessageType.CONTROL_CHANGE
|
||||||
|
channel: int = 0
|
||||||
|
controller: int = 0 # CC number or note number
|
||||||
|
is_nrpn: bool = False
|
||||||
|
nrpn_number: int = 0 # 14-bit NRPN parameter number
|
||||||
|
source_device: str = "" # empty = any device
|
||||||
|
|
||||||
|
# Mixer target
|
||||||
|
param_type: ParameterType = ParameterType.VOLUME
|
||||||
|
param_channel: int = -1
|
||||||
|
|
||||||
|
# Value mapping
|
||||||
|
midi_min: int = 0
|
||||||
|
midi_max: int = 127
|
||||||
|
param_min: float = 0.0
|
||||||
|
param_max: float = 1.0
|
||||||
|
invert: bool = False
|
||||||
|
curve: str = "linear" # linear, logarithmic, exponential
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
label: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
def scale_value(self, midi_value: int) -> float:
|
||||||
|
"""Convert raw MIDI value to the parameter's output range."""
|
||||||
|
raw = (midi_value - self.midi_min) / max(1, self.midi_max - self.midi_min)
|
||||||
|
raw = max(0.0, min(1.0, raw))
|
||||||
|
if self.invert:
|
||||||
|
raw = 1.0 - raw
|
||||||
|
|
||||||
|
if self.curve == "logarithmic":
|
||||||
|
import math
|
||||||
|
raw = math.log10(1 + 9 * raw)
|
||||||
|
elif self.curve == "exponential":
|
||||||
|
raw = raw ** 2
|
||||||
|
|
||||||
|
return self.param_min + raw * (self.param_max - self.param_min)
|
||||||
|
|
||||||
|
def matches(self, msg: MIDIMessage) -> bool:
|
||||||
|
"""Return True if msg matches this mapping's source criteria."""
|
||||||
|
if msg.msg_type != self.msg_type:
|
||||||
|
return False
|
||||||
|
if self.channel >= 0 and msg.channel != self.channel:
|
||||||
|
return False
|
||||||
|
if self.is_nrpn:
|
||||||
|
if not msg.is_nrpn or msg.nrpn_number != self.nrpn_number:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if msg.controller != self.controller:
|
||||||
|
return False
|
||||||
|
if self.source_device and msg.source_device != self.source_device:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Mixer DSP engine — channel strips, routing matrix, buses, automation.
|
||||||
|
|
||||||
|
The mixer package provides the complete DSP engine for the
|
||||||
|
Raspberry Pi RT Audio Mixer, including:
|
||||||
|
- Channel strip parameter management (EQ, comp, gate, gain)
|
||||||
|
- Carla OSC control for plugin parameter automation
|
||||||
|
- JACK routing matrix for any-input-to-any-output signal flow
|
||||||
|
- Bus manager (aux sends/returns, subgroups, VCA groups)
|
||||||
|
- Fader automation with recording, playback, and scenes
|
||||||
|
- Master DSP engine orchestrator
|
||||||
|
|
||||||
|
Companion to the midi package; MIDI events flow through the
|
||||||
|
ParameterRegistry into the DSP engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Iterator
|
||||||
|
|
||||||
|
from ..midi.types import ParameterCategory, ParameterType, MixerParameter
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter change callback type ──────────────────────────────────────────
|
||||||
|
|
||||||
|
ParameterCallback = Callable[[MixerParameter, float], None]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mixer strip factory ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _channel_params(channel: int) -> list[MixerParameter]:
|
||||||
|
"""Build the standard parameter set for one channel strip."""
|
||||||
|
return [
|
||||||
|
MixerParameter(ParameterType.VOLUME, ParameterCategory.CHANNEL, channel, "Volume", -60.0, 12.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.PAN, ParameterCategory.CHANNEL, channel, "Pan", -1.0, 1.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.MUTE, ParameterCategory.CHANNEL, channel, "Mute", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.SOLO, ParameterCategory.CHANNEL, channel, "Solo", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.GAIN, ParameterCategory.CHANNEL, channel, "Gain", -20.0, 60.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.PHASE_INVERT, ParameterCategory.CHANNEL, channel, "Phase", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
# EQ
|
||||||
|
MixerParameter(ParameterType.EQ_ENABLE, ParameterCategory.CHANNEL, channel, "EQ Enable", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.EQ_LOW_FREQ, ParameterCategory.CHANNEL, channel, "EQ Lo Freq", 20.0, 500.0, 100.0),
|
||||||
|
MixerParameter(ParameterType.EQ_LOW_GAIN, ParameterCategory.CHANNEL, channel, "EQ Lo Gain", -15.0, 15.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.EQ_LOW_Q, ParameterCategory.CHANNEL, channel, "EQ Lo Q", 0.1, 6.0, 0.71),
|
||||||
|
MixerParameter(ParameterType.EQ_MID_FREQ, ParameterCategory.CHANNEL, channel, "EQ Mid Freq", 200.0, 8000.0, 1000.0),
|
||||||
|
MixerParameter(ParameterType.EQ_MID_GAIN, ParameterCategory.CHANNEL, channel, "EQ Mid Gain", -15.0, 15.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.EQ_MID_Q, ParameterCategory.CHANNEL, channel, "EQ Mid Q", 0.1, 6.0, 0.71),
|
||||||
|
MixerParameter(ParameterType.EQ_HIGH_FREQ, ParameterCategory.CHANNEL, channel, "EQ Hi Freq", 2000.0, 20000.0, 5000.0),
|
||||||
|
MixerParameter(ParameterType.EQ_HIGH_GAIN, ParameterCategory.CHANNEL, channel, "EQ Hi Gain", -15.0, 15.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.EQ_HIGH_Q, ParameterCategory.CHANNEL, channel, "EQ Hi Q", 0.1, 6.0, 0.71),
|
||||||
|
# Dynamics
|
||||||
|
MixerParameter(ParameterType.COMP_THRESHOLD, ParameterCategory.CHANNEL, channel, "Comp Thresh", -60.0, 0.0, -20.0),
|
||||||
|
MixerParameter(ParameterType.COMP_RATIO, ParameterCategory.CHANNEL, channel, "Comp Ratio", 1.0, 20.0, 2.0),
|
||||||
|
MixerParameter(ParameterType.COMP_ATTACK, ParameterCategory.CHANNEL, channel, "Comp Attack", 0.1, 100.0, 10.0),
|
||||||
|
MixerParameter(ParameterType.COMP_RELEASE, ParameterCategory.CHANNEL, channel, "Comp Release", 10.0, 1000.0, 100.0),
|
||||||
|
MixerParameter(ParameterType.COMP_GAIN, ParameterCategory.CHANNEL, channel, "Comp Gain", -20.0, 20.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.GATE_THRESHOLD, ParameterCategory.CHANNEL, channel, "Gate Thresh", -80.0, 0.0, -40.0),
|
||||||
|
MixerParameter(ParameterType.GATE_RANGE, ParameterCategory.CHANNEL, channel, "Gate Range", -80.0, 0.0, -60.0),
|
||||||
|
# FX sends (2 sends)
|
||||||
|
MixerParameter(ParameterType.FX_SEND_A, ParameterCategory.CHANNEL, channel, "FX Send A", -60.0, 12.0, -60.0),
|
||||||
|
MixerParameter(ParameterType.FX_SEND_B, ParameterCategory.CHANNEL, channel, "FX Send B", -60.0, 12.0, -60.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _master_params() -> list[MixerParameter]:
|
||||||
|
"""Build master bus parameters."""
|
||||||
|
return [
|
||||||
|
MixerParameter(ParameterType.MASTER_VOLUME, ParameterCategory.MASTER, -1, "Master Vol", -60.0, 12.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.MASTER_MUTE, ParameterCategory.MASTER, -1, "Master Mute", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.MASTER_DIM, ParameterCategory.MASTER, -1, "Dim", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.MONITOR_VOLUME, ParameterCategory.MASTER, -1, "Monitor Vol", -60.0, 12.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.PHONES_VOLUME, ParameterCategory.MASTER, -1, "Phones Vol", -60.0, 12.0, 0.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fx_params() -> list[MixerParameter]:
|
||||||
|
"""Build FX return parameters."""
|
||||||
|
return [
|
||||||
|
MixerParameter(ParameterType.FX_RETURN_A, ParameterCategory.FX, -1, "FX Return A", -60.0, 12.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.FX_RETURN_B, ParameterCategory.FX, -1, "FX Return B", -60.0, 12.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.FX_RETURN_C, ParameterCategory.FX, -1, "FX Return C", -60.0, 12.0, 0.0),
|
||||||
|
MixerParameter(ParameterType.FX_RETURN_D, ParameterCategory.FX, -1, "FX Return D", -60.0, 12.0, 0.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _transport_params() -> list[MixerParameter]:
|
||||||
|
"""Build transport parameters."""
|
||||||
|
return [
|
||||||
|
MixerParameter(ParameterType.PLAY, ParameterCategory.TRANSPORT, -1, "Play", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.STOP, ParameterCategory.TRANSPORT, -1, "Stop", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.RECORD, ParameterCategory.TRANSPORT, -1, "Record", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.LOOP, ParameterCategory.TRANSPORT, -1, "Loop", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.TEMPO, ParameterCategory.TRANSPORT, -1, "Tempo BPM", 20.0, 300.0, 120.0),
|
||||||
|
MixerParameter(ParameterType.TAP_TEMPO, ParameterCategory.TRANSPORT, -1, "Tap Tempo", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _utility_params() -> list[MixerParameter]:
|
||||||
|
"""Build utility parameters."""
|
||||||
|
return [
|
||||||
|
MixerParameter(ParameterType.SNAPSHOT_LOAD, ParameterCategory.UTILITY, -1, "Snapshot Load", 0.0, 127.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.SNAPSHOT_SAVE, ParameterCategory.UTILITY, -1, "Snapshot Save", 0.0, 127.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.SCENE_NEXT, ParameterCategory.UTILITY, -1, "Scene Next", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
MixerParameter(ParameterType.SCENE_PREV, ParameterCategory.UTILITY, -1, "Scene Prev", 0.0, 1.0, 0.0, step=1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter registry ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParameterRegistry:
|
||||||
|
"""Holds all mixer parameters and dispatches value changes.
|
||||||
|
|
||||||
|
This is the runtime registry the MIDI engine writes parameter
|
||||||
|
changes into. Downstream consumers (DSP engine, UI) register
|
||||||
|
callbacks to receive updates.
|
||||||
|
"""
|
||||||
|
channels: int = 16
|
||||||
|
params: dict[tuple[ParameterType, int], MixerParameter] = field(default_factory=dict)
|
||||||
|
_callbacks: list[ParameterCallback] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
"""Populate the registry with default parameter set."""
|
||||||
|
# Master bus
|
||||||
|
for p in _master_params():
|
||||||
|
self.params[(p.param_type, p.channel)] = p
|
||||||
|
|
||||||
|
# FX returns
|
||||||
|
for p in _fx_params():
|
||||||
|
self.params[(p.param_type, p.channel)] = p
|
||||||
|
|
||||||
|
# Transport
|
||||||
|
for p in _transport_params():
|
||||||
|
self.params[(p.param_type, p.channel)] = p
|
||||||
|
|
||||||
|
# Utility
|
||||||
|
for p in _utility_params():
|
||||||
|
self.params[(p.param_type, p.channel)] = p
|
||||||
|
|
||||||
|
# Channel strips
|
||||||
|
for ch in range(self.channels):
|
||||||
|
for p in _channel_params(ch):
|
||||||
|
self.params[(p.param_type, p.channel)] = p
|
||||||
|
|
||||||
|
def get(self, param_type: ParameterType, channel: int = -1) -> MixerParameter | None:
|
||||||
|
"""Look up a parameter by type and channel."""
|
||||||
|
return self.params.get((param_type, channel))
|
||||||
|
|
||||||
|
def iter_all(self) -> Iterator[MixerParameter]:
|
||||||
|
yield from self.params.values()
|
||||||
|
|
||||||
|
def iter_by_category(self, category: ParameterCategory) -> Iterator[MixerParameter]:
|
||||||
|
for p in self.params.values():
|
||||||
|
if p.category == category:
|
||||||
|
yield p
|
||||||
|
|
||||||
|
def iter_by_channel(self, channel: int) -> Iterator[MixerParameter]:
|
||||||
|
for p in self.params.values():
|
||||||
|
if p.channel == channel:
|
||||||
|
yield p
|
||||||
|
|
||||||
|
# ── Callback management ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def subscribe(self, callback: ParameterCallback) -> None:
|
||||||
|
"""Register a callback to receive parameter change events."""
|
||||||
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(self, callback: ParameterCallback) -> None:
|
||||||
|
if callback in self._callbacks:
|
||||||
|
self._callbacks.remove(callback)
|
||||||
|
|
||||||
|
def set_value(self, param_type: ParameterType, channel: int, value: float) -> None:
|
||||||
|
"""Set a parameter value and notify subscribers."""
|
||||||
|
param = self.get(param_type, channel)
|
||||||
|
if param is None:
|
||||||
|
return
|
||||||
|
for cb in self._callbacks:
|
||||||
|
try:
|
||||||
|
cb(param, value)
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let one broken callback break the chain
|
||||||
|
|
||||||
|
|
||||||
|
# ── DSP engine exports ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
from .osc_client import (
|
||||||
|
CarlaOSCClient,
|
||||||
|
CarlaPluginInfo,
|
||||||
|
DEFAULT_PLUGIN_LAYOUT,
|
||||||
|
encode_osc,
|
||||||
|
decode_osc,
|
||||||
|
linear_to_db,
|
||||||
|
db_to_linear,
|
||||||
|
freq_to_normalized,
|
||||||
|
normalized_to_freq,
|
||||||
|
time_ms_to_normalized,
|
||||||
|
mix_to_pan,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .channel_strip import (
|
||||||
|
ChannelStrip,
|
||||||
|
ChannelState,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .routing_matrix import (
|
||||||
|
RoutingMatrix,
|
||||||
|
RouteNode,
|
||||||
|
RoutingEdge,
|
||||||
|
NodeType,
|
||||||
|
get_jack_ports,
|
||||||
|
get_jack_connections,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .bus_manager import (
|
||||||
|
BusManager,
|
||||||
|
AuxBus,
|
||||||
|
SubgroupBus,
|
||||||
|
VCAGroup,
|
||||||
|
MasterBus,
|
||||||
|
BusType,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .fader_automation import (
|
||||||
|
FaderAutomation,
|
||||||
|
AutomationLane,
|
||||||
|
AutomationPoint,
|
||||||
|
Scene,
|
||||||
|
InterpolationMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .dsp_engine import (
|
||||||
|
DSPEngine,
|
||||||
|
DSPEngineConfig,
|
||||||
|
create_default_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Registry
|
||||||
|
"ParameterRegistry",
|
||||||
|
"ParameterCallback",
|
||||||
|
# OSC
|
||||||
|
"CarlaOSCClient",
|
||||||
|
"CarlaPluginInfo",
|
||||||
|
"DEFAULT_PLUGIN_LAYOUT",
|
||||||
|
"encode_osc",
|
||||||
|
"decode_osc",
|
||||||
|
"linear_to_db",
|
||||||
|
"db_to_linear",
|
||||||
|
"freq_to_normalized",
|
||||||
|
"normalized_to_freq",
|
||||||
|
"time_ms_to_normalized",
|
||||||
|
"mix_to_pan",
|
||||||
|
# Channel strip
|
||||||
|
"ChannelStrip",
|
||||||
|
"ChannelState",
|
||||||
|
# Routing
|
||||||
|
"RoutingMatrix",
|
||||||
|
"RouteNode",
|
||||||
|
"RoutingEdge",
|
||||||
|
"NodeType",
|
||||||
|
"get_jack_ports",
|
||||||
|
"get_jack_connections",
|
||||||
|
# Buses
|
||||||
|
"BusManager",
|
||||||
|
"AuxBus",
|
||||||
|
"SubgroupBus",
|
||||||
|
"VCAGroup",
|
||||||
|
"MasterBus",
|
||||||
|
"BusType",
|
||||||
|
# Automation
|
||||||
|
"FaderAutomation",
|
||||||
|
"AutomationLane",
|
||||||
|
"AutomationPoint",
|
||||||
|
"Scene",
|
||||||
|
"InterpolationMode",
|
||||||
|
# Engine
|
||||||
|
"DSPEngine",
|
||||||
|
"DSPEngineConfig",
|
||||||
|
"create_default_engine",
|
||||||
|
]
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""Bus manager — aux sends/returns, subgroups, VCA groups, master bus.
|
||||||
|
|
||||||
|
Manages the mixer's bus architecture:
|
||||||
|
- Aux buses: configurable sends per channel → aux bus → return (with FX) → master
|
||||||
|
- Subgroups: group channels together, apply group-level processing
|
||||||
|
- VCA groups: gang faders together (relative level only, no signal routing)
|
||||||
|
- Master bus: stereo summing, insert points, master fader, dim, mute
|
||||||
|
|
||||||
|
Buses manage gain, mute, and routing but don't perform DSP themselves —
|
||||||
|
that's handled by Carla plugins connected to the appropriate JACK ports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enums ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class BusType(StrEnum):
|
||||||
|
AUX = "aux"
|
||||||
|
SUBGROUP = "subgroup"
|
||||||
|
VCA = "vca"
|
||||||
|
MASTER = "master"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data classes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuxBus:
|
||||||
|
"""An aux send/return bus.
|
||||||
|
|
||||||
|
Each channel has a send level to the aux bus. The bus sums all
|
||||||
|
sends, optionally routes through an FX plugin (Carla), then
|
||||||
|
returns to the master bus at the return level.
|
||||||
|
"""
|
||||||
|
index: int # 0-based aux number
|
||||||
|
label: str = ""
|
||||||
|
send_gain_db: float = 0.0 # overall send master (dB)
|
||||||
|
return_gain_db: float = 0.0 # overall return master (dB)
|
||||||
|
muted: bool = False
|
||||||
|
pre_fader: bool = False # True = pre-fader send, False = post-fader
|
||||||
|
fx_plugin_id: int | None = None # Carla plugin ID for FX processing
|
||||||
|
jack_send_port: str = "" # JACK port for the aux bus input
|
||||||
|
jack_return_port: str = "" # JACK port for the aux bus output
|
||||||
|
|
||||||
|
# Per-channel send levels (dB, -inf to +12)
|
||||||
|
channel_sends: dict[int, float] = field(default_factory=dict)
|
||||||
|
# Per-channel send mutes
|
||||||
|
channel_mutes: dict[int, bool] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def get_send(self, channel: int) -> float:
|
||||||
|
return self.channel_sends.get(channel, -60.0)
|
||||||
|
|
||||||
|
def set_send(self, channel: int, db: float) -> None:
|
||||||
|
self.channel_sends[channel] = db
|
||||||
|
|
||||||
|
def get_mute(self, channel: int) -> bool:
|
||||||
|
return self.channel_mutes.get(channel, False)
|
||||||
|
|
||||||
|
def set_mute(self, channel: int, muted: bool) -> None:
|
||||||
|
self.channel_mutes[channel] = muted
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SubgroupBus:
|
||||||
|
"""A subgroup bus.
|
||||||
|
|
||||||
|
Channels assigned to a subgroup have their post-fader signal
|
||||||
|
summed into the subgroup bus. The subgroup can then be routed
|
||||||
|
to the master bus with its own level control, or sent to a
|
||||||
|
separate output.
|
||||||
|
"""
|
||||||
|
index: int
|
||||||
|
label: str = ""
|
||||||
|
volume_db: float = 0.0
|
||||||
|
pan: float = 0.0 # -1.0 L to 1.0 R
|
||||||
|
muted: bool = False
|
||||||
|
solo: bool = False
|
||||||
|
is_stereo: bool = True
|
||||||
|
jack_out_port_l: str = "" # JACK output L
|
||||||
|
jack_out_port_r: str = "" # JACK output R
|
||||||
|
members: set[int] = field(default_factory=set) # channel indices
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VCAGroup:
|
||||||
|
"""A VCA (Voltage Controlled Amplifier) group.
|
||||||
|
|
||||||
|
VCAs don't carry audio — they gang faders together. When you
|
||||||
|
move the VCA master, all member channel faders move relatively.
|
||||||
|
Members still route individually to buses/master.
|
||||||
|
|
||||||
|
The VCA master provides dB offset applied to member faders.
|
||||||
|
"""
|
||||||
|
index: int
|
||||||
|
label: str = ""
|
||||||
|
master_db: float = 0.0 # VCA master level (dB offset)
|
||||||
|
muted: bool = False
|
||||||
|
members: set[int] = field(default_factory=set) # channel indices
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MasterBus:
|
||||||
|
"""The stereo master bus.
|
||||||
|
|
||||||
|
Sums all channels, subgroups, and aux returns. Has insert
|
||||||
|
points for external processing and a final output stage.
|
||||||
|
"""
|
||||||
|
volume_db: float = 0.0 # master fader (dB)
|
||||||
|
dim_db: float = -20.0 # dim attenuation (dB)
|
||||||
|
dim_active: bool = False
|
||||||
|
muted: bool = False
|
||||||
|
mono: bool = False # mono sum
|
||||||
|
insert_enabled: bool = False # insert loop active
|
||||||
|
jack_insert_send_l: str = ""
|
||||||
|
jack_insert_send_r: str = ""
|
||||||
|
jack_insert_return_l: str = ""
|
||||||
|
jack_insert_return_r: str = ""
|
||||||
|
jack_out_l: str = "system:playback_1"
|
||||||
|
jack_out_r: str = "system:playback_2"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_gain(self) -> float:
|
||||||
|
"""Linear gain of master output."""
|
||||||
|
if self.muted:
|
||||||
|
return 0.0
|
||||||
|
db = self.volume_db
|
||||||
|
if self.dim_active:
|
||||||
|
db += self.dim_db
|
||||||
|
return 10.0 ** (db / 20.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bus Manager ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class BusManager:
|
||||||
|
"""Manages all mixer buses.
|
||||||
|
|
||||||
|
Coordinates aux sends, subgroups, VCA groups, and the master bus.
|
||||||
|
Provides a unified interface for the DSP engine to query and update
|
||||||
|
bus state during parameter changes.
|
||||||
|
|
||||||
|
The bus manager does NOT directly change JACK connections — that's
|
||||||
|
the routing matrix's job. It maintains bus state and provides the
|
||||||
|
information needed to update the routing matrix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
num_channels: int = 16,
|
||||||
|
num_aux: int = 4,
|
||||||
|
num_subgroups: int = 2,
|
||||||
|
num_vca: int = 2,
|
||||||
|
):
|
||||||
|
self.num_channels = num_channels
|
||||||
|
self.num_aux = num_aux
|
||||||
|
|
||||||
|
# Buses
|
||||||
|
self.aux_buses: list[AuxBus] = []
|
||||||
|
self.subgroups: list[SubgroupBus] = []
|
||||||
|
self.vca_groups: list[VCAGroup] = []
|
||||||
|
self.master = MasterBus()
|
||||||
|
|
||||||
|
self._init_buses(num_aux, num_subgroups, num_vca)
|
||||||
|
|
||||||
|
def _init_buses(self, num_aux: int, num_subgroups: int, num_vca: int) -> None:
|
||||||
|
"""Initialize default bus configuration."""
|
||||||
|
# Aux buses
|
||||||
|
for i in range(num_aux):
|
||||||
|
self.aux_buses.append(AuxBus(
|
||||||
|
index=i,
|
||||||
|
label=f"AUX {i+1}",
|
||||||
|
jack_send_port=f"Carla:aux_{i}_in",
|
||||||
|
jack_return_port=f"Carla:aux_{i}_out",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Subgroups
|
||||||
|
for i in range(num_subgroups):
|
||||||
|
self.subgroups.append(SubgroupBus(
|
||||||
|
index=i,
|
||||||
|
label=f"Subgroup {i+1}",
|
||||||
|
jack_out_port_l=f"system:playback_{(i*2)+3}" if (i*2)+3 <= 8 else "",
|
||||||
|
jack_out_port_r=f"system:playback_{(i*2)+4}" if (i*2)+4 <= 8 else "",
|
||||||
|
))
|
||||||
|
|
||||||
|
# VCA groups
|
||||||
|
for i in range(num_vca):
|
||||||
|
self.vca_groups.append(VCAGroup(
|
||||||
|
index=i,
|
||||||
|
label=f"VCA {i+1}",
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── Aux bus operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_aux(self, index: int) -> AuxBus | None:
|
||||||
|
if 0 <= index < len(self.aux_buses):
|
||||||
|
return self.aux_buses[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_aux_send(self, aux_index: int, channel: int, db: float) -> None:
|
||||||
|
"""Set the send level from a channel to an aux bus."""
|
||||||
|
aux = self.get_aux(aux_index)
|
||||||
|
if aux:
|
||||||
|
aux.set_send(channel, db)
|
||||||
|
|
||||||
|
def set_aux_return(self, aux_index: int, db: float) -> None:
|
||||||
|
"""Set the aux return level."""
|
||||||
|
aux = self.get_aux(aux_index)
|
||||||
|
if aux:
|
||||||
|
aux.return_gain_db = db
|
||||||
|
|
||||||
|
def set_aux_mute(self, aux_index: int, muted: bool) -> None:
|
||||||
|
aux = self.get_aux(aux_index)
|
||||||
|
if aux:
|
||||||
|
aux.muted = muted
|
||||||
|
|
||||||
|
# ── Subgroup operations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_subgroup(self, index: int) -> SubgroupBus | None:
|
||||||
|
if 0 <= index < len(self.subgroups):
|
||||||
|
return self.subgroups[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def subgroup_add_channel(self, sg_index: int, channel: int) -> bool:
|
||||||
|
sg = self.get_subgroup(sg_index)
|
||||||
|
if sg and 0 <= channel < self.num_channels:
|
||||||
|
sg.members.add(channel)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def subgroup_remove_channel(self, sg_index: int, channel: int) -> bool:
|
||||||
|
sg = self.get_subgroup(sg_index)
|
||||||
|
if sg:
|
||||||
|
sg.members.discard(channel)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_channel_subgroups(self, channel: int) -> list[SubgroupBus]:
|
||||||
|
"""Get all subgroups a channel belongs to."""
|
||||||
|
return [sg for sg in self.subgroups if channel in sg.members]
|
||||||
|
|
||||||
|
# ── VCA operations ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_vca(self, index: int) -> VCAGroup | None:
|
||||||
|
if 0 <= index < len(self.vca_groups):
|
||||||
|
return self.vca_groups[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def vca_add_channel(self, vca_index: int, channel: int) -> bool:
|
||||||
|
vca = self.get_vca(vca_index)
|
||||||
|
if vca and 0 <= channel < self.num_channels:
|
||||||
|
vca.members.add(channel)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def vca_remove_channel(self, vca_index: int, channel: int) -> bool:
|
||||||
|
vca = self.get_vca(vca_index)
|
||||||
|
if vca:
|
||||||
|
vca.members.discard(channel)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_channel_vcas(self, channel: int) -> list[VCAGroup]:
|
||||||
|
"""Get all VCA groups a channel belongs to."""
|
||||||
|
return [vca for vca in self.vca_groups if channel in vca.members]
|
||||||
|
|
||||||
|
def get_channel_vca_offset(self, channel: int) -> float:
|
||||||
|
"""Get the total VCA dB offset for a channel.
|
||||||
|
|
||||||
|
When a channel belongs to multiple VCAs, their master levels
|
||||||
|
are summed to produce a total offset applied to the channel
|
||||||
|
fader.
|
||||||
|
"""
|
||||||
|
offset = 0.0
|
||||||
|
for vca in self.vca_groups:
|
||||||
|
if channel in vca.members and not vca.muted:
|
||||||
|
offset += vca.master_db
|
||||||
|
return offset
|
||||||
|
|
||||||
|
# ── Master bus operations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_master_volume(self, db: float) -> None:
|
||||||
|
self.master.volume_db = db
|
||||||
|
|
||||||
|
def set_master_mute(self, muted: bool) -> None:
|
||||||
|
self.master.muted = muted
|
||||||
|
|
||||||
|
def set_master_dim(self, active: bool) -> None:
|
||||||
|
self.master.dim_active = active
|
||||||
|
|
||||||
|
def set_master_mono(self, mono: bool) -> None:
|
||||||
|
self.master.mono = mono
|
||||||
|
|
||||||
|
def set_master_insert(self, enabled: bool) -> None:
|
||||||
|
self.master.insert_enabled = enabled
|
||||||
|
|
||||||
|
# ── Snapshot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize all bus state to a dict."""
|
||||||
|
aux_data = []
|
||||||
|
for aux in self.aux_buses:
|
||||||
|
aux_data.append({
|
||||||
|
"index": aux.index,
|
||||||
|
"label": aux.label,
|
||||||
|
"send_gain_db": aux.send_gain_db,
|
||||||
|
"return_gain_db": aux.return_gain_db,
|
||||||
|
"muted": aux.muted,
|
||||||
|
"pre_fader": aux.pre_fader,
|
||||||
|
"channel_sends": dict(aux.channel_sends),
|
||||||
|
"channel_mutes": dict(aux.channel_mutes),
|
||||||
|
})
|
||||||
|
|
||||||
|
sg_data = []
|
||||||
|
for sg in self.subgroups:
|
||||||
|
sg_data.append({
|
||||||
|
"index": sg.index,
|
||||||
|
"label": sg.label,
|
||||||
|
"volume_db": sg.volume_db,
|
||||||
|
"pan": sg.pan,
|
||||||
|
"muted": sg.muted,
|
||||||
|
"solo": sg.solo,
|
||||||
|
"members": list(sg.members),
|
||||||
|
})
|
||||||
|
|
||||||
|
vca_data = []
|
||||||
|
for vca in self.vca_groups:
|
||||||
|
vca_data.append({
|
||||||
|
"index": vca.index,
|
||||||
|
"label": vca.label,
|
||||||
|
"master_db": vca.master_db,
|
||||||
|
"muted": vca.muted,
|
||||||
|
"members": list(vca.members),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"aux_buses": aux_data,
|
||||||
|
"subgroups": sg_data,
|
||||||
|
"vca_groups": vca_data,
|
||||||
|
"master": {
|
||||||
|
"volume_db": self.master.volume_db,
|
||||||
|
"dim_active": self.master.dim_active,
|
||||||
|
"muted": self.master.muted,
|
||||||
|
"mono": self.master.mono,
|
||||||
|
"insert_enabled": self.master.insert_enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def from_dict(self, data: dict) -> None:
|
||||||
|
"""Restore bus state from a dict."""
|
||||||
|
for i, aux_d in enumerate(data.get("aux_buses", [])):
|
||||||
|
if i < len(self.aux_buses):
|
||||||
|
aux = self.aux_buses[i]
|
||||||
|
aux.label = aux_d.get("label", aux.label)
|
||||||
|
aux.send_gain_db = aux_d.get("send_gain_db", 0.0)
|
||||||
|
aux.return_gain_db = aux_d.get("return_gain_db", 0.0)
|
||||||
|
aux.muted = aux_d.get("muted", False)
|
||||||
|
aux.pre_fader = aux_d.get("pre_fader", False)
|
||||||
|
aux.channel_sends = {int(k): v for k, v in aux_d.get("channel_sends", {}).items()}
|
||||||
|
aux.channel_mutes = {int(k): v for k, v in aux_d.get("channel_mutes", {}).items()}
|
||||||
|
|
||||||
|
for i, sg_d in enumerate(data.get("subgroups", [])):
|
||||||
|
if i < len(self.subgroups):
|
||||||
|
sg = self.subgroups[i]
|
||||||
|
sg.label = sg_d.get("label", sg.label)
|
||||||
|
sg.volume_db = sg_d.get("volume_db", 0.0)
|
||||||
|
sg.pan = sg_d.get("pan", 0.0)
|
||||||
|
sg.muted = sg_d.get("muted", False)
|
||||||
|
sg.solo = sg_d.get("solo", False)
|
||||||
|
sg.members = set(sg_d.get("members", []))
|
||||||
|
|
||||||
|
for i, vca_d in enumerate(data.get("vca_groups", [])):
|
||||||
|
if i < len(self.vca_groups):
|
||||||
|
vca = self.vca_groups[i]
|
||||||
|
vca.label = vca_d.get("label", vca.label)
|
||||||
|
vca.master_db = vca_d.get("master_db", 0.0)
|
||||||
|
vca.muted = vca_d.get("muted", False)
|
||||||
|
vca.members = set(vca_d.get("members", []))
|
||||||
|
|
||||||
|
m = data.get("master", {})
|
||||||
|
self.master.volume_db = m.get("volume_db", 0.0)
|
||||||
|
self.master.dim_active = m.get("dim_active", False)
|
||||||
|
self.master.muted = m.get("muted", False)
|
||||||
|
self.master.mono = m.get("mono", False)
|
||||||
|
self.master.insert_enabled = m.get("insert_enabled", False)
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""Channel strip controller.
|
||||||
|
|
||||||
|
Each channel strip manages the state of one mixer channel, including
|
||||||
|
gain, 3-band EQ, compressor, gate, FX sends, and pan/volume. Parameter
|
||||||
|
changes are forwarded to Carla via OSC for real-time DSP processing.
|
||||||
|
|
||||||
|
Supports N channels (configurable; default 16 to match the parameter registry).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..midi.types import ParameterType
|
||||||
|
from .osc_client import (
|
||||||
|
CarlaOSCClient,
|
||||||
|
CarlaPluginInfo,
|
||||||
|
DEFAULT_PLUGIN_LAYOUT,
|
||||||
|
linear_to_db,
|
||||||
|
db_to_linear,
|
||||||
|
freq_to_normalized,
|
||||||
|
normalized_to_freq,
|
||||||
|
time_ms_to_normalized,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Channel strip state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelState:
|
||||||
|
"""Snapshot of all parameters for one channel strip."""
|
||||||
|
|
||||||
|
# Fader
|
||||||
|
volume: float = 0.0 # dB (-60 to +12)
|
||||||
|
pan: float = 0.0 # -1.0 L to 1.0 R
|
||||||
|
mute: bool = False
|
||||||
|
solo: bool = False
|
||||||
|
|
||||||
|
# Preamp
|
||||||
|
gain: float = 0.0 # dB (-20 to +60)
|
||||||
|
phase_invert: bool = False
|
||||||
|
|
||||||
|
# EQ (3-band)
|
||||||
|
eq_enable: bool = False
|
||||||
|
eq_low_freq: float = 100.0 # Hz
|
||||||
|
eq_low_gain: float = 0.0 # dB
|
||||||
|
eq_low_q: float = 0.71
|
||||||
|
eq_mid_freq: float = 1000.0 # Hz
|
||||||
|
eq_mid_gain: float = 0.0 # dB
|
||||||
|
eq_mid_q: float = 0.71
|
||||||
|
eq_high_freq: float = 5000.0 # Hz
|
||||||
|
eq_high_gain: float = 0.0 # dB
|
||||||
|
eq_high_q: float = 0.71
|
||||||
|
|
||||||
|
# Compressor
|
||||||
|
comp_threshold: float = -20.0 # dB
|
||||||
|
comp_ratio: float = 2.0 # :1
|
||||||
|
comp_attack: float = 10.0 # ms
|
||||||
|
comp_release: float = 100.0 # ms
|
||||||
|
comp_gain: float = 0.0 # dB (makeup)
|
||||||
|
|
||||||
|
# Gate
|
||||||
|
gate_threshold: float = -40.0 # dB
|
||||||
|
gate_range: float = -60.0 # dB
|
||||||
|
|
||||||
|
# FX sends
|
||||||
|
fx_send_a: float = -60.0 # dB (reverb)
|
||||||
|
fx_send_b: float = -60.0 # dB (delay)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelStrip:
|
||||||
|
"""Controller for one mixer channel strip.
|
||||||
|
|
||||||
|
Manages parameter state and dispatches changes to Carla OSC
|
||||||
|
when a Carla plugin is mapped to this channel.
|
||||||
|
|
||||||
|
Channels that don't have Carla plugins (e.g., channels 5–16 in
|
||||||
|
the default 4-channel rack) still maintain state — their audio
|
||||||
|
routing is handled purely through JACK port connections; DSP
|
||||||
|
parameters are no-ops but state is preserved for future
|
||||||
|
expansion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
index: int # 0-based channel number
|
||||||
|
state: ChannelState = field(default_factory=ChannelState)
|
||||||
|
_osc: Optional[CarlaOSCClient] = None
|
||||||
|
_plugins: dict[str, CarlaPluginInfo] = field(default_factory=dict) # role → plugin info
|
||||||
|
_last_osc_update: dict[str, float] = field(default_factory=dict) # param → timestamp
|
||||||
|
_update_coalesce_s: float = 0.005 # coalesce updates within 5ms
|
||||||
|
|
||||||
|
def bind_osc(self, client: CarlaOSCClient) -> None:
|
||||||
|
"""Attach an OSC client for Carla control."""
|
||||||
|
self._osc = client
|
||||||
|
|
||||||
|
def register_plugin(self, info: CarlaPluginInfo) -> None:
|
||||||
|
"""Register a Carla plugin mapped to this channel."""
|
||||||
|
self._plugins[info.role] = info
|
||||||
|
logger.debug("CH%d registered plugin: %s (id=%d, role=%s)",
|
||||||
|
self.index, info.name, info.plugin_id, info.role)
|
||||||
|
|
||||||
|
def unregister_plugin(self, role: str) -> None:
|
||||||
|
"""Remove a plugin mapping."""
|
||||||
|
self._plugins.pop(role, None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_dsp(self) -> bool:
|
||||||
|
"""True if this channel has Carla DSP plugins mapped."""
|
||||||
|
return len(self._plugins) > 0
|
||||||
|
|
||||||
|
# ── Parameter dispatch ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_parameter(self, param_type: ParameterType, value: float) -> None:
|
||||||
|
"""Set a parameter value and dispatch to Carla if applicable."""
|
||||||
|
# Update state
|
||||||
|
_apply_state(self.state, param_type, value)
|
||||||
|
|
||||||
|
# Dispatch to Carla OSC
|
||||||
|
if self._osc:
|
||||||
|
self._dispatch_osc(param_type, value)
|
||||||
|
|
||||||
|
def set_many(self, updates: dict[ParameterType, float]) -> None:
|
||||||
|
"""Set multiple parameters at once (batch OSC where possible)."""
|
||||||
|
for pt, val in updates.items():
|
||||||
|
_apply_state(self.state, pt, val)
|
||||||
|
|
||||||
|
if self._osc:
|
||||||
|
osc_updates = []
|
||||||
|
for pt, val in updates.items():
|
||||||
|
commands = _get_osc_commands(self._plugins, self.index, pt, val)
|
||||||
|
osc_updates.extend(commands)
|
||||||
|
|
||||||
|
# Send all updates
|
||||||
|
for plugin_id, param_idx, v in osc_updates:
|
||||||
|
self._osc.set_parameter(plugin_id, param_idx, v)
|
||||||
|
|
||||||
|
def _dispatch_osc(self, param_type: ParameterType, value: float) -> None:
|
||||||
|
"""Dispatch a single parameter to Carla OSC."""
|
||||||
|
if not self._osc:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Coalesce rapid updates for the same parameter
|
||||||
|
now = time.monotonic()
|
||||||
|
key = f"{param_type.value}"
|
||||||
|
last = self._last_osc_update.get(key, 0)
|
||||||
|
if now - last < self._update_coalesce_s:
|
||||||
|
return # skip — too close to last update
|
||||||
|
self._last_osc_update[key] = now
|
||||||
|
|
||||||
|
commands = _get_osc_commands(self._plugins, self.index, param_type, value)
|
||||||
|
for plugin_id, param_idx, v in commands:
|
||||||
|
self._osc.set_parameter(plugin_id, param_idx, v)
|
||||||
|
|
||||||
|
# ── Snapshot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def snapshot(self) -> ChannelState:
|
||||||
|
"""Return a copy of current state."""
|
||||||
|
return ChannelState(**self.state.__dict__)
|
||||||
|
|
||||||
|
def restore(self, state: ChannelState, send_osc: bool = True) -> None:
|
||||||
|
"""Restore state from a snapshot."""
|
||||||
|
self.state = ChannelState(**state.__dict__)
|
||||||
|
if send_osc and self._osc:
|
||||||
|
# Send all current parameters to OSC
|
||||||
|
updates = _state_to_osc_updates(self._plugins, self.state)
|
||||||
|
for plugin_id, param_idx, val in updates:
|
||||||
|
self._osc.set_parameter(plugin_id, param_idx, val)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset to default state and send to Carla."""
|
||||||
|
self.restore(ChannelState(), send_osc=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter → state mapping ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_state(cs: ChannelState, pt: ParameterType, value: float) -> None:
|
||||||
|
"""Apply a parameter value to channel state."""
|
||||||
|
match pt:
|
||||||
|
case ParameterType.VOLUME: cs.volume = value
|
||||||
|
case ParameterType.PAN: cs.pan = value
|
||||||
|
case ParameterType.MUTE: cs.mute = value >= 0.5
|
||||||
|
case ParameterType.SOLO: cs.solo = value >= 0.5
|
||||||
|
case ParameterType.GAIN: cs.gain = value
|
||||||
|
case ParameterType.PHASE_INVERT: cs.phase_invert = value >= 0.5
|
||||||
|
case ParameterType.EQ_ENABLE: cs.eq_enable = value >= 0.5
|
||||||
|
case ParameterType.EQ_LOW_FREQ: cs.eq_low_freq = value
|
||||||
|
case ParameterType.EQ_LOW_GAIN: cs.eq_low_gain = value
|
||||||
|
case ParameterType.EQ_LOW_Q: cs.eq_low_q = value
|
||||||
|
case ParameterType.EQ_MID_FREQ: cs.eq_mid_freq = value
|
||||||
|
case ParameterType.EQ_MID_GAIN: cs.eq_mid_gain = value
|
||||||
|
case ParameterType.EQ_MID_Q: cs.eq_mid_q = value
|
||||||
|
case ParameterType.EQ_HIGH_FREQ: cs.eq_high_freq = value
|
||||||
|
case ParameterType.EQ_HIGH_GAIN: cs.eq_high_gain = value
|
||||||
|
case ParameterType.EQ_HIGH_Q: cs.eq_high_q = value
|
||||||
|
case ParameterType.COMP_THRESHOLD: cs.comp_threshold = value
|
||||||
|
case ParameterType.COMP_RATIO: cs.comp_ratio = value
|
||||||
|
case ParameterType.COMP_ATTACK: cs.comp_attack = value
|
||||||
|
case ParameterType.COMP_RELEASE: cs.comp_release = value
|
||||||
|
case ParameterType.COMP_GAIN: cs.comp_gain = value
|
||||||
|
case ParameterType.GATE_THRESHOLD: cs.gate_threshold = value
|
||||||
|
case ParameterType.GATE_RANGE: cs.gate_range = value
|
||||||
|
case ParameterType.FX_SEND_A: cs.fx_send_a = value
|
||||||
|
case ParameterType.FX_SEND_B: cs.fx_send_b = value
|
||||||
|
case _:
|
||||||
|
logger.debug("CH%d: unhandled param %s", cs.volume, pt.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter → OSC command mapping ─────────────────────────────────────────
|
||||||
|
|
||||||
|
# Maps ParameterType to (plugin_role, param_name, value_transform_fn)
|
||||||
|
_PARAM_TO_OSC: dict[ParameterType, tuple[str, str]] = {
|
||||||
|
# Gate
|
||||||
|
ParameterType.GATE_THRESHOLD: ("gate", "threshold"),
|
||||||
|
ParameterType.GATE_RANGE: ("gate", "range"),
|
||||||
|
# EQ
|
||||||
|
ParameterType.EQ_LOW_FREQ: ("eq", "low_freq"),
|
||||||
|
ParameterType.EQ_LOW_GAIN: ("eq", "low_gain"),
|
||||||
|
ParameterType.EQ_LOW_Q: ("eq", "low_q"),
|
||||||
|
ParameterType.EQ_MID_FREQ: ("eq", "mid_freq"),
|
||||||
|
ParameterType.EQ_MID_GAIN: ("eq", "mid_gain"),
|
||||||
|
ParameterType.EQ_MID_Q: ("eq", "mid_q"),
|
||||||
|
ParameterType.EQ_HIGH_FREQ: ("eq", "high_freq"),
|
||||||
|
ParameterType.EQ_HIGH_GAIN: ("eq", "high_gain"),
|
||||||
|
ParameterType.EQ_HIGH_Q: ("eq", "high_q"),
|
||||||
|
# Compressor
|
||||||
|
ParameterType.COMP_THRESHOLD: ("comp", "threshold"),
|
||||||
|
ParameterType.COMP_RATIO: ("comp", "ratio"),
|
||||||
|
ParameterType.COMP_ATTACK: ("comp", "attack"),
|
||||||
|
ParameterType.COMP_RELEASE: ("comp", "release"),
|
||||||
|
ParameterType.COMP_GAIN: ("comp", "makeup"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_value(param_type: ParameterType, value: float) -> float:
|
||||||
|
"""Convert mixer parameter value to Carla normalized (0.0–1.0) value.
|
||||||
|
|
||||||
|
Carla Calf plugins expect parameters in their native display ranges,
|
||||||
|
sent via OSC as normalized floats. We convert from our parameter
|
||||||
|
ranges to Carla-compatible 0.0–1.0 values.
|
||||||
|
"""
|
||||||
|
match param_type:
|
||||||
|
# Gain/dB parameters → 0–1 linear
|
||||||
|
case (ParameterType.GAIN | ParameterType.GATE_THRESHOLD |
|
||||||
|
ParameterType.GATE_RANGE | ParameterType.COMP_THRESHOLD |
|
||||||
|
ParameterType.COMP_GAIN | ParameterType.EQ_LOW_GAIN |
|
||||||
|
ParameterType.EQ_MID_GAIN | ParameterType.EQ_HIGH_GAIN):
|
||||||
|
# These are already in dB — normalize to 0–1
|
||||||
|
return _normalize_param(value, param_type)
|
||||||
|
|
||||||
|
# Frequency → log scale 0–1
|
||||||
|
case (ParameterType.EQ_LOW_FREQ | ParameterType.EQ_MID_FREQ |
|
||||||
|
ParameterType.EQ_HIGH_FREQ):
|
||||||
|
return freq_to_normalized(value)
|
||||||
|
|
||||||
|
# Q → linear 0–1 (0.1–6.0)
|
||||||
|
case (ParameterType.EQ_LOW_Q | ParameterType.EQ_MID_Q | ParameterType.EQ_HIGH_Q):
|
||||||
|
return (value - 0.1) / 5.9
|
||||||
|
|
||||||
|
# Ratio → log scale (1–20)
|
||||||
|
case ParameterType.COMP_RATIO:
|
||||||
|
import math
|
||||||
|
return math.log(value) / math.log(20.0)
|
||||||
|
|
||||||
|
# Attack/Release ms → 0–1
|
||||||
|
case (ParameterType.COMP_ATTACK | ParameterType.COMP_RELEASE):
|
||||||
|
return time_ms_to_normalized(value)
|
||||||
|
|
||||||
|
# Volume, FX sends → dB to 0–1
|
||||||
|
case (ParameterType.VOLUME | ParameterType.FX_SEND_A | ParameterType.FX_SEND_B):
|
||||||
|
return _normalize_param(value, param_type)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
return value # pass-through
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_param(value: float, param_type: ParameterType) -> float:
|
||||||
|
"""Normalize a parameter to 0–1 given its type's range."""
|
||||||
|
# Default ranges per ParameterType
|
||||||
|
ranges: dict[ParameterType, tuple[float, float]] = {
|
||||||
|
ParameterType.GAIN: (-20.0, 60.0),
|
||||||
|
ParameterType.GATE_THRESHOLD: (-80.0, 0.0),
|
||||||
|
ParameterType.GATE_RANGE: (-80.0, 0.0),
|
||||||
|
ParameterType.COMP_THRESHOLD: (-60.0, 0.0),
|
||||||
|
ParameterType.COMP_GAIN: (-20.0, 20.0),
|
||||||
|
ParameterType.EQ_LOW_GAIN: (-15.0, 15.0),
|
||||||
|
ParameterType.EQ_MID_GAIN: (-15.0, 15.0),
|
||||||
|
ParameterType.EQ_HIGH_GAIN: (-15.0, 15.0),
|
||||||
|
ParameterType.VOLUME: (-60.0, 12.0),
|
||||||
|
ParameterType.FX_SEND_A: (-60.0, 12.0),
|
||||||
|
ParameterType.FX_SEND_B: (-60.0, 12.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
lo, hi = ranges.get(param_type, (0.0, 1.0))
|
||||||
|
if hi == lo:
|
||||||
|
return 0.5
|
||||||
|
return max(0.0, min(1.0, (value - lo) / (hi - lo)))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_osc_commands(
|
||||||
|
plugins: dict[str, CarlaPluginInfo],
|
||||||
|
channel: int,
|
||||||
|
param_type: ParameterType,
|
||||||
|
value: float,
|
||||||
|
) -> list[tuple[int, int, float]]:
|
||||||
|
"""Convert a parameter change to OSC commands.
|
||||||
|
|
||||||
|
Returns list of (plugin_id, param_index, normalized_value).
|
||||||
|
"""
|
||||||
|
role_and_param = _PARAM_TO_OSC.get(param_type)
|
||||||
|
if not role_and_param:
|
||||||
|
return []
|
||||||
|
|
||||||
|
role, param_name = role_and_param
|
||||||
|
plugin = plugins.get(role)
|
||||||
|
if not plugin:
|
||||||
|
return []
|
||||||
|
|
||||||
|
param_index = plugin.param_map.get(param_name)
|
||||||
|
if param_index is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized = _transform_value(param_type, value)
|
||||||
|
return [(plugin.plugin_id, param_index, normalized)]
|
||||||
|
|
||||||
|
|
||||||
|
def _state_to_osc_updates(
|
||||||
|
plugins: dict[str, CarlaPluginInfo],
|
||||||
|
state: ChannelState,
|
||||||
|
) -> list[tuple[int, int, float]]:
|
||||||
|
"""Convert all channel state to OSC updates."""
|
||||||
|
updates = []
|
||||||
|
for param_type, (role, param_name) in _PARAM_TO_OSC.items():
|
||||||
|
plugin = plugins.get(role)
|
||||||
|
if not plugin:
|
||||||
|
continue
|
||||||
|
param_index = plugin.param_map.get(param_name)
|
||||||
|
if param_index is None:
|
||||||
|
continue
|
||||||
|
raw_value = _get_state_value(state, param_type)
|
||||||
|
normalized = _transform_value(param_type, raw_value)
|
||||||
|
updates.append((plugin.plugin_id, param_index, normalized))
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
def _get_state_value(state: ChannelState, pt: ParameterType) -> float:
|
||||||
|
"""Read a parameter value from channel state."""
|
||||||
|
mapping = {
|
||||||
|
ParameterType.GATE_THRESHOLD: state.gate_threshold,
|
||||||
|
ParameterType.GATE_RANGE: state.gate_range,
|
||||||
|
ParameterType.EQ_LOW_FREQ: state.eq_low_freq,
|
||||||
|
ParameterType.EQ_LOW_GAIN: state.eq_low_gain,
|
||||||
|
ParameterType.EQ_LOW_Q: state.eq_low_q,
|
||||||
|
ParameterType.EQ_MID_FREQ: state.eq_mid_freq,
|
||||||
|
ParameterType.EQ_MID_GAIN: state.eq_mid_gain,
|
||||||
|
ParameterType.EQ_MID_Q: state.eq_mid_q,
|
||||||
|
ParameterType.EQ_HIGH_FREQ: state.eq_high_freq,
|
||||||
|
ParameterType.EQ_HIGH_GAIN: state.eq_high_gain,
|
||||||
|
ParameterType.EQ_HIGH_Q: state.eq_high_q,
|
||||||
|
ParameterType.COMP_THRESHOLD: state.comp_threshold,
|
||||||
|
ParameterType.COMP_RATIO: state.comp_ratio,
|
||||||
|
ParameterType.COMP_ATTACK: state.comp_attack,
|
||||||
|
ParameterType.COMP_RELEASE: state.comp_release,
|
||||||
|
ParameterType.COMP_GAIN: state.comp_gain,
|
||||||
|
ParameterType.GAIN: state.gain,
|
||||||
|
ParameterType.VOLUME: state.volume,
|
||||||
|
ParameterType.FX_SEND_A: state.fx_send_a,
|
||||||
|
ParameterType.FX_SEND_B: state.fx_send_b,
|
||||||
|
}
|
||||||
|
return mapping.get(pt, 0.0)
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
"""DSP Engine — main mixer orchestrator.
|
||||||
|
|
||||||
|
The DSPEngine is the central controller that ties together:
|
||||||
|
- Channel strips (parameter state + Carla OSC control)
|
||||||
|
- Routing matrix (JACK port connections)
|
||||||
|
- Bus manager (aux, subgroups, VCA, master)
|
||||||
|
- Fader automation (recording + playback)
|
||||||
|
- Parameter registry (MIDI → parameter dispatch)
|
||||||
|
|
||||||
|
It receives parameter changes from MIDI (via the ParameterRegistry
|
||||||
|
callback) and dispatches them to the appropriate channel strip,
|
||||||
|
bus, and routing components.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
MIDI Engine → ParameterRegistry → DSPEngine.handle_parameter()
|
||||||
|
├── ChannelStrip.set_parameter() → Carla OSC
|
||||||
|
├── BusManager (aux send/return levels)
|
||||||
|
├── RoutingMatrix (mute/solo/gain)
|
||||||
|
└── FaderAutomation.record()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..midi.types import (
|
||||||
|
ParameterType,
|
||||||
|
ParameterCategory,
|
||||||
|
MixerParameter,
|
||||||
|
)
|
||||||
|
from .osc_client import CarlaOSCClient, DEFAULT_PLUGIN_LAYOUT
|
||||||
|
from .channel_strip import ChannelStrip, ChannelState
|
||||||
|
from .routing_matrix import RoutingMatrix, RouteNode, NodeType
|
||||||
|
from .bus_manager import BusManager, MasterBus, AuxBus, SubgroupBus, VCAGroup
|
||||||
|
from .fader_automation import FaderAutomation, Scene
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── DSP Engine ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DSPEngineConfig:
|
||||||
|
"""Configuration for the DSP engine."""
|
||||||
|
num_channels: int = 16
|
||||||
|
num_aux: int = 4
|
||||||
|
num_subgroups: int = 2
|
||||||
|
num_vca: int = 2
|
||||||
|
osc_host: str = "127.0.0.1"
|
||||||
|
osc_port: int = 22752
|
||||||
|
osc_enabled: bool = True
|
||||||
|
jack_routing_enabled: bool = True
|
||||||
|
automation_enabled: bool = True
|
||||||
|
samplerate: int = 48000
|
||||||
|
buffer_size: int = 256
|
||||||
|
|
||||||
|
|
||||||
|
class DSPEngine:
|
||||||
|
"""Main mixer DSP engine.
|
||||||
|
|
||||||
|
Central controller for all audio processing and routing.
|
||||||
|
Designed to run on a Raspberry Pi 4B with:
|
||||||
|
- Carla rack for plugin processing (EQ, comp, gate, FX)
|
||||||
|
- JACK for audio routing
|
||||||
|
- OSC for Carla parameter control
|
||||||
|
- Python for orchestration and MIDI integration
|
||||||
|
|
||||||
|
Thread-safe for concurrent parameter updates from MIDI callbacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: DSPEngineConfig | None = None):
|
||||||
|
self.config = config or DSPEngineConfig()
|
||||||
|
|
||||||
|
# Components
|
||||||
|
self.osc: Optional[CarlaOSCClient] = None
|
||||||
|
self.channels: list[ChannelStrip] = []
|
||||||
|
self.routing = RoutingMatrix(name="dsp-engine")
|
||||||
|
self.buses = BusManager(
|
||||||
|
num_channels=self.config.num_channels,
|
||||||
|
num_aux=self.config.num_aux,
|
||||||
|
num_subgroups=self.config.num_subgroups,
|
||||||
|
num_vca=self.config.num_vca,
|
||||||
|
)
|
||||||
|
self.automation = FaderAutomation()
|
||||||
|
|
||||||
|
# Lifecycle
|
||||||
|
self._running = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._start_time: float = 0.0
|
||||||
|
self._param_count: int = 0
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
self._init_channels()
|
||||||
|
self._init_routing()
|
||||||
|
self._init_callbacks()
|
||||||
|
|
||||||
|
logger.info("DSP engine initialized: %d channels, %d aux, %d subgroups, %d VCA",
|
||||||
|
self.config.num_channels, self.config.num_aux,
|
||||||
|
self.config.num_subgroups, self.config.num_vca)
|
||||||
|
|
||||||
|
def _init_channels(self) -> None:
|
||||||
|
"""Create channel strips."""
|
||||||
|
self.channels = [
|
||||||
|
ChannelStrip(index=i) for i in range(self.config.num_channels)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _init_routing(self) -> None:
|
||||||
|
"""Build default routing matrix."""
|
||||||
|
self.routing.build_default_8ch_matrix()
|
||||||
|
|
||||||
|
def _init_callbacks(self) -> None:
|
||||||
|
"""Set up internal callbacks."""
|
||||||
|
pass # Callbacks are registered externally via handle_parameter()
|
||||||
|
|
||||||
|
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start(self, connect_osc: bool = True) -> None:
|
||||||
|
"""Start the DSP engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connect_osc: If True, establish OSC connection to Carla.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
|
||||||
|
if connect_osc and self.config.osc_enabled:
|
||||||
|
self.osc = CarlaOSCClient(
|
||||||
|
host=self.config.osc_host,
|
||||||
|
port=self.config.osc_port,
|
||||||
|
)
|
||||||
|
self._bind_osc_to_channels()
|
||||||
|
logger.info("OSC client connected to %s:%d",
|
||||||
|
self.config.osc_host, self.config.osc_port)
|
||||||
|
|
||||||
|
# Apply initial routing
|
||||||
|
if self.config.jack_routing_enabled:
|
||||||
|
stats = self.routing.apply_full_matrix()
|
||||||
|
logger.info("JACK routing applied: %s", stats)
|
||||||
|
|
||||||
|
logger.info("DSP engine started (sr=%d, buf=%d)",
|
||||||
|
self.config.samplerate, self.config.buffer_size)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the DSP engine and clean up."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# Disconnect JACK routing
|
||||||
|
if self.config.jack_routing_enabled:
|
||||||
|
self.routing.disconnect_all()
|
||||||
|
logger.info("JACK connections disconnected")
|
||||||
|
|
||||||
|
# Close OSC
|
||||||
|
if self.osc:
|
||||||
|
self.osc.close()
|
||||||
|
self.osc = None
|
||||||
|
|
||||||
|
uptime = time.monotonic() - self._start_time
|
||||||
|
logger.info("DSP engine stopped (uptime: %.1fs, params: %d)",
|
||||||
|
uptime, self._param_count)
|
||||||
|
|
||||||
|
def _bind_osc_to_channels(self) -> None:
|
||||||
|
"""Bind OSC client to channel strips based on plugin layout."""
|
||||||
|
if not self.osc:
|
||||||
|
return
|
||||||
|
|
||||||
|
for plugin_info in DEFAULT_PLUGIN_LAYOUT:
|
||||||
|
if plugin_info.channel >= 0 and plugin_info.channel < len(self.channels):
|
||||||
|
ch = self.channels[plugin_info.channel]
|
||||||
|
ch.bind_osc(self.osc)
|
||||||
|
ch.register_plugin(plugin_info)
|
||||||
|
|
||||||
|
logger.info("OSC bound to %d channels", len(self.channels))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
# ── Parameter handling ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def handle_parameter(
|
||||||
|
self,
|
||||||
|
param: MixerParameter,
|
||||||
|
value: float,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a parameter change from the ParameterRegistry.
|
||||||
|
|
||||||
|
This is the primary entry point for all parameter changes,
|
||||||
|
whether from MIDI controllers, UI, or automation playback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param: The mixer parameter being changed.
|
||||||
|
value: New value in the parameter's native range.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._param_count += 1
|
||||||
|
|
||||||
|
cat = param.category
|
||||||
|
ch = param.channel
|
||||||
|
pt = param.param_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Dispatch based on category
|
||||||
|
if cat == ParameterCategory.CHANNEL and ch >= 0 and ch < len(self.channels):
|
||||||
|
self._handle_channel_param(ch, pt, value)
|
||||||
|
|
||||||
|
elif cat == ParameterCategory.MASTER:
|
||||||
|
self._handle_master_param(pt, value)
|
||||||
|
|
||||||
|
elif cat == ParameterCategory.FX:
|
||||||
|
self._handle_fx_param(pt, value)
|
||||||
|
|
||||||
|
elif cat == ParameterCategory.ROUTING:
|
||||||
|
self._handle_routing_param(pt, ch, value)
|
||||||
|
|
||||||
|
elif cat == ParameterCategory.TRANSPORT:
|
||||||
|
self._handle_transport_param(pt, value)
|
||||||
|
|
||||||
|
elif cat == ParameterCategory.UTILITY:
|
||||||
|
self._handle_utility_param(pt, value)
|
||||||
|
|
||||||
|
# Record automation
|
||||||
|
if self.config.automation_enabled and self.automation.is_recording:
|
||||||
|
key = _make_param_key(cat, ch, pt)
|
||||||
|
self.automation.record(key, value)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error handling param %s CH%d = %0.2f: %s",
|
||||||
|
pt.value, ch, value, exc)
|
||||||
|
|
||||||
|
def _handle_channel_param(self, ch: int, pt: ParameterType, value: float) -> None:
|
||||||
|
"""Handle a channel-level parameter change."""
|
||||||
|
strip = self.channels[ch]
|
||||||
|
|
||||||
|
# Handle volume/mute/solo through routing matrix
|
||||||
|
match pt:
|
||||||
|
case ParameterType.VOLUME:
|
||||||
|
strip.set_parameter(pt, value)
|
||||||
|
# Update routing gain for this channel → master
|
||||||
|
self.routing.set_gain(
|
||||||
|
f"ch_{ch}_to_master", "master_input",
|
||||||
|
value, # dB
|
||||||
|
)
|
||||||
|
case ParameterType.MUTE:
|
||||||
|
strip.set_parameter(pt, value)
|
||||||
|
self.routing.set_mute(
|
||||||
|
f"ch_{ch}_to_master", "master_input",
|
||||||
|
value >= 0.5,
|
||||||
|
)
|
||||||
|
case ParameterType.SOLO:
|
||||||
|
strip.set_parameter(pt, value)
|
||||||
|
self.routing.set_solo(f"ch_{ch}_to_master", value >= 0.5)
|
||||||
|
case ParameterType.FX_SEND_A:
|
||||||
|
strip.set_parameter(pt, value)
|
||||||
|
self.buses.set_aux_send(0, ch, value)
|
||||||
|
case ParameterType.FX_SEND_B:
|
||||||
|
strip.set_parameter(pt, value)
|
||||||
|
self.buses.set_aux_send(1, ch, value)
|
||||||
|
case _:
|
||||||
|
# EQ, comp, gate — handled by channel strip (OSC)
|
||||||
|
strip.set_parameter(pt, value)
|
||||||
|
|
||||||
|
def _handle_master_param(self, pt: ParameterType, value: float) -> None:
|
||||||
|
"""Handle master bus parameter changes."""
|
||||||
|
match pt:
|
||||||
|
case ParameterType.MASTER_VOLUME:
|
||||||
|
self.buses.set_master_volume(value)
|
||||||
|
case ParameterType.MASTER_MUTE:
|
||||||
|
self.buses.set_master_mute(value >= 0.5)
|
||||||
|
case ParameterType.MASTER_DIM:
|
||||||
|
self.buses.set_master_dim(value >= 0.5)
|
||||||
|
case ParameterType.MONITOR_VOLUME:
|
||||||
|
# Monitor volume — would control a separate monitor bus
|
||||||
|
logger.debug("Monitor volume: %0.1f dB", value)
|
||||||
|
case ParameterType.PHONES_VOLUME:
|
||||||
|
# Headphone volume
|
||||||
|
logger.debug("Phones volume: %0.1f dB", value)
|
||||||
|
case _:
|
||||||
|
logger.debug("Unhandled master param: %s = %0.2f", pt.value, value)
|
||||||
|
|
||||||
|
def _handle_fx_param(self, pt: ParameterType, value: float) -> None:
|
||||||
|
"""Handle FX return parameter changes."""
|
||||||
|
match pt:
|
||||||
|
case ParameterType.FX_RETURN_A:
|
||||||
|
self.buses.set_aux_return(0, value)
|
||||||
|
case ParameterType.FX_RETURN_B:
|
||||||
|
self.buses.set_aux_return(1, value)
|
||||||
|
case _:
|
||||||
|
logger.debug("Unhandled FX param: %s = %0.2f", pt.value, value)
|
||||||
|
|
||||||
|
def _handle_routing_param(self, pt: ParameterType, ch: int, value: float) -> None:
|
||||||
|
"""Handle routing parameter changes."""
|
||||||
|
# Routing parameters for source assignment, output mapping, etc.
|
||||||
|
logger.debug("Routing param: %s CH%d = %0.2f", pt.value, ch, value)
|
||||||
|
|
||||||
|
def _handle_transport_param(self, pt: ParameterType, value: float) -> None:
|
||||||
|
"""Handle transport parameter changes."""
|
||||||
|
match pt:
|
||||||
|
case ParameterType.PLAY:
|
||||||
|
if value >= 0.5:
|
||||||
|
self.automation.start_playback()
|
||||||
|
case ParameterType.STOP:
|
||||||
|
if value >= 0.5:
|
||||||
|
self.automation.stop_playback()
|
||||||
|
case ParameterType.RECORD:
|
||||||
|
if value >= 0.5:
|
||||||
|
self.automation.start_recording(keep_existing=True)
|
||||||
|
else:
|
||||||
|
self.automation.stop_recording()
|
||||||
|
case ParameterType.LOOP:
|
||||||
|
logger.debug("Loop toggle: %s", value >= 0.5)
|
||||||
|
case ParameterType.TEMPO:
|
||||||
|
logger.debug("Tempo: %0.1f BPM", value)
|
||||||
|
case _:
|
||||||
|
logger.debug("Transport param: %s = %0.2f", pt.value, value)
|
||||||
|
|
||||||
|
def _handle_utility_param(self, pt: ParameterType, value: float) -> None:
|
||||||
|
"""Handle utility parameter changes."""
|
||||||
|
match pt:
|
||||||
|
case ParameterType.SNAPSHOT_LOAD:
|
||||||
|
scene_idx = int(value)
|
||||||
|
scenes = self.automation.list_scenes()
|
||||||
|
if 0 <= scene_idx < len(scenes):
|
||||||
|
self.load_snapshot(scenes[scene_idx])
|
||||||
|
case ParameterType.SNAPSHOT_SAVE:
|
||||||
|
scene_idx = int(value)
|
||||||
|
self.save_snapshot(f"Scene {scene_idx}")
|
||||||
|
case ParameterType.SCENE_NEXT:
|
||||||
|
self.next_scene()
|
||||||
|
case ParameterType.SCENE_PREV:
|
||||||
|
self.prev_scene()
|
||||||
|
case _:
|
||||||
|
logger.debug("Utility param: %s = %0.2f", pt.value, value)
|
||||||
|
|
||||||
|
# ── Channel access ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_channel(self, index: int) -> ChannelStrip | None:
|
||||||
|
if 0 <= index < len(self.channels):
|
||||||
|
return self.channels[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_channel_state(self, index: int) -> ChannelState | None:
|
||||||
|
ch = self.get_channel(index)
|
||||||
|
return ch.state if ch else None
|
||||||
|
|
||||||
|
# ── Snapshot / Scene management ─────────────────────────────────────
|
||||||
|
|
||||||
|
def save_snapshot(self, name: str) -> Scene:
|
||||||
|
"""Save current mixer state as a named snapshot."""
|
||||||
|
channel_states = {
|
||||||
|
i: dict(ch.state.__dict__) # copy to avoid live-mutation
|
||||||
|
for i, ch in enumerate(self.channels)
|
||||||
|
}
|
||||||
|
bus_state = self.buses.to_dict()
|
||||||
|
routing_state = self.routing.to_dict()
|
||||||
|
|
||||||
|
return self.automation.save_scene(
|
||||||
|
name, channel_states, bus_state, routing_state
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_snapshot(self, name: str) -> bool:
|
||||||
|
"""Load a named snapshot, restoring all mixer state."""
|
||||||
|
scene = self.automation.recall_scene(name)
|
||||||
|
if not scene:
|
||||||
|
logger.warning("Scene not found: %s", name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Restore channel states
|
||||||
|
for ch_idx, state_dict in scene.channel_states.items():
|
||||||
|
if 0 <= ch_idx < len(self.channels):
|
||||||
|
cs = ChannelState(**state_dict)
|
||||||
|
self.channels[ch_idx].restore(cs, send_osc=True)
|
||||||
|
|
||||||
|
# Restore bus state
|
||||||
|
self.buses.from_dict(scene.bus_state)
|
||||||
|
|
||||||
|
# Restore routing
|
||||||
|
if scene.routing_state:
|
||||||
|
self.routing.from_dict(scene.routing_state)
|
||||||
|
if self.config.jack_routing_enabled:
|
||||||
|
self.routing.apply_full_matrix()
|
||||||
|
|
||||||
|
logger.info("Snapshot loaded: %s", name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def crossfade_to(self, scene_name: str, duration: float = 2.0) -> bool:
|
||||||
|
"""Crossfade to a saved scene over a duration."""
|
||||||
|
return self.automation.crossfade_to(scene_name, duration)
|
||||||
|
|
||||||
|
def next_scene(self) -> bool:
|
||||||
|
"""Recall the next scene alphabetically."""
|
||||||
|
scenes = self.automation.list_scenes()
|
||||||
|
if not scenes:
|
||||||
|
return False
|
||||||
|
if self.automation._current_scene is None:
|
||||||
|
return self.load_snapshot(scenes[0])
|
||||||
|
try:
|
||||||
|
idx = scenes.index(self.automation._current_scene)
|
||||||
|
next_idx = (idx + 1) % len(scenes)
|
||||||
|
return self.load_snapshot(scenes[next_idx])
|
||||||
|
except ValueError:
|
||||||
|
return self.load_snapshot(scenes[0])
|
||||||
|
|
||||||
|
def prev_scene(self) -> bool:
|
||||||
|
"""Recall the previous scene alphabetically."""
|
||||||
|
scenes = self.automation.list_scenes()
|
||||||
|
if not scenes:
|
||||||
|
return False
|
||||||
|
if self.automation._current_scene is None:
|
||||||
|
return self.load_snapshot(scenes[-1])
|
||||||
|
try:
|
||||||
|
idx = scenes.index(self.automation._current_scene)
|
||||||
|
prev_idx = (idx - 1) % len(scenes)
|
||||||
|
return self.load_snapshot(scenes[prev_idx])
|
||||||
|
except ValueError:
|
||||||
|
return self.load_snapshot(scenes[0])
|
||||||
|
|
||||||
|
# ── Automation tick ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def tick(self) -> None:
|
||||||
|
"""Process one automation engine tick.
|
||||||
|
|
||||||
|
Call this periodically (e.g., every 10-50ms from a timer or
|
||||||
|
the JACK process callback) to advance automation playback
|
||||||
|
and crossfades.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update crossfade
|
||||||
|
if self.automation._crossfade_active:
|
||||||
|
self.automation.update_crossfade()
|
||||||
|
|
||||||
|
# If automation is playing, read values and apply them
|
||||||
|
if self.automation.is_playing:
|
||||||
|
self._apply_automation()
|
||||||
|
|
||||||
|
def _apply_automation(self) -> None:
|
||||||
|
"""Apply automation values to parameters."""
|
||||||
|
# Iterate over lanes and apply values
|
||||||
|
for key, lane in self.automation._lanes.items():
|
||||||
|
if not lane.points:
|
||||||
|
continue
|
||||||
|
value = self.automation.get_value(key)
|
||||||
|
cat, ch, pt = _parse_param_key(key)
|
||||||
|
if cat is None or pt is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build a MixerParameter and handle it
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=pt,
|
||||||
|
category=cat,
|
||||||
|
channel=ch,
|
||||||
|
)
|
||||||
|
self.handle_parameter(param, value)
|
||||||
|
|
||||||
|
# ── Stats ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict:
|
||||||
|
uptime = time.monotonic() - self._start_time if self._start_time else 0
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"uptime_seconds": round(uptime, 1),
|
||||||
|
"param_count": self._param_count,
|
||||||
|
"num_channels": len(self.channels),
|
||||||
|
"num_aux": self.config.num_aux,
|
||||||
|
"osc_connected": self.osc is not None,
|
||||||
|
"osc_stats": self.osc.stats if self.osc else {},
|
||||||
|
"automation_active": self.automation.is_playing,
|
||||||
|
"automation_recording": self.automation.is_recording,
|
||||||
|
"scenes": self.automation.list_scenes(),
|
||||||
|
"samplerate": self.config.samplerate,
|
||||||
|
"buffer_size": self.config.buffer_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize full engine state."""
|
||||||
|
return {
|
||||||
|
"channels": {i: ch.state.__dict__ for i, ch in enumerate(self.channels)},
|
||||||
|
"buses": self.buses.to_dict(),
|
||||||
|
"routing": self.routing.to_dict(),
|
||||||
|
"automation": self.automation.to_dict(),
|
||||||
|
"config": {
|
||||||
|
"num_channels": self.config.num_channels,
|
||||||
|
"num_aux": self.config.num_aux,
|
||||||
|
"num_subgroups": self.config.num_subgroups,
|
||||||
|
"num_vca": self.config.num_vca,
|
||||||
|
"samplerate": self.config.samplerate,
|
||||||
|
"buffer_size": self.config.buffer_size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def from_dict(self, data: dict) -> None:
|
||||||
|
"""Restore engine state from a dict."""
|
||||||
|
# Restore channels
|
||||||
|
for ch_idx, state_dict in data.get("channels", {}).items():
|
||||||
|
ch_idx = int(ch_idx)
|
||||||
|
if 0 <= ch_idx < len(self.channels):
|
||||||
|
self.channels[ch_idx].restore(
|
||||||
|
ChannelState(**state_dict),
|
||||||
|
send_osc=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restore buses
|
||||||
|
self.buses.from_dict(data.get("buses", {}))
|
||||||
|
|
||||||
|
# Restore routing
|
||||||
|
if "routing" in data:
|
||||||
|
self.routing.from_dict(data["routing"])
|
||||||
|
|
||||||
|
# Restore automation
|
||||||
|
if "automation" in data:
|
||||||
|
self.automation.from_dict(data["automation"])
|
||||||
|
|
||||||
|
logger.info("Engine state restored")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter key helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_param_key(cat: ParameterCategory, ch: int, pt: ParameterType) -> str:
|
||||||
|
"""Create a unique key for a parameter in automation."""
|
||||||
|
ch_str = str(ch) if ch >= 0 else "master"
|
||||||
|
return f"{cat.value}_{ch_str}_{pt.value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_param_key(key: str) -> tuple[ParameterCategory | None, int, ParameterType | None]:
|
||||||
|
"""Parse an automation parameter key back into components."""
|
||||||
|
try:
|
||||||
|
parts = key.split("_", 2)
|
||||||
|
cat_str = parts[0]
|
||||||
|
ch_str = parts[1]
|
||||||
|
pt_str = parts[2]
|
||||||
|
|
||||||
|
cat = ParameterCategory(cat_str)
|
||||||
|
ch = int(ch_str) if ch_str != "master" else -1
|
||||||
|
pt = ParameterType(pt_str)
|
||||||
|
return cat, ch, pt
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None, -1, None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Factory ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_engine(
|
||||||
|
num_channels: int = 16,
|
||||||
|
osc_enabled: bool = True,
|
||||||
|
) -> DSPEngine:
|
||||||
|
"""Create a DSP engine with sensible defaults for RPi4B."""
|
||||||
|
config = DSPEngineConfig(
|
||||||
|
num_channels=num_channels,
|
||||||
|
num_aux=4,
|
||||||
|
num_subgroups=2,
|
||||||
|
num_vca=2,
|
||||||
|
osc_enabled=osc_enabled,
|
||||||
|
jack_routing_enabled=True,
|
||||||
|
automation_enabled=True,
|
||||||
|
samplerate=48000,
|
||||||
|
buffer_size=256,
|
||||||
|
)
|
||||||
|
return DSPEngine(config)
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
"""Fader automation engine.
|
||||||
|
|
||||||
|
Records and plays back parameter automation over time. Supports:
|
||||||
|
- Recording: capture parameter changes with timestamps
|
||||||
|
- Playback: interpolate between recorded points
|
||||||
|
- Interpolation: linear, logarithmic, S-curve
|
||||||
|
- Scenes/snapshots: store and recall full mixer state
|
||||||
|
- Crossfade: smooth transition between scenes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class InterpolationMode(StrEnum):
|
||||||
|
LINEAR = "linear"
|
||||||
|
LOGARITHMIC = "logarithmic"
|
||||||
|
S_CURVE = "s_curve" # smoothstep
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AutomationPoint:
|
||||||
|
"""A single automation data point."""
|
||||||
|
time_sec: float # time in seconds
|
||||||
|
value: float # parameter value
|
||||||
|
mode: InterpolationMode = InterpolationMode.LINEAR
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AutomationLane:
|
||||||
|
"""An automation lane for one parameter on one channel/bus.
|
||||||
|
|
||||||
|
Each lane is a sorted list of automation points. Playback
|
||||||
|
interpolates between points based on current time.
|
||||||
|
"""
|
||||||
|
param_key: str # e.g., "ch_0_volume", "master_volume"
|
||||||
|
points: list[AutomationPoint] = field(default_factory=list)
|
||||||
|
is_recording: bool = False
|
||||||
|
record_start_time: float = 0.0
|
||||||
|
|
||||||
|
def add_point(self, time_sec: float, value: float,
|
||||||
|
mode: InterpolationMode = InterpolationMode.LINEAR) -> None:
|
||||||
|
"""Add an automation point, maintaining time-sorted order."""
|
||||||
|
point = AutomationPoint(time_sec=time_sec, value=value, mode=mode)
|
||||||
|
idx = bisect.bisect_left([p.time_sec for p in self.points], time_sec)
|
||||||
|
self.points.insert(idx, point)
|
||||||
|
|
||||||
|
def get_value_at(self, time_sec: float, default: float = 0.0) -> float:
|
||||||
|
"""Get the interpolated value at a given time."""
|
||||||
|
if not self.points:
|
||||||
|
return default
|
||||||
|
|
||||||
|
if time_sec <= self.points[0].time_sec:
|
||||||
|
return self.points[0].value
|
||||||
|
|
||||||
|
if time_sec >= self.points[-1].time_sec:
|
||||||
|
return self.points[-1].value
|
||||||
|
|
||||||
|
# Find surrounding points
|
||||||
|
idx = bisect.bisect_right([p.time_sec for p in self.points], time_sec)
|
||||||
|
if idx <= 0:
|
||||||
|
return self.points[0].value
|
||||||
|
if idx >= len(self.points):
|
||||||
|
return self.points[-1].value
|
||||||
|
|
||||||
|
before = self.points[idx - 1]
|
||||||
|
after = self.points[idx]
|
||||||
|
|
||||||
|
# Calculate interpolation factor
|
||||||
|
duration = after.time_sec - before.time_sec
|
||||||
|
if duration <= 0:
|
||||||
|
return after.value
|
||||||
|
t = (time_sec - before.time_sec) / duration
|
||||||
|
|
||||||
|
return _interpolate(t, before.value, after.value, after.mode)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.points.clear()
|
||||||
|
|
||||||
|
def clone(self) -> AutomationLane:
|
||||||
|
return AutomationLane(
|
||||||
|
param_key=self.param_key,
|
||||||
|
points=[AutomationPoint(
|
||||||
|
time_sec=p.time_sec, value=p.value, mode=p.mode
|
||||||
|
) for p in self.points],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _interpolate(t: float, v0: float, v1: float,
|
||||||
|
mode: InterpolationMode) -> float:
|
||||||
|
"""Interpolate between two values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
t: 0.0–1.0 interpolation factor.
|
||||||
|
v0: start value.
|
||||||
|
v1: end value.
|
||||||
|
mode: interpolation curve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Interpolated value.
|
||||||
|
"""
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
|
||||||
|
if mode == InterpolationMode.LINEAR:
|
||||||
|
return v0 + t * (v1 - v0)
|
||||||
|
|
||||||
|
elif mode == InterpolationMode.LOGARITHMIC:
|
||||||
|
# Log mapping for dB-like parameters
|
||||||
|
if v0 <= 0 or v1 <= 0:
|
||||||
|
return v0 + t * (v1 - v0) # fallback to linear
|
||||||
|
import math
|
||||||
|
log_v0 = math.log(v0)
|
||||||
|
log_v1 = math.log(v1)
|
||||||
|
return math.exp(log_v0 + t * (log_v1 - log_v0))
|
||||||
|
|
||||||
|
elif mode == InterpolationMode.S_CURVE:
|
||||||
|
# Smoothstep (Hermite interpolation)
|
||||||
|
t_smooth = t * t * (3.0 - 2.0 * t)
|
||||||
|
return v0 + t_smooth * (v1 - v0)
|
||||||
|
|
||||||
|
return v0 + t * (v1 - v0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scene / Snapshot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scene:
|
||||||
|
"""A saved mixer scene (snapshot of all parameters)."""
|
||||||
|
name: str
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
# Serialized state dicts
|
||||||
|
channel_states: dict[int, dict] = field(default_factory=dict) # ch_idx → state dict
|
||||||
|
bus_state: dict = field(default_factory=dict)
|
||||||
|
routing_state: dict = field(default_factory=dict)
|
||||||
|
automation_lanes: dict[str, list[dict]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"channel_states": {str(k): v for k, v in self.channel_states.items()},
|
||||||
|
"bus_state": self.bus_state,
|
||||||
|
"routing_state": self.routing_state,
|
||||||
|
"automation_lanes": self.automation_lanes,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> Scene:
|
||||||
|
return cls(
|
||||||
|
name=data["name"],
|
||||||
|
created_at=data.get("created_at", time.time()),
|
||||||
|
channel_states={int(k): v for k, v in data.get("channel_states", {}).items()},
|
||||||
|
bus_state=data.get("bus_state", {}),
|
||||||
|
routing_state=data.get("routing_state", {}),
|
||||||
|
automation_lanes=data.get("automation_lanes", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fader Automation Engine ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FaderAutomation:
|
||||||
|
"""Records and plays back fader automation.
|
||||||
|
|
||||||
|
Manages multiple automation lanes (one per parameter per channel),
|
||||||
|
supports real-time recording, time-synced playback, and scene
|
||||||
|
management (save/recall/crossfade).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
auto = FaderAutomation()
|
||||||
|
auto.start_recording()
|
||||||
|
# ... parameter changes happen ...
|
||||||
|
auto.record("ch_0_volume", -3.5)
|
||||||
|
auto.stop_recording()
|
||||||
|
|
||||||
|
# Playback
|
||||||
|
auto.start_playback()
|
||||||
|
value = auto.get_value("ch_0_volume", current_time)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lanes: dict[str, AutomationLane] = {}
|
||||||
|
self._playback_active: bool = False
|
||||||
|
self._playback_start_time: float = 0.0
|
||||||
|
self._recording_active: bool = False
|
||||||
|
self._record_start_time: float = 0.0
|
||||||
|
self._scenes: dict[str, Scene] = {}
|
||||||
|
self._current_scene: str | None = None
|
||||||
|
self._crossfade_active: bool = False
|
||||||
|
self._crossfade_start_time: float = 0.0
|
||||||
|
self._crossfade_duration: float = 0.0
|
||||||
|
self._crossfade_from: Scene | None = None
|
||||||
|
self._crossfade_to: Scene | None = None
|
||||||
|
|
||||||
|
# ── Lane management ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_lane(self, param_key: str) -> AutomationLane:
|
||||||
|
if param_key not in self._lanes:
|
||||||
|
self._lanes[param_key] = AutomationLane(param_key=param_key)
|
||||||
|
return self._lanes[param_key]
|
||||||
|
|
||||||
|
def record(self, param_key: str, value: float,
|
||||||
|
mode: InterpolationMode = InterpolationMode.LINEAR) -> None:
|
||||||
|
"""Record a parameter value at the current time."""
|
||||||
|
if not self._recording_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
lane = self._get_lane(param_key)
|
||||||
|
elapsed = time.monotonic() - self._record_start_time
|
||||||
|
lane.add_point(elapsed, value, mode)
|
||||||
|
|
||||||
|
def get_value(self, param_key: str, default: float = 0.0) -> float:
|
||||||
|
"""Get the current automated value for a parameter."""
|
||||||
|
# Crossfade takes priority
|
||||||
|
if self._crossfade_active:
|
||||||
|
return self._get_crossfade_value(param_key, default)
|
||||||
|
|
||||||
|
# Playback
|
||||||
|
if self._playback_active and param_key in self._lanes:
|
||||||
|
elapsed = time.monotonic() - self._playback_start_time
|
||||||
|
return self._lanes[param_key].get_value_at(elapsed, default)
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
# ── Recording ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_recording(self, keep_existing: bool = False) -> None:
|
||||||
|
"""Start recording automation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keep_existing: If False, clear existing lanes before recording.
|
||||||
|
"""
|
||||||
|
if not keep_existing:
|
||||||
|
self._lanes.clear()
|
||||||
|
self._recording_active = True
|
||||||
|
self._record_start_time = time.monotonic()
|
||||||
|
logger.info("Automation recording started")
|
||||||
|
|
||||||
|
def stop_recording(self) -> None:
|
||||||
|
self._recording_active = False
|
||||||
|
logger.info("Automation recording stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_recording(self) -> bool:
|
||||||
|
return self._recording_active
|
||||||
|
|
||||||
|
# ── Playback ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_playback(self, start_time: float | None = None,
|
||||||
|
loop: bool = False) -> None:
|
||||||
|
"""Start playback of recorded automation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: Offset into the automation timeline (seconds).
|
||||||
|
loop: If True, loop playback.
|
||||||
|
"""
|
||||||
|
self._playback_active = True
|
||||||
|
self._playback_start_time = time.monotonic() - (start_time or 0)
|
||||||
|
self._loop = loop
|
||||||
|
logger.info("Automation playback started")
|
||||||
|
|
||||||
|
def stop_playback(self) -> None:
|
||||||
|
self._playback_active = False
|
||||||
|
|
||||||
|
def seek(self, time_sec: float) -> None:
|
||||||
|
"""Seek to a specific time in the automation timeline."""
|
||||||
|
self._playback_start_time = time.monotonic() - time_sec
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return self._playback_active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_time(self) -> float:
|
||||||
|
"""Get current playback time."""
|
||||||
|
if self._playback_active:
|
||||||
|
elapsed = time.monotonic() - self._playback_start_time
|
||||||
|
|
||||||
|
# Handle looping
|
||||||
|
if self._loop:
|
||||||
|
max_time = self.get_duration()
|
||||||
|
if max_time > 0:
|
||||||
|
elapsed = elapsed % max_time
|
||||||
|
|
||||||
|
return elapsed
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_duration(self) -> float:
|
||||||
|
"""Get the total duration of recorded automation (longest lane)."""
|
||||||
|
max_time = 0.0
|
||||||
|
for lane in self._lanes.values():
|
||||||
|
if lane.points:
|
||||||
|
max_time = max(max_time, lane.points[-1].time_sec)
|
||||||
|
return max_time
|
||||||
|
|
||||||
|
# ── Scene management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def save_scene(self, name: str, channel_states: dict[int, dict],
|
||||||
|
bus_state: dict, routing_state: dict) -> Scene:
|
||||||
|
"""Save current mixer state as a scene."""
|
||||||
|
# Clone current automation lanes
|
||||||
|
lanes_data = {}
|
||||||
|
for key, lane in self._lanes.items():
|
||||||
|
lanes_data[key] = [
|
||||||
|
{"time_sec": p.time_sec, "value": p.value, "mode": p.mode.value}
|
||||||
|
for p in lane.points
|
||||||
|
]
|
||||||
|
|
||||||
|
scene = Scene(
|
||||||
|
name=name,
|
||||||
|
channel_states=channel_states,
|
||||||
|
bus_state=bus_state,
|
||||||
|
routing_state=routing_state,
|
||||||
|
automation_lanes=lanes_data,
|
||||||
|
)
|
||||||
|
self._scenes[name] = scene
|
||||||
|
logger.info("Scene saved: %s", name)
|
||||||
|
return scene
|
||||||
|
|
||||||
|
def load_scene(self, name: str) -> Scene | None:
|
||||||
|
"""Load a scene by name."""
|
||||||
|
return self._scenes.get(name)
|
||||||
|
|
||||||
|
def delete_scene(self, name: str) -> bool:
|
||||||
|
if name in self._scenes:
|
||||||
|
del self._scenes[name]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_scenes(self) -> list[str]:
|
||||||
|
return sorted(self._scenes.keys())
|
||||||
|
|
||||||
|
def recall_scene(self, name: str) -> Scene | None:
|
||||||
|
"""Recall a scene (restore automation lanes)."""
|
||||||
|
scene = self._scenes.get(name)
|
||||||
|
if not scene:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Restore automation lanes from scene
|
||||||
|
self._lanes.clear()
|
||||||
|
for key, points_data in scene.automation_lanes.items():
|
||||||
|
lane = AutomationLane(param_key=key)
|
||||||
|
for pd in points_data:
|
||||||
|
lane.add_point(
|
||||||
|
pd["time_sec"],
|
||||||
|
pd["value"],
|
||||||
|
InterpolationMode(pd.get("mode", "linear")),
|
||||||
|
)
|
||||||
|
self._lanes[key] = lane
|
||||||
|
|
||||||
|
self._current_scene = name
|
||||||
|
logger.info("Scene recalled: %s", name)
|
||||||
|
return scene
|
||||||
|
|
||||||
|
def crossfade_to(self, scene_name: str, duration_sec: float) -> bool:
|
||||||
|
"""Crossfade from current state to a saved scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scene_name: Target scene name.
|
||||||
|
duration_sec: Crossfade duration in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if crossfade started, False if scene not found.
|
||||||
|
"""
|
||||||
|
target = self._scenes.get(scene_name)
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Capture current state as 'from' scene
|
||||||
|
current_lanes = {}
|
||||||
|
for key, lane in self._lanes.items():
|
||||||
|
current_lanes[key] = [{
|
||||||
|
"time_sec": p.time_sec,
|
||||||
|
"value": p.value,
|
||||||
|
"mode": p.mode.value,
|
||||||
|
} for p in lane.points]
|
||||||
|
|
||||||
|
self._crossfade_from = Scene(
|
||||||
|
name="__crossfade_from__",
|
||||||
|
automation_lanes=current_lanes,
|
||||||
|
)
|
||||||
|
self._crossfade_to = target
|
||||||
|
self._crossfade_duration = duration_sec
|
||||||
|
self._crossfade_start_time = time.monotonic()
|
||||||
|
self._crossfade_active = True
|
||||||
|
logger.info("Crossfade started to '%s' (%0.1fs)", scene_name, duration_sec)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_crossfade_value(self, param_key: str, default: float) -> float:
|
||||||
|
"""Get interpolated value during an active crossfade."""
|
||||||
|
elapsed = time.monotonic() - self._crossfade_start_time
|
||||||
|
t = min(1.0, elapsed / self._crossfade_duration) if self._crossfade_duration > 0 else 1.0
|
||||||
|
|
||||||
|
# Get from value
|
||||||
|
from_value = default
|
||||||
|
if self._crossfade_from:
|
||||||
|
from_points = self._crossfade_from.automation_lanes.get(param_key, [])
|
||||||
|
if from_points:
|
||||||
|
from_value = from_points[-1]["value"]
|
||||||
|
|
||||||
|
# Get to value
|
||||||
|
to_value = default
|
||||||
|
if self._crossfade_to:
|
||||||
|
to_points = self._crossfade_to.automation_lanes.get(param_key, [])
|
||||||
|
if to_points:
|
||||||
|
to_value = to_points[-1]["value"]
|
||||||
|
|
||||||
|
# S-curve crossfade for smooth transition
|
||||||
|
return _interpolate(t, from_value, to_value, InterpolationMode.S_CURVE)
|
||||||
|
|
||||||
|
def update_crossfade(self) -> bool:
|
||||||
|
"""Check if crossfade is complete. Returns True while crossfade is active."""
|
||||||
|
if not self._crossfade_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - self._crossfade_start_time
|
||||||
|
if elapsed >= self._crossfade_duration:
|
||||||
|
self._crossfade_active = False
|
||||||
|
if self._crossfade_to:
|
||||||
|
self.recall_scene(self._crossfade_to.name)
|
||||||
|
self._current_scene = self._crossfade_to.name
|
||||||
|
self._crossfade_from = None
|
||||||
|
self._crossfade_to = None
|
||||||
|
logger.info("Crossfade complete")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cancel_crossfade(self) -> None:
|
||||||
|
self._crossfade_active = False
|
||||||
|
self._crossfade_from = None
|
||||||
|
self._crossfade_to = None
|
||||||
|
|
||||||
|
# ── Serialization ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
lanes_data = {}
|
||||||
|
for key, lane in self._lanes.items():
|
||||||
|
lanes_data[key] = [
|
||||||
|
{"time_sec": p.time_sec, "value": p.value, "mode": p.mode.value}
|
||||||
|
for p in lane.points
|
||||||
|
]
|
||||||
|
|
||||||
|
scenes_data = {name: s.to_dict() for name, s in self._scenes.items()}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lanes": lanes_data,
|
||||||
|
"scenes": scenes_data,
|
||||||
|
"current_scene": self._current_scene,
|
||||||
|
}
|
||||||
|
|
||||||
|
def from_dict(self, data: dict) -> None:
|
||||||
|
self._lanes.clear()
|
||||||
|
for key, points_data in data.get("lanes", {}).items():
|
||||||
|
lane = AutomationLane(param_key=key)
|
||||||
|
for pd in points_data:
|
||||||
|
lane.add_point(
|
||||||
|
pd["time_sec"],
|
||||||
|
pd["value"],
|
||||||
|
InterpolationMode(pd.get("mode", "linear")),
|
||||||
|
)
|
||||||
|
self._lanes[key] = lane
|
||||||
|
|
||||||
|
self._scenes.clear()
|
||||||
|
for name, sd in data.get("scenes", {}).items():
|
||||||
|
self._scenes[name] = Scene.from_dict(sd)
|
||||||
|
|
||||||
|
self._current_scene = data.get("current_scene")
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""OSC client for Carla Rack control.
|
||||||
|
|
||||||
|
Carla exposes OSC on port 22752 by default. This module provides a
|
||||||
|
lightweight OSC encoder/decoder and a client for setting plugin parameters,
|
||||||
|
loading programs, and querying state.
|
||||||
|
|
||||||
|
OSC 1.0 packet format (subset we need):
|
||||||
|
- Messages: /path\0\0\0 ,typetag\0\0\0 , args...
|
||||||
|
- Type tags: f=float32, i=int32, s=string, T=True, F=False
|
||||||
|
- All aligned to 4-byte boundaries
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── OSC protocol helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _pad_4(s: bytes) -> bytes:
|
||||||
|
"""Pad to 4-byte boundary with null bytes."""
|
||||||
|
rem = len(s) % 4
|
||||||
|
return s + b"\x00" * (4 - rem) if rem else s
|
||||||
|
|
||||||
|
|
||||||
|
def _osc_string(s: str) -> bytes:
|
||||||
|
"""Encode an OSC string (null-terminated, 4-byte aligned)."""
|
||||||
|
return _pad_4(s.encode("utf-8") + b"\x00")
|
||||||
|
|
||||||
|
|
||||||
|
def _osc_float(v: float) -> bytes:
|
||||||
|
"""Encode a 32-bit big-endian float."""
|
||||||
|
return struct.pack(">f", v)
|
||||||
|
|
||||||
|
|
||||||
|
def _osc_int(v: int) -> bytes:
|
||||||
|
"""Encode a 32-bit big-endian int."""
|
||||||
|
return struct.pack(">i", v)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_osc(address: str, *args) -> bytes:
|
||||||
|
"""Encode an OSC message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: OSC address pattern (e.g., '/Carla/1/set_parameter_value')
|
||||||
|
*args: Values — floats, ints, or strings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw OSC packet bytes.
|
||||||
|
"""
|
||||||
|
# Build type tag
|
||||||
|
type_tag = ","
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, float):
|
||||||
|
type_tag += "f"
|
||||||
|
elif isinstance(arg, int):
|
||||||
|
type_tag += "i"
|
||||||
|
elif isinstance(arg, str):
|
||||||
|
type_tag += "s"
|
||||||
|
elif isinstance(arg, bool):
|
||||||
|
type_tag += "T" if arg else "F"
|
||||||
|
else:
|
||||||
|
type_tag += "s"
|
||||||
|
args = list(args)
|
||||||
|
idx = args.index(arg)
|
||||||
|
args[idx] = str(arg)
|
||||||
|
|
||||||
|
packet = _osc_string(address) + _osc_string(type_tag)
|
||||||
|
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, float):
|
||||||
|
packet += _osc_float(arg)
|
||||||
|
elif isinstance(arg, int):
|
||||||
|
packet += _osc_int(arg)
|
||||||
|
elif isinstance(arg, str):
|
||||||
|
packet += _osc_string(arg)
|
||||||
|
# bool types (T/F) have no data bytes
|
||||||
|
|
||||||
|
return packet
|
||||||
|
|
||||||
|
|
||||||
|
def decode_osc(data: bytes) -> tuple[str, list[Any]]:
|
||||||
|
"""Decode an OSC message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(address, [args...])
|
||||||
|
"""
|
||||||
|
# Read address
|
||||||
|
null_pos = data.find(b"\x00")
|
||||||
|
if null_pos < 0:
|
||||||
|
raise ValueError("Invalid OSC message: no null terminator")
|
||||||
|
address = data[:null_pos].decode("utf-8")
|
||||||
|
pos = (null_pos + 4) & ~3 # align to 4
|
||||||
|
|
||||||
|
# Read type tag
|
||||||
|
type_start = data.find(b",", pos)
|
||||||
|
if type_start < 0:
|
||||||
|
return address, []
|
||||||
|
type_end = data.find(b"\x00", type_start)
|
||||||
|
type_tag = data[type_start + 1 : type_end].decode("utf-8")
|
||||||
|
pos = (type_end + 4) & ~3
|
||||||
|
|
||||||
|
args = []
|
||||||
|
for tag in type_tag:
|
||||||
|
if tag == "f":
|
||||||
|
args.append(struct.unpack(">f", data[pos : pos + 4])[0])
|
||||||
|
pos += 4
|
||||||
|
elif tag == "i":
|
||||||
|
args.append(struct.unpack(">i", data[pos : pos + 4])[0])
|
||||||
|
pos += 4
|
||||||
|
elif tag == "s":
|
||||||
|
null_end = data.find(b"\x00", pos)
|
||||||
|
s = data[pos:null_end].decode("utf-8")
|
||||||
|
args.append(s)
|
||||||
|
pos = (null_end + 4) & ~3
|
||||||
|
elif tag in ("T", "F"):
|
||||||
|
args.append(tag == "T")
|
||||||
|
# Skip unknown types (no data)
|
||||||
|
|
||||||
|
return address, args
|
||||||
|
|
||||||
|
|
||||||
|
# ── Carla OSC API constants ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Carla OSC paths (port 22752)
|
||||||
|
CARLA_SET_PARAM = "/Carla/1/set_parameter_value"
|
||||||
|
CARLA_SET_PROGRAM = "/Carla/1/set_program"
|
||||||
|
CARLA_SET_MIDI_PROGRAM = "/Carla/1/set_midi_program"
|
||||||
|
CARLA_NOTE_ON = "/Carla/1/note_on"
|
||||||
|
CARLA_NOTE_OFF = "/Carla/1/note_off"
|
||||||
|
CARLA_ACTIVATE = "/Carla/1/activate"
|
||||||
|
CARLA_DEACTIVATE = "/Carla/1/deactivate"
|
||||||
|
CARLA_SET_VOLUME = "/Carla/1/set_volume" # per-plugin volume
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin ID registry ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CarlaPluginInfo:
|
||||||
|
"""Maps a Carla plugin ID to its role in the mixer."""
|
||||||
|
plugin_id: int
|
||||||
|
name: str
|
||||||
|
role: str # 'gate', 'eq', 'comp', 'gain', 'nam', 'ir', 'reverb', 'delay', 'limiter'
|
||||||
|
channel: int = -1 # -1 for master/global plugins
|
||||||
|
param_map: dict[str, int] = field(default_factory=dict) # param_name → param_index
|
||||||
|
|
||||||
|
|
||||||
|
# Default 8-ch mixer plugin layout (matches carla-8ch-default.carxp)
|
||||||
|
# Plugin IDs are assigned by Carla in order they appear in the rack.
|
||||||
|
# This registry is loaded from config / carxp parsing at runtime.
|
||||||
|
DEFAULT_PLUGIN_LAYOUT: list[CarlaPluginInfo] = [
|
||||||
|
# Channel 1: Gate → EQ → Comp
|
||||||
|
CarlaPluginInfo(1, "Ch1 Gate", "gate", 0, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "range": 4, "makeup": 5}),
|
||||||
|
CarlaPluginInfo(2, "Ch1 EQ", "eq", 0, {"low_freq": 0, "low_gain": 1, "low_q": 2, "mid_freq": 3, "mid_gain": 4, "mid_q": 5, "high_freq": 6, "high_gain": 7, "high_q": 8}),
|
||||||
|
CarlaPluginInfo(3, "Ch1 Comp", "comp", 0, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "makeup": 4, "knee": 5}),
|
||||||
|
# Channel 2: Gate → EQ → Comp
|
||||||
|
CarlaPluginInfo(4, "Ch2 Gate", "gate", 1, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "range": 4, "makeup": 5}),
|
||||||
|
CarlaPluginInfo(5, "Ch2 EQ", "eq", 1, {"low_freq": 0, "low_gain": 1, "low_q": 2, "mid_freq": 3, "mid_gain": 4, "mid_q": 5, "high_freq": 6, "high_gain": 7, "high_q": 8}),
|
||||||
|
CarlaPluginInfo(6, "Ch2 Comp", "comp", 1, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "makeup": 4, "knee": 5}),
|
||||||
|
# Channel 3: NAM → IR (guitar)
|
||||||
|
CarlaPluginInfo(7, "Ch3 NAM", "nam", 2, {"model": 0, "input_gain": 1, "output_gain": 2}),
|
||||||
|
CarlaPluginInfo(8, "Ch3 IR", "ir", 2, {"ir_file": 0, "wet_dry": 1}),
|
||||||
|
# Channel 4: Gate → EQ → Comp
|
||||||
|
CarlaPluginInfo(9, "Ch4 Gate", "gate", 3, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "range": 4, "makeup": 5}),
|
||||||
|
CarlaPluginInfo(10, "Ch4 EQ", "eq", 3, {"low_freq": 0, "low_gain": 1, "low_q": 2, "mid_freq": 3, "mid_gain": 4, "mid_q": 5, "high_freq": 6, "high_gain": 7, "high_q": 8}),
|
||||||
|
CarlaPluginInfo(11, "Ch4 Comp", "comp", 3, {"threshold": 0, "ratio": 1, "attack": 2, "release": 3, "makeup": 4, "knee": 5}),
|
||||||
|
# Aux FX
|
||||||
|
CarlaPluginInfo(12, "Aux Reverb", "reverb", -1, {"wet_dry": 0, "decay": 1, "predelay": 2, "size": 3, "damping": 4}),
|
||||||
|
CarlaPluginInfo(13, "Aux Delay", "delay", -1, {"wet_dry": 0, "time": 1, "feedback": 2, "lpf": 3, "hpf": 4}),
|
||||||
|
# Master Limiter
|
||||||
|
CarlaPluginInfo(14, "Master Limiter", "limiter", -1, {"threshold": 0, "release": 1, "ceiling": 2}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── OSC Client ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CarlaOSCClient:
|
||||||
|
"""OSC client for controlling Carla plugin parameters.
|
||||||
|
|
||||||
|
Communicates with a local Carla instance via UDP OSC on the
|
||||||
|
configured port (default 22752).
|
||||||
|
|
||||||
|
Thread-safe: all sends are serialized; UDP is connectionless
|
||||||
|
so no shared connection state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
port: int = 22752,
|
||||||
|
timeout: float = 0.1,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self._sock: socket.socket | None = None
|
||||||
|
self._send_count: int = 0
|
||||||
|
self._error_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sock(self) -> socket.socket:
|
||||||
|
if self._sock is None:
|
||||||
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
self._sock.settimeout(self.timeout)
|
||||||
|
return self._sock
|
||||||
|
|
||||||
|
def _send(self, address: str, *args) -> bool:
|
||||||
|
"""Send an OSC message. Returns True on success."""
|
||||||
|
try:
|
||||||
|
packet = encode_osc(address, *args)
|
||||||
|
self.sock.sendto(packet, (self.host, self.port))
|
||||||
|
self._send_count += 1
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
self._error_count += 1
|
||||||
|
logger.warning("OSC send failed (%s): %s", address, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Plugin parameter control ────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_parameter(self, plugin_id: int, param_index: int, value: float) -> bool:
|
||||||
|
"""Set a plugin parameter value via OSC.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Carla internal plugin ID (1-based).
|
||||||
|
param_index: Plugin parameter index (0-based).
|
||||||
|
value: Normalized float (0.0–1.0) or native value.
|
||||||
|
"""
|
||||||
|
return self._send(CARLA_SET_PARAM, plugin_id, param_index, float(value))
|
||||||
|
|
||||||
|
def set_volume(self, plugin_id: int, volume: float) -> bool:
|
||||||
|
"""Set plugin output volume (dB or linear scale)."""
|
||||||
|
return self._send(CARLA_SET_VOLUME, plugin_id, float(volume))
|
||||||
|
|
||||||
|
def set_program(self, plugin_id: int, program: int) -> bool:
|
||||||
|
"""Change plugin program/preset."""
|
||||||
|
return self._send(CARLA_SET_PROGRAM, plugin_id, program)
|
||||||
|
|
||||||
|
def set_midi_program(self, plugin_id: int, program: int) -> bool:
|
||||||
|
"""Change plugin MIDI program."""
|
||||||
|
return self._send(CARLA_SET_MIDI_PROGRAM, plugin_id, program)
|
||||||
|
|
||||||
|
def note_on(self, plugin_id: int, note: int, velocity: int = 100) -> bool:
|
||||||
|
"""Send MIDI note-on to a plugin."""
|
||||||
|
return self._send(CARLA_NOTE_ON, plugin_id, note, velocity)
|
||||||
|
|
||||||
|
def note_off(self, plugin_id: int, note: int) -> bool:
|
||||||
|
"""Send MIDI note-off to a plugin."""
|
||||||
|
return self._send(CARLA_NOTE_OFF, plugin_id, note, 0)
|
||||||
|
|
||||||
|
def activate(self, plugin_id: int) -> bool:
|
||||||
|
"""Activate a plugin."""
|
||||||
|
return self._send(CARLA_ACTIVATE, plugin_id)
|
||||||
|
|
||||||
|
def deactivate(self, plugin_id: int) -> bool:
|
||||||
|
"""Deactivate (bypass) a plugin."""
|
||||||
|
return self._send(CARLA_DEACTIVATE, plugin_id)
|
||||||
|
|
||||||
|
# ── Batch operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_many(self, updates: list[tuple[int, int, float]]) -> int:
|
||||||
|
"""Set multiple parameters in one call. Returns success count."""
|
||||||
|
ok = 0
|
||||||
|
for plugin_id, param_index, value in updates:
|
||||||
|
if self.set_parameter(plugin_id, param_index, value):
|
||||||
|
ok += 1
|
||||||
|
return ok
|
||||||
|
|
||||||
|
# ── Stats ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict:
|
||||||
|
return {
|
||||||
|
"sends": self._send_count,
|
||||||
|
"errors": self._error_count,
|
||||||
|
"host": self.host,
|
||||||
|
"port": self.port,
|
||||||
|
}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._sock:
|
||||||
|
self._sock.close()
|
||||||
|
self._sock = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter value scaling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def linear_to_db(linear: float, min_db: float = -60.0, max_db: float = 12.0) -> float:
|
||||||
|
"""Convert 0.0–1.0 linear to dB."""
|
||||||
|
if linear <= 0.0:
|
||||||
|
return float("-inf") if min_db <= -60.0 else min_db
|
||||||
|
return min_db + linear * (max_db - min_db)
|
||||||
|
|
||||||
|
|
||||||
|
def db_to_linear(db: float, min_db: float = -60.0, max_db: float = 12.0) -> float:
|
||||||
|
"""Convert dB to 0.0–1.0 linear."""
|
||||||
|
if db <= min_db:
|
||||||
|
return 0.0
|
||||||
|
return (db - min_db) / (max_db - min_db)
|
||||||
|
|
||||||
|
|
||||||
|
def freq_to_normalized(hz: float, min_hz: float = 20.0, max_hz: float = 20000.0) -> float:
|
||||||
|
"""Convert frequency to 0.0–1.0 using log scale."""
|
||||||
|
import math
|
||||||
|
return math.log(hz / min_hz) / math.log(max_hz / min_hz)
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_to_freq(norm: float, min_hz: float = 20.0, max_hz: float = 20000.0) -> float:
|
||||||
|
"""Convert 0.0–1.0 to frequency using log scale."""
|
||||||
|
import math
|
||||||
|
return min_hz * (max_hz / min_hz) ** norm
|
||||||
|
|
||||||
|
|
||||||
|
def time_ms_to_normalized(ms: float, min_ms: float = 0.1, max_ms: float = 2000.0) -> float:
|
||||||
|
"""Convert milliseconds to 0.0–1.0."""
|
||||||
|
if ms <= min_ms:
|
||||||
|
return 0.0
|
||||||
|
if ms >= max_ms:
|
||||||
|
return 1.0
|
||||||
|
return (ms - min_ms) / (max_ms - min_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def mix_to_pan(mix: float, pan: float) -> tuple[float, float]:
|
||||||
|
"""Convert mix + pan into L/R gain pair.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mix: 0.0–1.0 (0 = full L, 1 = full R, 0.5 = center).
|
||||||
|
pan: -1.0 (L) to 1.0 (R).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(left_gain, right_gain) in linear scale.
|
||||||
|
"""
|
||||||
|
# Constant-power panning
|
||||||
|
import math
|
||||||
|
angle = (pan + 1.0) * math.pi / 4.0 # 0 to pi/2
|
||||||
|
left = math.cos(angle)
|
||||||
|
right = math.sin(angle)
|
||||||
|
# Blend with mix
|
||||||
|
left *= mix
|
||||||
|
right *= mix
|
||||||
|
return left, right
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
"""Signal routing matrix.
|
||||||
|
|
||||||
|
Manages the JACK port connection graph: which inputs feed which
|
||||||
|
channels, buses, and outputs. Implemented as a directed graph where
|
||||||
|
edges carry gain, mute, and solo state.
|
||||||
|
|
||||||
|
Routing classes:
|
||||||
|
- RouteNode: a point in the signal graph (input, channel, bus, output)
|
||||||
|
- RoutingEdge: a connection with gain/mute/solo
|
||||||
|
- RoutingMatrix: the full graph with JACK port management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Node types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NodeType(StrEnum):
|
||||||
|
"""Types of nodes in the routing graph."""
|
||||||
|
SYSTEM_INPUT = "system_input" # system:capture_N
|
||||||
|
CHANNEL_INPUT = "channel_input" # mixer channel N input
|
||||||
|
CHANNEL_DIRECT = "channel_direct" # direct out (pre-fader)
|
||||||
|
AUX_SEND = "aux_send" # channel → aux bus send
|
||||||
|
AUX_BUS = "aux_bus" # aux bus input (summing point)
|
||||||
|
AUX_RETURN = "aux_return" # aux return (post-FX)
|
||||||
|
SUBGROUP = "subgroup" # subgroup bus
|
||||||
|
VCA_GROUP = "vca_group" # VCA control group (not a signal path)
|
||||||
|
MASTER_INPUT = "master_input" # master bus summing input
|
||||||
|
MASTER_INSERT_SEND = "master_insert_send"
|
||||||
|
MASTER_INSERT_RETURN = "master_insert_return"
|
||||||
|
SYSTEM_OUTPUT = "system_output" # system:playback_N
|
||||||
|
CARLA_PLUGIN_IN = "carla_plugin_in"
|
||||||
|
CARLA_PLUGIN_OUT = "carla_plugin_out"
|
||||||
|
FX_INPUT = "fx_input" # external FX input
|
||||||
|
FX_OUTPUT = "fx_output" # external FX output
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RouteNode:
|
||||||
|
"""A node in the routing graph."""
|
||||||
|
node_id: str # unique identifier
|
||||||
|
node_type: NodeType
|
||||||
|
label: str = ""
|
||||||
|
channel: int = -1 # associated mixer channel (0-based)
|
||||||
|
jack_port: str = "" # JACK port name (if directly mapped)
|
||||||
|
is_stereo: bool = False # True for stereo pairs (handled as L/R pair)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.node_id)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if isinstance(other, RouteNode):
|
||||||
|
return self.node_id == other.node_id
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutingEdge:
|
||||||
|
"""A connection between two nodes."""
|
||||||
|
source: RouteNode
|
||||||
|
dest: RouteNode
|
||||||
|
gain_db: float = 0.0 # dB trim
|
||||||
|
muted: bool = False
|
||||||
|
soloed: bool = False
|
||||||
|
is_active: bool = True # edge enabled/disabled
|
||||||
|
jack_connected: bool = False # actual JACK port connection status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_gain(self) -> float:
|
||||||
|
"""Linear gain with mute."""
|
||||||
|
if self.muted:
|
||||||
|
return 0.0
|
||||||
|
return 10.0 ** (self.gain_db / 20.0)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.source.node_id, self.dest.node_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routing Matrix ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingMatrix:
|
||||||
|
"""Manages the full signal routing graph and JACK connections.
|
||||||
|
|
||||||
|
The routing matrix is the central authority on signal flow. It
|
||||||
|
maintains a directed graph of RouteNodes connected by RoutingEdges.
|
||||||
|
When the graph changes, it applies the changes to JACK via
|
||||||
|
jack_connect/jack_disconnect calls.
|
||||||
|
|
||||||
|
Thread-safe for concurrent access from MIDI callbacks and UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "mixer-routing"):
|
||||||
|
self.name = name
|
||||||
|
self._nodes: dict[str, RouteNode] = {}
|
||||||
|
self._edges: list[RoutingEdge] = []
|
||||||
|
self._solo_active: bool = False # global solo flag
|
||||||
|
self._solo_nodes: set[str] = set() # node IDs that have solo engaged
|
||||||
|
|
||||||
|
# ── Node management ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_node(self, node: RouteNode) -> RouteNode:
|
||||||
|
"""Add a node to the graph. Returns the node."""
|
||||||
|
self._nodes[node.node_id] = node
|
||||||
|
return node
|
||||||
|
|
||||||
|
def get_node(self, node_id: str) -> RouteNode | None:
|
||||||
|
return self._nodes.get(node_id)
|
||||||
|
|
||||||
|
def remove_node(self, node_id: str) -> bool:
|
||||||
|
if node_id not in self._nodes:
|
||||||
|
return False
|
||||||
|
# Remove all edges involving this node
|
||||||
|
self._edges = [e for e in self._edges
|
||||||
|
if e.source.node_id != node_id and e.dest.node_id != node_id]
|
||||||
|
del self._nodes[node_id]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def find_nodes(self, node_type: NodeType | None = None,
|
||||||
|
channel: int | None = None) -> list[RouteNode]:
|
||||||
|
"""Find nodes by type and/or channel."""
|
||||||
|
result = []
|
||||||
|
for node in self._nodes.values():
|
||||||
|
if node_type and node.node_type != node_type:
|
||||||
|
continue
|
||||||
|
if channel is not None and node.channel != channel:
|
||||||
|
continue
|
||||||
|
result.append(node)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Edge management ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self,
|
||||||
|
source_id: str,
|
||||||
|
dest_id: str,
|
||||||
|
gain_db: float = 0.0,
|
||||||
|
apply_jack: bool = True,
|
||||||
|
) -> RoutingEdge | None:
|
||||||
|
"""Create a routing edge between two nodes.
|
||||||
|
|
||||||
|
If apply_jack is True and both nodes have JACK ports, connects
|
||||||
|
them via jack_connect immediately.
|
||||||
|
"""
|
||||||
|
source = self._nodes.get(source_id)
|
||||||
|
dest = self._nodes.get(dest_id)
|
||||||
|
if not source or not dest:
|
||||||
|
logger.warning("Cannot connect %s → %s: node not found", source_id, dest_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check for duplicate
|
||||||
|
for existing in self._edges:
|
||||||
|
if existing.source.node_id == source_id and existing.dest.node_id == dest_id:
|
||||||
|
# Update existing edge
|
||||||
|
existing.gain_db = gain_db
|
||||||
|
if apply_jack:
|
||||||
|
self._jack_connect_edge(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
edge = RoutingEdge(source=source, dest=dest, gain_db=gain_db)
|
||||||
|
self._edges.append(edge)
|
||||||
|
|
||||||
|
if apply_jack:
|
||||||
|
self._jack_connect_edge(edge)
|
||||||
|
|
||||||
|
return edge
|
||||||
|
|
||||||
|
def disconnect(self, source_id: str, dest_id: str, apply_jack: bool = True) -> bool:
|
||||||
|
"""Remove a routing edge between two nodes."""
|
||||||
|
for i, edge in enumerate(self._edges):
|
||||||
|
if edge.source.node_id == source_id and edge.dest.node_id == dest_id:
|
||||||
|
if apply_jack:
|
||||||
|
self._jack_disconnect_edge(edge)
|
||||||
|
self._edges.pop(i)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_edges(self, source_id: str | None = None,
|
||||||
|
dest_id: str | None = None) -> list[RoutingEdge]:
|
||||||
|
"""Get edges, optionally filtered by source/dest."""
|
||||||
|
result = []
|
||||||
|
for edge in self._edges:
|
||||||
|
if source_id and edge.source.node_id != source_id:
|
||||||
|
continue
|
||||||
|
if dest_id and edge.dest.node_id != dest_id:
|
||||||
|
continue
|
||||||
|
result.append(edge)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def set_gain(self, source_id: str, dest_id: str, gain_db: float) -> bool:
|
||||||
|
"""Set the gain for an existing edge."""
|
||||||
|
for edge in self._edges:
|
||||||
|
if edge.source.node_id == source_id and edge.dest.node_id == dest_id:
|
||||||
|
edge.gain_db = gain_db
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_mute(self, source_id: str, dest_id: str, muted: bool) -> bool:
|
||||||
|
"""Mute/unmute an edge."""
|
||||||
|
for edge in self._edges:
|
||||||
|
if edge.source.node_id == source_id and edge.dest.node_id == dest_id:
|
||||||
|
edge.muted = muted
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Solo logic ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_solo(self, source_id: str, solo: bool) -> None:
|
||||||
|
"""Set solo state for a source node."""
|
||||||
|
if solo:
|
||||||
|
self._solo_nodes.add(source_id)
|
||||||
|
else:
|
||||||
|
self._solo_nodes.discard(source_id)
|
||||||
|
|
||||||
|
self._update_solo_routing()
|
||||||
|
|
||||||
|
def clear_solo(self) -> None:
|
||||||
|
"""Clear all solo states."""
|
||||||
|
self._solo_nodes.clear()
|
||||||
|
self._update_solo_routing()
|
||||||
|
|
||||||
|
def _update_solo_routing(self) -> None:
|
||||||
|
"""Update mute states based on solo logic.
|
||||||
|
|
||||||
|
Solo-in-place: when any source is soloed, only edges from
|
||||||
|
soloed sources pass signal. All others are effectively muted.
|
||||||
|
"""
|
||||||
|
has_solo = len(self._solo_nodes) > 0
|
||||||
|
self._solo_active = has_solo
|
||||||
|
|
||||||
|
if not has_solo:
|
||||||
|
# Clear all solo flags when nothing is soloed
|
||||||
|
for edge in self._edges:
|
||||||
|
edge.soloed = False
|
||||||
|
return
|
||||||
|
|
||||||
|
for edge in self._edges:
|
||||||
|
if edge.source.node_id in self._solo_nodes:
|
||||||
|
edge.soloed = True
|
||||||
|
else:
|
||||||
|
edge.soloed = False
|
||||||
|
|
||||||
|
# ── JACK port management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _jack_connect_edge(self, edge: RoutingEdge) -> bool:
|
||||||
|
"""Connect a routing edge via JACK if both nodes have jack_ports."""
|
||||||
|
src_port = edge.source.jack_port
|
||||||
|
dst_port = edge.dest.jack_port
|
||||||
|
|
||||||
|
if not src_port or not dst_port:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Handle stereo pairs
|
||||||
|
ports_to_connect = _get_port_pairs(src_port, dst_port, edge.source.is_stereo,
|
||||||
|
edge.dest.is_stereo)
|
||||||
|
|
||||||
|
success = True
|
||||||
|
for s, d in ports_to_connect:
|
||||||
|
if _jack_connect(s, d):
|
||||||
|
edge.jack_connected = True
|
||||||
|
logger.debug("JACK connect: %s → %s", s, d)
|
||||||
|
else:
|
||||||
|
success = False
|
||||||
|
logger.warning("JACK connect FAILED: %s → %s", s, d)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def _jack_disconnect_edge(self, edge: RoutingEdge) -> bool:
|
||||||
|
"""Disconnect a routing edge from JACK."""
|
||||||
|
src_port = edge.source.jack_port
|
||||||
|
dst_port = edge.dest.jack_port
|
||||||
|
|
||||||
|
if not src_port or not dst_port:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ports_to_disconnect = _get_port_pairs(src_port, dst_port, edge.source.is_stereo,
|
||||||
|
edge.dest.is_stereo)
|
||||||
|
|
||||||
|
for s, d in ports_to_disconnect:
|
||||||
|
_jack_disconnect(s, d)
|
||||||
|
|
||||||
|
edge.jack_connected = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def apply_full_matrix(self) -> dict:
|
||||||
|
"""Apply all edges to JACK. Returns stats."""
|
||||||
|
stats = {"connected": 0, "failed": 0, "disconnected": 0}
|
||||||
|
|
||||||
|
# First, disconnect everything we manage, then reconnect
|
||||||
|
# This ensures consistency with the matrix state.
|
||||||
|
for edge in self._edges:
|
||||||
|
if edge.jack_connected:
|
||||||
|
if self._jack_disconnect_edge(edge):
|
||||||
|
stats["disconnected"] += 1
|
||||||
|
|
||||||
|
for edge in self._edges:
|
||||||
|
if edge.is_active and not edge.muted:
|
||||||
|
if self._jack_connect_edge(edge):
|
||||||
|
stats["connected"] += 1
|
||||||
|
else:
|
||||||
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def disconnect_all(self) -> int:
|
||||||
|
"""Disconnect all managed JACK connections. Returns count."""
|
||||||
|
count = 0
|
||||||
|
for edge in self._edges:
|
||||||
|
if edge.jack_connected:
|
||||||
|
if self._jack_disconnect_edge(edge):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
# ── Graph convenience ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_default_8ch_matrix(self) -> None:
|
||||||
|
"""Build the default 8-channel routing matrix.
|
||||||
|
|
||||||
|
system:capture_{1..8} → channel_{0..7}_input → [carla processing]
|
||||||
|
→ channel_{0..7}_to_master → master_input → master_out → system:playback_{1..2}
|
||||||
|
"""
|
||||||
|
self._nodes.clear()
|
||||||
|
self._edges.clear()
|
||||||
|
|
||||||
|
# System I/O nodes
|
||||||
|
for i in range(1, 9):
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
f"sys_in_{i}", NodeType.SYSTEM_INPUT,
|
||||||
|
f"Capture {i}", channel=i - 1,
|
||||||
|
jack_port=f"system:capture_{i}",
|
||||||
|
))
|
||||||
|
for i in range(1, 3):
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
f"sys_out_{i}", NodeType.SYSTEM_OUTPUT,
|
||||||
|
f"Playback {i}", channel=-1,
|
||||||
|
jack_port=f"system:playback_{i}",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Channel inputs
|
||||||
|
for ch in range(8):
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
f"ch_{ch}_input", NodeType.CHANNEL_INPUT,
|
||||||
|
f"CH{ch+1} Input", channel=ch,
|
||||||
|
))
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
f"ch_{ch}_to_master", NodeType.CHANNEL_INPUT,
|
||||||
|
f"CH{ch+1} → Master", channel=ch,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Aux buses (4 sends)
|
||||||
|
for aux in range(4):
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
f"aux_{aux}_bus", NodeType.AUX_BUS,
|
||||||
|
f"AUX {aux+1} Bus", channel=-1,
|
||||||
|
))
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
f"aux_{aux}_return", NodeType.AUX_RETURN,
|
||||||
|
f"AUX {aux+1} Return", channel=-1,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Subgroups (2 stereo subgroups)
|
||||||
|
for sg in range(2):
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
f"subgroup_{sg}", NodeType.SUBGROUP,
|
||||||
|
f"Subgroup {sg+1}", channel=-1, is_stereo=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Master bus
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
"master_input", NodeType.MASTER_INPUT,
|
||||||
|
"Master Bus", channel=-1, is_stereo=True,
|
||||||
|
))
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
"master_insert_send", NodeType.MASTER_INSERT_SEND,
|
||||||
|
"Master Insert Send", channel=-1, is_stereo=True,
|
||||||
|
))
|
||||||
|
self.add_node(RouteNode(
|
||||||
|
"master_insert_return", NodeType.MASTER_INSERT_RETURN,
|
||||||
|
"Master Insert Return", channel=-1, is_stereo=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Connect system inputs → channels (these get routed through Carla)
|
||||||
|
for ch in range(8):
|
||||||
|
self.connect(f"sys_in_{ch+1}", f"ch_{ch}_input", apply_jack=False)
|
||||||
|
|
||||||
|
# Connect channels → master
|
||||||
|
for ch in range(8):
|
||||||
|
self.connect(f"ch_{ch}_to_master", "master_input", apply_jack=False)
|
||||||
|
|
||||||
|
# Connect master → system outputs
|
||||||
|
self.connect("master_input", "sys_out_1", apply_jack=False)
|
||||||
|
self.connect("master_input", "sys_out_2", apply_jack=False)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize the routing matrix to a dict."""
|
||||||
|
nodes = []
|
||||||
|
for n in self._nodes.values():
|
||||||
|
nodes.append({
|
||||||
|
"node_id": n.node_id,
|
||||||
|
"type": n.node_type.value,
|
||||||
|
"label": n.label,
|
||||||
|
"channel": n.channel,
|
||||||
|
"jack_port": n.jack_port,
|
||||||
|
"is_stereo": n.is_stereo,
|
||||||
|
})
|
||||||
|
|
||||||
|
edges = []
|
||||||
|
for e in self._edges:
|
||||||
|
edges.append({
|
||||||
|
"source": e.source.node_id,
|
||||||
|
"dest": e.dest.node_id,
|
||||||
|
"gain_db": e.gain_db,
|
||||||
|
"muted": e.muted,
|
||||||
|
"is_active": e.is_active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"nodes": nodes, "edges": edges, "solo_nodes": list(self._solo_nodes)}
|
||||||
|
|
||||||
|
def from_dict(self, data: dict) -> None:
|
||||||
|
"""Restore routing matrix from a dict."""
|
||||||
|
self._nodes.clear()
|
||||||
|
self._edges.clear()
|
||||||
|
self._solo_nodes.clear()
|
||||||
|
|
||||||
|
for n in data.get("nodes", []):
|
||||||
|
node = RouteNode(
|
||||||
|
node_id=n["node_id"],
|
||||||
|
node_type=NodeType(n["type"]),
|
||||||
|
label=n.get("label", ""),
|
||||||
|
channel=n.get("channel", -1),
|
||||||
|
jack_port=n.get("jack_port", ""),
|
||||||
|
is_stereo=n.get("is_stereo", False),
|
||||||
|
)
|
||||||
|
self._nodes[node.node_id] = node
|
||||||
|
|
||||||
|
for e in data.get("edges", []):
|
||||||
|
src = self._nodes.get(e["source"])
|
||||||
|
dst = self._nodes.get(e["dest"])
|
||||||
|
if src and dst:
|
||||||
|
edge = RoutingEdge(
|
||||||
|
source=src,
|
||||||
|
dest=dst,
|
||||||
|
gain_db=e.get("gain_db", 0.0),
|
||||||
|
muted=e.get("muted", False),
|
||||||
|
is_active=e.get("is_active", True),
|
||||||
|
)
|
||||||
|
self._edges.append(edge)
|
||||||
|
|
||||||
|
self._solo_nodes = set(data.get("solo_nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
# ── JACK helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_port_pairs(
|
||||||
|
src: str, dst: str, src_stereo: bool, dst_stereo: bool
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
"""Get the actual JACK port pairs to connect/disconnect.
|
||||||
|
|
||||||
|
Handles stereo pairs: if both are stereo, connect L→L, R→R.
|
||||||
|
If only one is stereo, connect both L/R of the stereo side to the mono side.
|
||||||
|
"""
|
||||||
|
# Simple case: both mono
|
||||||
|
if not src_stereo and not dst_stereo:
|
||||||
|
return [(src, dst)]
|
||||||
|
|
||||||
|
# Both stereo: match channels
|
||||||
|
if src_stereo and dst_stereo:
|
||||||
|
return [
|
||||||
|
(src.replace("_1", "_1").replace("_L", "_L") if "_L" not in src else src,
|
||||||
|
dst.replace("_1", "_1").replace("_L", "_L") if "_L" not in dst else dst),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mono → Stereo or Stereo → Mono
|
||||||
|
# For simplicity, just connect the given ports directly
|
||||||
|
return [(src, dst)]
|
||||||
|
|
||||||
|
|
||||||
|
def _jack_connect(source: str, dest: str) -> bool:
|
||||||
|
"""Connect two JACK ports. Returns True on success."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jack_connect", source, dest],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
|
||||||
|
logger.debug("jack_connect failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _jack_disconnect(source: str, dest: str) -> bool:
|
||||||
|
"""Disconnect two JACK ports."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jack_disconnect", source, dest],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_jack_ports() -> list[str]:
|
||||||
|
"""Get all current JACK ports."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jack_lsp", "-c"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
return result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_jack_connections() -> list[tuple[str, str]]:
|
||||||
|
"""Get all current JACK connections."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jack_lsp", "-c"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
connections = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
for dest in parts[1:]:
|
||||||
|
connections.append((parts[0], dest))
|
||||||
|
return connections
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Network API — OSC bridge, REST, and WebSocket for the RPi Mixer.
|
||||||
|
|
||||||
|
Provides the external control surface for the mixer engine:
|
||||||
|
- OSC server for DAW and hardware controller integration
|
||||||
|
- REST API for mixer state query and control
|
||||||
|
- WebSocket for real-time bidirectional parameter updates
|
||||||
|
- Session-based auth for browser WebSocket clients
|
||||||
|
- Web UI static file serving
|
||||||
|
- Settings and file management endpoints
|
||||||
|
- API key authentication
|
||||||
|
- Rate limiting and connection management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
ChannelSchema,
|
||||||
|
MasterBusSchema,
|
||||||
|
MixerStateSchema,
|
||||||
|
PluginSchema,
|
||||||
|
TransportSchema,
|
||||||
|
RoutingSchema,
|
||||||
|
AuxBusSchema,
|
||||||
|
SubgroupSchema,
|
||||||
|
VCASchema,
|
||||||
|
)
|
||||||
|
from .server import NetworkServer
|
||||||
|
from .session import SessionManager, Session
|
||||||
|
from .web_routes import create_web_routes, get_static_files_app
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NetworkServer",
|
||||||
|
"SessionManager",
|
||||||
|
"Session",
|
||||||
|
"create_web_routes",
|
||||||
|
"get_static_files_app",
|
||||||
|
"ChannelSchema",
|
||||||
|
"MasterBusSchema",
|
||||||
|
"MixerStateSchema",
|
||||||
|
"PluginSchema",
|
||||||
|
"TransportSchema",
|
||||||
|
"RoutingSchema",
|
||||||
|
"AuxBusSchema",
|
||||||
|
"SubgroupSchema",
|
||||||
|
"VCASchema",
|
||||||
|
]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""API key authentication for the mixer network API.
|
||||||
|
|
||||||
|
Simple shared-secret authentication for local network use.
|
||||||
|
The key is loaded from the MIXER_API_KEY environment variable or
|
||||||
|
a config file. Supports multiple valid keys for key rotation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Header name for API key
|
||||||
|
API_KEY_HEADER = "X-API-Key"
|
||||||
|
|
||||||
|
# Default key (only used when no env var is set — prints a warning)
|
||||||
|
_DEFAULT_KEY = "mixer-local"
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyAuth:
|
||||||
|
"""Simple API key validator for local network use.
|
||||||
|
|
||||||
|
The API key is set via the MIXER_API_KEY environment variable.
|
||||||
|
Multiple keys can be provided as comma-separated values to support
|
||||||
|
key rotation. Keys are compared in constant time to prevent timing attacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, keys: list[str] | None = None):
|
||||||
|
if keys is not None:
|
||||||
|
self._valid_hashes = {_hash_key(k.strip()) for k in keys if k.strip()}
|
||||||
|
else:
|
||||||
|
env_key = os.environ.get("MIXER_API_KEY", _DEFAULT_KEY)
|
||||||
|
if env_key == _DEFAULT_KEY:
|
||||||
|
logger.warning(
|
||||||
|
"MIXER_API_KEY not set — using default key '%s'. "
|
||||||
|
"Set MIXER_API_KEY env var for production use.",
|
||||||
|
_DEFAULT_KEY,
|
||||||
|
)
|
||||||
|
self._valid_hashes = {_hash_key(k.strip()) for k in env_key.split(",") if k.strip()}
|
||||||
|
|
||||||
|
def validate(self, api_key: str | None) -> bool:
|
||||||
|
"""Validate an API key. Constant-time comparison."""
|
||||||
|
if not api_key:
|
||||||
|
return False
|
||||||
|
key_hash = _hash_key(api_key)
|
||||||
|
return any(
|
||||||
|
hmac.compare_digest(key_hash, valid_hash)
|
||||||
|
for valid_hash in self._valid_hashes
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_keys_count(self) -> int:
|
||||||
|
"""Return the number of valid keys configured."""
|
||||||
|
return len(self._valid_hashes)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_key(key: str) -> str:
|
||||||
|
"""Hash a key for secure storage."""
|
||||||
|
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI dependencies ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyHeader(HTTPBearer):
|
||||||
|
"""FastAPI dependency that validates the X-API-Key header.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
auth = APIKeyAuth()
|
||||||
|
router = APIRouter(dependencies=[Depends(APIKeyHeader(auth))])
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, auth: APIKeyAuth, auto_error: bool = True):
|
||||||
|
super().__init__(auto_error=auto_error)
|
||||||
|
self._auth = auth
|
||||||
|
|
||||||
|
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
|
||||||
|
"""Validate the API key from the request header."""
|
||||||
|
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Missing X-API-Key header",
|
||||||
|
headers={"WWW-Authenticate": "ApiKey"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._auth.validate(api_key):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
|
||||||
|
return None # Auth succeeded, no credentials object needed
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(auth: APIKeyAuth):
|
||||||
|
"""Create a FastAPI dependency that requires a valid API key.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
auth = APIKeyAuth()
|
||||||
|
app.include_router(router, dependencies=[Depends(require_api_key(auth))])
|
||||||
|
"""
|
||||||
|
async def dependency(request: Request) -> None:
|
||||||
|
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Missing X-API-Key header",
|
||||||
|
)
|
||||||
|
if not auth.validate(api_key):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
return dependency
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""Asynchronous OSC server for external mixer control.
|
||||||
|
|
||||||
|
Listens on a configurable UDP port for OSC messages and dispatches
|
||||||
|
parameter changes to the mixer engine via the ParameterRegistry.
|
||||||
|
|
||||||
|
OSC address pattern:
|
||||||
|
/mixer/channel/<n>/<parameter> <value>
|
||||||
|
/mixer/master/<parameter> <value>
|
||||||
|
/mixer/transport/<parameter> <value>
|
||||||
|
/mixer/fx/<parameter> <value>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
/mixer/channel/0/volume 0.75
|
||||||
|
/mixer/channel/3/mute 1.0
|
||||||
|
/mixer/master/volume -6.0
|
||||||
|
/mixer/transport/play 1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
from ..midi.types import ParameterType, ParameterCategory, MixerParameter
|
||||||
|
from ..mixer.osc_client import encode_osc, decode_osc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default OSC port (different from Carla's 22752)
|
||||||
|
DEFAULT_OSC_PORT = 9001
|
||||||
|
|
||||||
|
# Allowed parameter types for OSC address routing
|
||||||
|
_CHANNEL_PARAMS = {
|
||||||
|
"volume": ParameterType.VOLUME,
|
||||||
|
"pan": ParameterType.PAN,
|
||||||
|
"mute": ParameterType.MUTE,
|
||||||
|
"solo": ParameterType.SOLO,
|
||||||
|
"gain": ParameterType.GAIN,
|
||||||
|
"phase": ParameterType.PHASE_INVERT,
|
||||||
|
"phase_invert": ParameterType.PHASE_INVERT,
|
||||||
|
"eq_enable": ParameterType.EQ_ENABLE,
|
||||||
|
"eq_low_freq": ParameterType.EQ_LOW_FREQ,
|
||||||
|
"eq_low_gain": ParameterType.EQ_LOW_GAIN,
|
||||||
|
"eq_low_q": ParameterType.EQ_LOW_Q,
|
||||||
|
"eq_mid_freq": ParameterType.EQ_MID_FREQ,
|
||||||
|
"eq_mid_gain": ParameterType.EQ_MID_GAIN,
|
||||||
|
"eq_mid_q": ParameterType.EQ_MID_Q,
|
||||||
|
"eq_high_freq": ParameterType.EQ_HIGH_FREQ,
|
||||||
|
"eq_high_gain": ParameterType.EQ_HIGH_GAIN,
|
||||||
|
"eq_high_q": ParameterType.EQ_HIGH_Q,
|
||||||
|
"comp_threshold": ParameterType.COMP_THRESHOLD,
|
||||||
|
"comp_ratio": ParameterType.COMP_RATIO,
|
||||||
|
"comp_attack": ParameterType.COMP_ATTACK,
|
||||||
|
"comp_release": ParameterType.COMP_RELEASE,
|
||||||
|
"comp_gain": ParameterType.COMP_GAIN,
|
||||||
|
"gate_threshold": ParameterType.GATE_THRESHOLD,
|
||||||
|
"gate_range": ParameterType.GATE_RANGE,
|
||||||
|
"fx_send_a": ParameterType.FX_SEND_A,
|
||||||
|
"fx_send_b": ParameterType.FX_SEND_B,
|
||||||
|
}
|
||||||
|
|
||||||
|
_MASTER_PARAMS = {
|
||||||
|
"volume": ParameterType.MASTER_VOLUME,
|
||||||
|
"mute": ParameterType.MASTER_MUTE,
|
||||||
|
"dim": ParameterType.MASTER_DIM,
|
||||||
|
"monitor": ParameterType.MONITOR_VOLUME,
|
||||||
|
"phones": ParameterType.PHONES_VOLUME,
|
||||||
|
}
|
||||||
|
|
||||||
|
_FX_PARAMS = {
|
||||||
|
"return_a": ParameterType.FX_RETURN_A,
|
||||||
|
"return_b": ParameterType.FX_RETURN_B,
|
||||||
|
}
|
||||||
|
|
||||||
|
_TRANSPORT_PARAMS = {
|
||||||
|
"play": ParameterType.PLAY,
|
||||||
|
"stop": ParameterType.STOP,
|
||||||
|
"record": ParameterType.RECORD,
|
||||||
|
"loop": ParameterType.LOOP,
|
||||||
|
"tempo": ParameterType.TEMPO,
|
||||||
|
"tap_tempo": ParameterType.TAP_TEMPO,
|
||||||
|
}
|
||||||
|
|
||||||
|
_UTILITY_PARAMS = {
|
||||||
|
"snapshot_load": ParameterType.SNAPSHOT_LOAD,
|
||||||
|
"snapshot_save": ParameterType.SNAPSHOT_SAVE,
|
||||||
|
"scene_next": ParameterType.SCENE_NEXT,
|
||||||
|
"scene_prev": ParameterType.SCENE_PREV,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OSCServer:
|
||||||
|
"""Asynchronous OSC server for mixer control.
|
||||||
|
|
||||||
|
Listens on UDP for OSC messages and dispatches parameter changes.
|
||||||
|
Designed to run alongside the REST/WebSocket server.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
server = OSCServer(host="0.0.0.0", port=9001)
|
||||||
|
server.set_dispatcher(my_dispatch_function)
|
||||||
|
await server.start()
|
||||||
|
# ... mixer runs ...
|
||||||
|
await server.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = DEFAULT_OSC_PORT,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||||
|
self._protocol: Optional[_OSCProtocol] = None
|
||||||
|
self._running = False
|
||||||
|
self._dispatch_fn: Optional[Callable[[MixerParameter, float], None]] = None
|
||||||
|
self._msg_count: int = 0
|
||||||
|
self._err_count: int = 0
|
||||||
|
|
||||||
|
def set_dispatcher(
|
||||||
|
self, fn: Callable[[MixerParameter, float], None]
|
||||||
|
) -> None:
|
||||||
|
"""Set the callback for parameter changes.
|
||||||
|
|
||||||
|
The dispatcher receives (MixerParameter, value) for each valid
|
||||||
|
OSC message received.
|
||||||
|
"""
|
||||||
|
self._dispatch_fn = fn
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the OSC server."""
|
||||||
|
if self._running:
|
||||||
|
return # Already running — idempotent
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
self._protocol = _OSCProtocol(self._handle_message, self.host, self.port)
|
||||||
|
|
||||||
|
protocol = self._protocol
|
||||||
|
self._transport, _ = await loop.create_datagram_endpoint(
|
||||||
|
lambda: protocol, # type: ignore[return-value]
|
||||||
|
local_addr=(self.host, self.port),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
logger.info("OSC server listening on %s:%d", self.host, self.port)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the OSC server."""
|
||||||
|
self._running = False
|
||||||
|
if self._transport:
|
||||||
|
self._transport.close()
|
||||||
|
self._transport = None
|
||||||
|
logger.info("OSC server stopped (%d messages, %d errors)",
|
||||||
|
self._msg_count, self._err_count)
|
||||||
|
|
||||||
|
def _handle_message(self, address: str, args: list) -> None:
|
||||||
|
"""Handle a decoded OSC message."""
|
||||||
|
self._msg_count += 1
|
||||||
|
|
||||||
|
if not self._dispatch_fn:
|
||||||
|
logger.debug("OSC message received but no dispatcher set: %s", address)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = _extract_value(args)
|
||||||
|
param_info = _parse_osc_address(address)
|
||||||
|
if param_info is None:
|
||||||
|
logger.debug("Unknown OSC address: %s", address)
|
||||||
|
return
|
||||||
|
|
||||||
|
category, param_type, channel, _ = param_info
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=param_type,
|
||||||
|
category=category,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._dispatch_fn(param, value)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._err_count += 1
|
||||||
|
logger.error("Error handling OSC message %s: %s", address, exc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict:
|
||||||
|
return {
|
||||||
|
"host": self.host,
|
||||||
|
"port": self.port,
|
||||||
|
"running": self._running,
|
||||||
|
"messages": self._msg_count,
|
||||||
|
"errors": self._err_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _OSCProtocol(asyncio.DatagramProtocol):
|
||||||
|
"""asyncio UDP protocol for receiving OSC messages."""
|
||||||
|
|
||||||
|
def __init__(self, handler, host: str, port: int):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self._handler = handler
|
||||||
|
self._buffer = b""
|
||||||
|
|
||||||
|
def connection_made(self, transport):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
||||||
|
"""Process a received OSC datagram."""
|
||||||
|
try:
|
||||||
|
address, args = decode_osc(data)
|
||||||
|
self._handler(address, args)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to decode OSC from %s: %s", addr, exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── OSC address parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# Channel: /mixer/channel/<n>/<param> <value>
|
||||||
|
_CHANNEL_RE = re.compile(r"^/mixer/channel/(\d+)/([a-z_]+)$")
|
||||||
|
|
||||||
|
# Master: /mixer/master/<param> <value>
|
||||||
|
_MASTER_RE = re.compile(r"^/mixer/master/([a-z_]+)$")
|
||||||
|
|
||||||
|
# FX: /mixer/fx/<param> <value>
|
||||||
|
_FX_RE = re.compile(r"^/mixer/fx/([a-z_]+)$")
|
||||||
|
|
||||||
|
# Transport: /mixer/transport/<param> <value>
|
||||||
|
_TRANSPORT_RE = re.compile(r"^/mixer/transport/([a-z_]+)$")
|
||||||
|
|
||||||
|
# Utility: /mixer/utility/<param> <value>
|
||||||
|
_UTILITY_RE = re.compile(r"^/mixer/utility/([a-z_]+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_osc_address(address: str) -> Optional[tuple[ParameterCategory, ParameterType, int, float]]:
|
||||||
|
"""Parse an OSC address into a parameter change.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(category, param_type, channel, value) or None if not recognized.
|
||||||
|
"""
|
||||||
|
# Strip trailing nulls that OSC encoders sometimes add
|
||||||
|
address = address.rstrip("\x00")
|
||||||
|
|
||||||
|
# Split off the last argument which should be the value
|
||||||
|
# (the args come separately from the address, but we handle
|
||||||
|
# the case where value is encoded as a float in the args list)
|
||||||
|
|
||||||
|
# Channel pattern
|
||||||
|
m = _CHANNEL_RE.match(address)
|
||||||
|
if m:
|
||||||
|
channel = int(m.group(1))
|
||||||
|
param_name = m.group(2)
|
||||||
|
if param_name in _CHANNEL_PARAMS:
|
||||||
|
return (
|
||||||
|
ParameterCategory.CHANNEL,
|
||||||
|
_CHANNEL_PARAMS[param_name],
|
||||||
|
channel,
|
||||||
|
0.0, # value will be set from args
|
||||||
|
)
|
||||||
|
|
||||||
|
# Master pattern
|
||||||
|
m = _MASTER_RE.match(address)
|
||||||
|
if m:
|
||||||
|
param_name = m.group(1)
|
||||||
|
if param_name in _MASTER_PARAMS:
|
||||||
|
return (
|
||||||
|
ParameterCategory.MASTER,
|
||||||
|
_MASTER_PARAMS[param_name],
|
||||||
|
-1,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# FX pattern
|
||||||
|
m = _FX_RE.match(address)
|
||||||
|
if m:
|
||||||
|
param_name = m.group(1)
|
||||||
|
if param_name in _FX_PARAMS:
|
||||||
|
return (
|
||||||
|
ParameterCategory.FX,
|
||||||
|
_FX_PARAMS[param_name],
|
||||||
|
-1,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transport pattern
|
||||||
|
m = _TRANSPORT_RE.match(address)
|
||||||
|
if m:
|
||||||
|
param_name = m.group(1)
|
||||||
|
if param_name in _TRANSPORT_PARAMS:
|
||||||
|
return (
|
||||||
|
ParameterCategory.TRANSPORT,
|
||||||
|
_TRANSPORT_PARAMS[param_name],
|
||||||
|
-1,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Utility pattern
|
||||||
|
m = _UTILITY_RE.match(address)
|
||||||
|
if m:
|
||||||
|
param_name = m.group(1)
|
||||||
|
if param_name in _UTILITY_PARAMS:
|
||||||
|
return (
|
||||||
|
ParameterCategory.UTILITY,
|
||||||
|
_UTILITY_PARAMS[param_name],
|
||||||
|
-1,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_value(args: list) -> float:
|
||||||
|
"""Extract a float value from OSC args."""
|
||||||
|
if not args:
|
||||||
|
return 0.0
|
||||||
|
arg = args[0]
|
||||||
|
if isinstance(arg, (int, float)):
|
||||||
|
return float(arg)
|
||||||
|
if isinstance(arg, bool):
|
||||||
|
return 1.0 if arg else 0.0
|
||||||
|
if isinstance(arg, str):
|
||||||
|
try:
|
||||||
|
return float(arg)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return 0.0
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Rate limiter — token bucket algorithm for REST and WebSocket connections.
|
||||||
|
|
||||||
|
Provides per-IP rate limiting with configurable burst and steady-state
|
||||||
|
rates. Uses a token bucket with periodic refill. Thread-safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenBucket:
|
||||||
|
"""Single token bucket for one client.
|
||||||
|
|
||||||
|
Tokens refill at `rate` per second, up to `capacity`.
|
||||||
|
Each request consumes one token. If no tokens are available,
|
||||||
|
the request should be rate-limited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("rate", "capacity", "_tokens", "_last_refill")
|
||||||
|
|
||||||
|
def __init__(self, rate: float, capacity: int):
|
||||||
|
self.rate = rate # tokens per second
|
||||||
|
self.capacity = capacity # max burst
|
||||||
|
self._tokens: float = float(capacity)
|
||||||
|
self._last_refill: float = time.monotonic()
|
||||||
|
|
||||||
|
def consume(self, tokens: int = 1) -> bool:
|
||||||
|
"""Try to consume tokens. Returns True if allowed."""
|
||||||
|
self._refill()
|
||||||
|
if self._tokens >= tokens:
|
||||||
|
self._tokens -= tokens
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> float:
|
||||||
|
"""Return the current number of available tokens."""
|
||||||
|
self._refill()
|
||||||
|
return self._tokens
|
||||||
|
|
||||||
|
def _refill(self) -> None:
|
||||||
|
"""Refill tokens based on elapsed time."""
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_refill
|
||||||
|
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
|
||||||
|
self._last_refill = now
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""Per-IP rate limiter using token buckets.
|
||||||
|
|
||||||
|
Maintains a bucket for each client IP. Old buckets are evicted
|
||||||
|
after a configurable idle timeout.
|
||||||
|
|
||||||
|
Thread-safe for concurrent access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rate: float = 100.0,
|
||||||
|
capacity: int = 200,
|
||||||
|
eviction_timeout: float = 300.0,
|
||||||
|
ws_rate: float = 500.0,
|
||||||
|
ws_capacity: int = 1000,
|
||||||
|
):
|
||||||
|
self._rate = rate
|
||||||
|
self._capacity = capacity
|
||||||
|
self._ws_rate = ws_rate
|
||||||
|
self._ws_capacity = ws_capacity
|
||||||
|
self._eviction_timeout = eviction_timeout
|
||||||
|
self._buckets: dict[str, TokenBucket] = {}
|
||||||
|
self._last_access: dict[str, float] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def _get_client_id(self, request: Request) -> str:
|
||||||
|
"""Extract client identifier from request.
|
||||||
|
|
||||||
|
Prefers X-Forwarded-For header for proxied setups,
|
||||||
|
falls back to client host.
|
||||||
|
"""
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
# Take the first IP in the chain
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
client = getattr(request, "client", None)
|
||||||
|
if client:
|
||||||
|
return client.host if hasattr(client, "host") else str(client)
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
async def check_rest(self, request: Request) -> bool:
|
||||||
|
"""Check REST API rate limit. Raises HTTPException if exceeded."""
|
||||||
|
client_id = self._get_client_id(request)
|
||||||
|
async with self._lock:
|
||||||
|
bucket = await self._get_bucket(client_id, self._rate, self._capacity)
|
||||||
|
if not bucket.consume(1):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Rate limit exceeded. Try again later.",
|
||||||
|
headers={"Retry-After": "1", "X-RateLimit-Remaining": "0"},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def check_ws(self, client_id: str) -> bool:
|
||||||
|
"""Check WebSocket rate limit. Returns False if exceeded."""
|
||||||
|
async with self._lock:
|
||||||
|
bucket = await self._get_bucket(client_id, self._ws_rate, self._ws_capacity)
|
||||||
|
return bucket.consume(1)
|
||||||
|
|
||||||
|
async def _get_bucket(self, client_id: str, rate: float, capacity: int) -> TokenBucket:
|
||||||
|
"""Get or create a token bucket for a client."""
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# Evict stale buckets
|
||||||
|
stale = [
|
||||||
|
cid for cid, last in self._last_access.items()
|
||||||
|
if now - last > self._eviction_timeout
|
||||||
|
]
|
||||||
|
for cid in stale:
|
||||||
|
self._buckets.pop(cid, None)
|
||||||
|
self._last_access.pop(cid, None)
|
||||||
|
|
||||||
|
self._last_access[client_id] = now
|
||||||
|
|
||||||
|
if client_id not in self._buckets:
|
||||||
|
self._buckets[client_id] = TokenBucket(rate=rate, capacity=capacity)
|
||||||
|
return self._buckets[client_id]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_connections(self) -> int:
|
||||||
|
"""Number of active client buckets."""
|
||||||
|
return len(self._buckets)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict:
|
||||||
|
"""Rate limiter statistics."""
|
||||||
|
return {
|
||||||
|
"active_clients": len(self._buckets),
|
||||||
|
"rest_rate": self._rate,
|
||||||
|
"rest_capacity": self._capacity,
|
||||||
|
"ws_rate": self._ws_rate,
|
||||||
|
"ws_capacity": self._ws_capacity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI dependency ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit(limiter: RateLimiter):
|
||||||
|
"""Create a FastAPI dependency for rate limiting.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
limiter = RateLimiter()
|
||||||
|
router = APIRouter(dependencies=[Depends(rate_limit(limiter))])
|
||||||
|
"""
|
||||||
|
async def dependency(request: Request) -> None:
|
||||||
|
await limiter.check_rest(request)
|
||||||
|
return dependency
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""REST API for mixer state — FastAPI router with all endpoints.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /channels — list all channel states
|
||||||
|
GET /channels/{id} — get specific channel
|
||||||
|
PUT /channels/{id}/parameter — set channel parameter
|
||||||
|
GET /mixes — master bus + aux + subgroups + VCA
|
||||||
|
PUT /mixes/parameter — set master parameter
|
||||||
|
GET /plugins — list plugins
|
||||||
|
GET /transport — transport state
|
||||||
|
PUT /transport/command — transport control
|
||||||
|
GET /routing — routing matrix
|
||||||
|
GET /scenes — list saved scenes
|
||||||
|
POST /scenes/{name}/load — load a scene
|
||||||
|
POST /scenes/{name}/save — save current state as scene
|
||||||
|
GET /state — full mixer state
|
||||||
|
GET /stats — server statistics
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
ChannelSchema,
|
||||||
|
MasterBusSchema,
|
||||||
|
MixerStateSchema,
|
||||||
|
PluginSchema,
|
||||||
|
TransportSchema,
|
||||||
|
RoutingSchema,
|
||||||
|
ParameterUpdateSchema,
|
||||||
|
ParameterUpdateBatchSchema,
|
||||||
|
ParameterSetRequest,
|
||||||
|
APIResponse,
|
||||||
|
)
|
||||||
|
from .auth import APIKeyAuth, require_api_key
|
||||||
|
from .rate_limiter import RateLimiter, rate_limit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MixerStateProvider:
|
||||||
|
"""Adapter that provides mixer state to the REST API.
|
||||||
|
|
||||||
|
The REST API is stateless — it queries the DSP engine for current
|
||||||
|
state on each request. This adapter wraps those queries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Callbacks set by the server
|
||||||
|
self.get_full_state: Optional[Callable[[], MixerStateSchema]] = None
|
||||||
|
self.get_channel: Optional[Callable[[int], Optional[ChannelSchema]]] = None
|
||||||
|
self.get_all_channels: Optional[Callable[[], list[ChannelSchema]]] = None
|
||||||
|
self.get_master: Optional[Callable[[], MasterBusSchema]] = None
|
||||||
|
self.get_transport: Optional[Callable[[], TransportSchema]] = None
|
||||||
|
self.get_plugins: Optional[Callable[[], list[PluginSchema]]] = None
|
||||||
|
self.get_routing: Optional[Callable[[], RoutingSchema]] = None
|
||||||
|
self.list_scenes: Optional[Callable[[], list[str]]] = None
|
||||||
|
self.load_scene: Optional[Callable[[str], bool]] = None
|
||||||
|
self.save_scene: Optional[Callable[[str], bool]] = None
|
||||||
|
self.handle_parameter: Optional[Callable[[str, float, int], None]] = None
|
||||||
|
self.handle_transport: Optional[Callable[[str], None]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Router factory ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(
|
||||||
|
state: MixerStateProvider,
|
||||||
|
auth: APIKeyAuth,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
prefix: str = "",
|
||||||
|
) -> APIRouter:
|
||||||
|
"""Create the FastAPI router with all mixer API endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: MixerStateProvider for querying engine state.
|
||||||
|
auth: APIKeyAuth for authentication.
|
||||||
|
rate_limiter: RateLimiter for per-IP rate limiting.
|
||||||
|
prefix: Optional path prefix (e.g., "/api/v1").
|
||||||
|
"""
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix=prefix,
|
||||||
|
tags=["mixer"],
|
||||||
|
dependencies=[
|
||||||
|
Depends(require_api_key(auth)),
|
||||||
|
Depends(rate_limit(rate_limiter)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Channels ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/channels", response_model=list[ChannelSchema])
|
||||||
|
async def list_channels():
|
||||||
|
"""List all channel states."""
|
||||||
|
if not state.get_all_channels:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
return state.get_all_channels()
|
||||||
|
|
||||||
|
@router.get("/channels/{channel_id}", response_model=ChannelSchema)
|
||||||
|
async def get_channel(channel_id: int):
|
||||||
|
"""Get a specific channel's state."""
|
||||||
|
if not state.get_channel:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
ch = state.get_channel(channel_id)
|
||||||
|
if ch is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Channel {channel_id} not found")
|
||||||
|
return ch
|
||||||
|
|
||||||
|
@router.put("/channels/{channel_id}/parameter", response_model=APIResponse)
|
||||||
|
async def set_channel_parameter(channel_id: int, body: ParameterSetRequest):
|
||||||
|
"""Set a parameter on a specific channel."""
|
||||||
|
if not state.handle_parameter:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
|
||||||
|
param_type = body.param_type if hasattr(body, "param_type") else "volume"
|
||||||
|
value = body.value
|
||||||
|
|
||||||
|
state.handle_parameter(param_type, value, channel_id)
|
||||||
|
return APIResponse(ok=True, data={"channel": channel_id, "value": value})
|
||||||
|
|
||||||
|
@router.put("/channels/{channel_id}/parameters", response_model=APIResponse)
|
||||||
|
async def set_channel_parameters(channel_id: int, body: ParameterUpdateBatchSchema):
|
||||||
|
"""Set multiple parameters on a specific channel."""
|
||||||
|
if not state.handle_parameter:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
|
||||||
|
for update in body.updates:
|
||||||
|
state.handle_parameter(
|
||||||
|
update.param_type.value,
|
||||||
|
update.value,
|
||||||
|
update.channel if update.channel >= 0 else channel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return APIResponse(ok=True, data={"channel": channel_id, "count": len(body.updates)})
|
||||||
|
|
||||||
|
# ── Mixes / Master ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/mixes", response_model=MasterBusSchema)
|
||||||
|
async def get_mixes():
|
||||||
|
"""Get master bus and all aux/subgroup/VCA states."""
|
||||||
|
if not state.get_master:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
return state.get_master()
|
||||||
|
|
||||||
|
@router.put("/mixes/parameter", response_model=APIResponse)
|
||||||
|
async def set_master_parameter(body: ParameterSetRequest):
|
||||||
|
"""Set a master bus parameter."""
|
||||||
|
if not state.handle_parameter:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
param_type = getattr(body, "param_type", "master_volume")
|
||||||
|
state.handle_parameter(param_type, body.value, -1)
|
||||||
|
return APIResponse(ok=True, data={"param_type": param_type, "value": body.value})
|
||||||
|
|
||||||
|
# ── Plugins ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/plugins", response_model=list[PluginSchema])
|
||||||
|
async def list_plugins():
|
||||||
|
"""List all Carla plugins in the mixer rack."""
|
||||||
|
if not state.get_plugins:
|
||||||
|
raise HTTPException(status_code=503, detail="Plugin info not available")
|
||||||
|
return state.get_plugins()
|
||||||
|
|
||||||
|
# ── Transport ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/transport", response_model=TransportSchema)
|
||||||
|
async def get_transport():
|
||||||
|
"""Get transport state."""
|
||||||
|
if not state.get_transport:
|
||||||
|
raise HTTPException(status_code=503, detail="Transport not available")
|
||||||
|
return state.get_transport()
|
||||||
|
|
||||||
|
@router.put("/transport/command", response_model=APIResponse)
|
||||||
|
async def transport_command(command: str = Query(..., description="Transport command (play, stop, record, loop)")):
|
||||||
|
"""Send a transport command."""
|
||||||
|
if not state.handle_transport:
|
||||||
|
raise HTTPException(status_code=503, detail="Transport not available")
|
||||||
|
state.handle_transport(command)
|
||||||
|
return APIResponse(ok=True, data={"command": command})
|
||||||
|
|
||||||
|
# ── Routing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/routing", response_model=RoutingSchema)
|
||||||
|
async def get_routing():
|
||||||
|
"""Get the full routing matrix."""
|
||||||
|
if not state.get_routing:
|
||||||
|
raise HTTPException(status_code=503, detail="Routing info not available")
|
||||||
|
return state.get_routing()
|
||||||
|
|
||||||
|
# ── Scenes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/scenes", response_model=list[str])
|
||||||
|
async def list_scenes():
|
||||||
|
"""List saved scene names."""
|
||||||
|
if not state.list_scenes:
|
||||||
|
raise HTTPException(status_code=503, detail="Scenes not available")
|
||||||
|
return state.list_scenes()
|
||||||
|
|
||||||
|
@router.post("/scenes/{name}/load", response_model=APIResponse)
|
||||||
|
async def load_scene(name: str):
|
||||||
|
"""Load a saved scene."""
|
||||||
|
if not state.load_scene:
|
||||||
|
raise HTTPException(status_code=503, detail="Scenes not available")
|
||||||
|
ok = state.load_scene(name)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Scene '{name}' not found")
|
||||||
|
return APIResponse(ok=True, data={"scene": name})
|
||||||
|
|
||||||
|
@router.post("/scenes/{name}/save", response_model=APIResponse)
|
||||||
|
async def save_scene(name: str):
|
||||||
|
"""Save current mixer state as a scene."""
|
||||||
|
if not state.save_scene:
|
||||||
|
raise HTTPException(status_code=503, detail="Scenes not available")
|
||||||
|
ok = state.save_scene(name)
|
||||||
|
return APIResponse(ok=True, data={"scene": name, "saved": ok})
|
||||||
|
|
||||||
|
# ── Full state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/state", response_model=MixerStateSchema)
|
||||||
|
async def get_full_state():
|
||||||
|
"""Get the complete mixer state (channels, buses, routing, plugins, scenes)."""
|
||||||
|
if not state.get_full_state:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
return state.get_full_state()
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unified parameter endpoint ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def add_unified_parameter_route(router: APIRouter, state: MixerStateProvider) -> None:
|
||||||
|
"""Add a unified /parameter endpoint that accepts category+channel+type.
|
||||||
|
|
||||||
|
PUT /parameter
|
||||||
|
Body: {"category": "channel", "channel": 0, "param_type": "volume", "value": -3.0}
|
||||||
|
"""
|
||||||
|
@router.put("/parameter", response_model=APIResponse)
|
||||||
|
async def set_parameter(body: dict):
|
||||||
|
if not state.handle_parameter:
|
||||||
|
raise HTTPException(status_code=503, detail="Mixer engine not available")
|
||||||
|
|
||||||
|
param_type = body.get("param_type", "")
|
||||||
|
value = float(body.get("value", 0.0))
|
||||||
|
channel = int(body.get("channel", -1))
|
||||||
|
|
||||||
|
state.handle_parameter(param_type, value, channel)
|
||||||
|
return APIResponse(ok=True, data={
|
||||||
|
"param_type": param_type,
|
||||||
|
"value": value,
|
||||||
|
"channel": channel,
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone network API server for the RPi Audio Mixer.
|
||||||
|
|
||||||
|
Starts the OSC, REST, and WebSocket servers. The DSP engine must
|
||||||
|
be running with Carla and JACK for full functionality.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m src.network.run [--host HOST] [--port PORT] [--osc-port PORT]
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
MIXER_API_KEY — API key for authentication (default: mixer-local)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
|
||||||
|
from src.mixer.dsp_engine import DSPEngine, DSPEngineConfig
|
||||||
|
from src.network.server import NetworkServer
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("mixer-network")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="RPi Audio Mixer — Network API Server",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", default="0.0.0.0",
|
||||||
|
help="HTTP/WS listen address (default: 0.0.0.0)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", type=int, default=8080,
|
||||||
|
help="HTTP/WS listen port (default: 8080)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--osc-port", type=int, default=9001,
|
||||||
|
help="OSC UDP listen port (default: 9001)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--channels", type=int, default=16,
|
||||||
|
help="Number of mixer channels (default: 16)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-osc", action="store_true",
|
||||||
|
help="Disable OSC server",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key",
|
||||||
|
help="API key (overrides MIXER_API_KEY env var)",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# API key
|
||||||
|
if args.api_key:
|
||||||
|
os.environ["MIXER_API_KEY"] = args.api_key
|
||||||
|
|
||||||
|
# Create DSP engine (simplified — no Carla OSC connection)
|
||||||
|
config = DSPEngineConfig(
|
||||||
|
num_channels=args.channels,
|
||||||
|
osc_enabled=False, # Don't connect to Carla in standalone mode
|
||||||
|
jack_routing_enabled=False, # Don't touch JACK connections in standalone
|
||||||
|
)
|
||||||
|
engine = DSPEngine(config)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
# Create network server
|
||||||
|
server = NetworkServer(
|
||||||
|
engine=engine,
|
||||||
|
osc_host=args.host,
|
||||||
|
osc_port=args.osc_port if not args.no_osc else 0,
|
||||||
|
http_host=args.host,
|
||||||
|
http_port=args.port,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Graceful shutdown
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
def _shutdown(sig=None, frame=None):
|
||||||
|
logger.info("Shutdown signal received")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
if not args.no_osc:
|
||||||
|
logger.info("OSC server: udp://%s:%d", args.host, args.osc_port)
|
||||||
|
logger.info("REST API: http://%s:%d/api/v1", args.host, args.port)
|
||||||
|
logger.info("WebSocket: ws://%s:%d/ws", args.host, args.port)
|
||||||
|
logger.info("API Key: %s", os.environ.get("MIXER_API_KEY", "mixer-local"))
|
||||||
|
|
||||||
|
# Wait for shutdown
|
||||||
|
await shutdown_event.wait()
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
await server.stop()
|
||||||
|
engine.stop()
|
||||||
|
logger.info("Server stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""JSON schemas for mixer state — Pydantic models for REST/WebSocket API.
|
||||||
|
|
||||||
|
These models define the canonical serialization format for mixer state,
|
||||||
|
ensuring UI interoperability across web, mobile, and desktop clients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enums ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterCategoryEnum(StrEnum):
|
||||||
|
CHANNEL = "channel"
|
||||||
|
MASTER = "master"
|
||||||
|
FX = "fx"
|
||||||
|
ROUTING = "routing"
|
||||||
|
TRANSPORT = "transport"
|
||||||
|
UTILITY = "utility"
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterTypeEnum(StrEnum):
|
||||||
|
"""All mixer parameter types."""
|
||||||
|
# Channel strip
|
||||||
|
VOLUME = "volume"
|
||||||
|
PAN = "pan"
|
||||||
|
MUTE = "mute"
|
||||||
|
SOLO = "solo"
|
||||||
|
GAIN = "gain"
|
||||||
|
PHASE_INVERT = "phase_invert"
|
||||||
|
# EQ
|
||||||
|
EQ_LOW_FREQ = "eq_low_freq"
|
||||||
|
EQ_LOW_GAIN = "eq_low_gain"
|
||||||
|
EQ_LOW_Q = "eq_low_q"
|
||||||
|
EQ_MID_FREQ = "eq_mid_freq"
|
||||||
|
EQ_MID_GAIN = "eq_mid_gain"
|
||||||
|
EQ_MID_Q = "eq_mid_q"
|
||||||
|
EQ_HIGH_FREQ = "eq_high_freq"
|
||||||
|
EQ_HIGH_GAIN = "eq_high_gain"
|
||||||
|
EQ_HIGH_Q = "eq_high_q"
|
||||||
|
EQ_ENABLE = "eq_enable"
|
||||||
|
# Dynamics
|
||||||
|
COMP_THRESHOLD = "comp_threshold"
|
||||||
|
COMP_RATIO = "comp_ratio"
|
||||||
|
COMP_ATTACK = "comp_attack"
|
||||||
|
COMP_RELEASE = "comp_release"
|
||||||
|
COMP_GAIN = "comp_gain"
|
||||||
|
GATE_THRESHOLD = "gate_threshold"
|
||||||
|
GATE_RANGE = "gate_range"
|
||||||
|
# FX
|
||||||
|
FX_SEND_A = "fx_send_a"
|
||||||
|
FX_SEND_B = "fx_send_b"
|
||||||
|
FX_RETURN_A = "fx_return_a"
|
||||||
|
FX_RETURN_B = "fx_return_b"
|
||||||
|
# Master
|
||||||
|
MASTER_VOLUME = "master_volume"
|
||||||
|
MASTER_MUTE = "master_mute"
|
||||||
|
MASTER_DIM = "master_dim"
|
||||||
|
MONITOR_VOLUME = "monitor_volume"
|
||||||
|
PHONES_VOLUME = "phones_volume"
|
||||||
|
# Transport
|
||||||
|
PLAY = "play"
|
||||||
|
STOP = "stop"
|
||||||
|
RECORD = "record"
|
||||||
|
LOOP = "loop"
|
||||||
|
TEMPO = "tempo"
|
||||||
|
TAP_TEMPO = "tap_tempo"
|
||||||
|
# Utility
|
||||||
|
SNAPSHOT_LOAD = "snapshot_load"
|
||||||
|
SNAPSHOT_SAVE = "snapshot_save"
|
||||||
|
SCENE_NEXT = "scene_next"
|
||||||
|
SCENE_PREV = "scene_prev"
|
||||||
|
|
||||||
|
|
||||||
|
class InterpolationModeEnum(StrEnum):
|
||||||
|
LINEAR = "linear"
|
||||||
|
LOGARITHMIC = "logarithmic"
|
||||||
|
S_CURVE = "s_curve"
|
||||||
|
|
||||||
|
|
||||||
|
class NodeTypeEnum(StrEnum):
|
||||||
|
SYSTEM_INPUT = "system_input"
|
||||||
|
CHANNEL_INPUT = "channel_input"
|
||||||
|
CHANNEL_DIRECT = "channel_direct"
|
||||||
|
AUX_SEND = "aux_send"
|
||||||
|
AUX_BUS = "aux_bus"
|
||||||
|
AUX_RETURN = "aux_return"
|
||||||
|
SUBGROUP = "subgroup"
|
||||||
|
VCA_GROUP = "vca_group"
|
||||||
|
MASTER_INPUT = "master_input"
|
||||||
|
MASTER_INSERT_SEND = "master_insert_send"
|
||||||
|
MASTER_INSERT_RETURN = "master_insert_return"
|
||||||
|
SYSTEM_OUTPUT = "system_output"
|
||||||
|
CARLA_PLUGIN_IN = "carla_plugin_in"
|
||||||
|
CARLA_PLUGIN_OUT = "carla_plugin_out"
|
||||||
|
FX_INPUT = "fx_input"
|
||||||
|
FX_OUTPUT = "fx_output"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter definition ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MixerParameterSchema(BaseModel):
|
||||||
|
"""A single mixer parameter definition."""
|
||||||
|
param_type: ParameterTypeEnum
|
||||||
|
category: ParameterCategoryEnum
|
||||||
|
channel: int = -1
|
||||||
|
label: str = ""
|
||||||
|
min_val: float = 0.0
|
||||||
|
max_val: float = 1.0
|
||||||
|
default_val: float = 0.5
|
||||||
|
step: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Channel schema ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class EQBandSchema(BaseModel):
|
||||||
|
"""One band of parametric EQ."""
|
||||||
|
freq_hz: float = 100.0
|
||||||
|
gain_db: float = 0.0
|
||||||
|
q: float = 0.71
|
||||||
|
|
||||||
|
|
||||||
|
class EQSchema(BaseModel):
|
||||||
|
"""3-band parametric EQ state."""
|
||||||
|
enabled: bool = False
|
||||||
|
low: EQBandSchema = Field(default_factory=lambda: EQBandSchema(freq_hz=100.0, gain_db=0.0, q=0.71))
|
||||||
|
mid: EQBandSchema = Field(default_factory=lambda: EQBandSchema(freq_hz=1000.0, gain_db=0.0, q=0.71))
|
||||||
|
high: EQBandSchema = Field(default_factory=lambda: EQBandSchema(freq_hz=5000.0, gain_db=0.0, q=0.71))
|
||||||
|
|
||||||
|
|
||||||
|
class CompressorSchema(BaseModel):
|
||||||
|
"""Compressor state."""
|
||||||
|
threshold_db: float = -20.0
|
||||||
|
ratio: float = 2.0
|
||||||
|
attack_ms: float = 10.0
|
||||||
|
release_ms: float = 100.0
|
||||||
|
makeup_gain_db: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class GateSchema(BaseModel):
|
||||||
|
"""Noise gate state."""
|
||||||
|
threshold_db: float = -40.0
|
||||||
|
range_db: float = -60.0
|
||||||
|
|
||||||
|
|
||||||
|
class FXSendsSchema(BaseModel):
|
||||||
|
"""FX send levels."""
|
||||||
|
send_a_db: float = -60.0
|
||||||
|
send_b_db: float = -60.0
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelSchema(BaseModel):
|
||||||
|
"""Complete state of one mixer channel strip."""
|
||||||
|
channel: int = Field(ge=0, description="Zero-based channel index")
|
||||||
|
label: str = ""
|
||||||
|
# Fader
|
||||||
|
volume_db: float = 0.0
|
||||||
|
pan: float = 0.0 # -1.0 L to 1.0 R
|
||||||
|
mute: bool = False
|
||||||
|
solo: bool = False
|
||||||
|
# Preamp
|
||||||
|
gain_db: float = 0.0
|
||||||
|
phase_invert: bool = False
|
||||||
|
# Processing blocks
|
||||||
|
eq: EQSchema = Field(default_factory=EQSchema)
|
||||||
|
compressor: CompressorSchema = Field(default_factory=CompressorSchema)
|
||||||
|
gate: GateSchema = Field(default_factory=GateSchema)
|
||||||
|
fx_sends: FXSendsSchema = Field(default_factory=FXSendsSchema)
|
||||||
|
# Plugin info
|
||||||
|
has_dsp: bool = False
|
||||||
|
plugins: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bus schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class AuxBusSchema(BaseModel):
|
||||||
|
"""Aux send/return bus state."""
|
||||||
|
index: int
|
||||||
|
label: str = ""
|
||||||
|
send_gain_db: float = 0.0
|
||||||
|
return_gain_db: float = 0.0
|
||||||
|
muted: bool = False
|
||||||
|
pre_fader: bool = False
|
||||||
|
fx_plugin_id: Optional[int] = None
|
||||||
|
channel_sends: dict[str, float] = Field(default_factory=dict) # "ch_N" → dB
|
||||||
|
|
||||||
|
|
||||||
|
class SubgroupSchema(BaseModel):
|
||||||
|
"""Subgroup bus state."""
|
||||||
|
index: int
|
||||||
|
label: str = ""
|
||||||
|
volume_db: float = 0.0
|
||||||
|
pan: float = 0.0
|
||||||
|
muted: bool = False
|
||||||
|
solo: bool = False
|
||||||
|
is_stereo: bool = True
|
||||||
|
members: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class VCASchema(BaseModel):
|
||||||
|
"""VCA group state."""
|
||||||
|
index: int
|
||||||
|
label: str = ""
|
||||||
|
master_db: float = 0.0
|
||||||
|
muted: bool = False
|
||||||
|
members: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MasterBusSchema(BaseModel):
|
||||||
|
"""Master bus state."""
|
||||||
|
volume_db: float = 0.0
|
||||||
|
dim_db: float = -20.0
|
||||||
|
dim_active: bool = False
|
||||||
|
muted: bool = False
|
||||||
|
mono: bool = False
|
||||||
|
insert_enabled: bool = False
|
||||||
|
aux_buses: list[AuxBusSchema] = Field(default_factory=list)
|
||||||
|
subgroups: list[SubgroupSchema] = Field(default_factory=list)
|
||||||
|
vca_groups: list[VCASchema] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transport schema ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TransportSchema(BaseModel):
|
||||||
|
"""Transport state."""
|
||||||
|
playing: bool = False
|
||||||
|
recording: bool = False
|
||||||
|
loop: bool = False
|
||||||
|
tempo_bpm: float = 120.0
|
||||||
|
position_sec: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin schema ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PluginParamMapSchema(BaseModel):
|
||||||
|
"""Parameter mapping info for a plugin."""
|
||||||
|
name: str
|
||||||
|
index: int
|
||||||
|
value: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class PluginSchema(BaseModel):
|
||||||
|
"""Information about a Carla plugin in the mixer rack."""
|
||||||
|
plugin_id: int
|
||||||
|
name: str
|
||||||
|
role: str # 'gate', 'eq', 'comp', 'gain', 'nam', 'ir', 'reverb', 'delay', 'limiter'
|
||||||
|
channel: int = -1 # -1 for master/global
|
||||||
|
params: list[PluginParamMapSchema] = Field(default_factory=list)
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routing schema ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class RouteNodeSchema(BaseModel):
|
||||||
|
"""A node in the routing graph."""
|
||||||
|
node_id: str
|
||||||
|
type: NodeTypeEnum
|
||||||
|
label: str = ""
|
||||||
|
channel: int = -1
|
||||||
|
jack_port: str = ""
|
||||||
|
is_stereo: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingEdgeSchema(BaseModel):
|
||||||
|
"""An edge in the routing graph."""
|
||||||
|
source: str
|
||||||
|
dest: str
|
||||||
|
gain_db: float = 0.0
|
||||||
|
muted: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingSchema(BaseModel):
|
||||||
|
"""Full routing matrix state."""
|
||||||
|
nodes: list[RouteNodeSchema] = Field(default_factory=list)
|
||||||
|
edges: list[RoutingEdgeSchema] = Field(default_factory=list)
|
||||||
|
solo_nodes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Full mixer state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MixerStateSchema(BaseModel):
|
||||||
|
"""Complete mixer state — the canonical serialization format."""
|
||||||
|
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||||
|
channels: list[ChannelSchema] = Field(default_factory=list)
|
||||||
|
master: MasterBusSchema = Field(default_factory=MasterBusSchema)
|
||||||
|
transport: TransportSchema = Field(default_factory=TransportSchema)
|
||||||
|
plugins: list[PluginSchema] = Field(default_factory=list)
|
||||||
|
routing: RoutingSchema = Field(default_factory=RoutingSchema)
|
||||||
|
scenes: list[str] = Field(default_factory=list)
|
||||||
|
uptime_seconds: float = 0.0
|
||||||
|
osc_stats: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parameter update schema ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterUpdateSchema(BaseModel):
|
||||||
|
"""A single parameter update — used in REST PUT and WebSocket messages."""
|
||||||
|
param_type: ParameterTypeEnum
|
||||||
|
value: float
|
||||||
|
channel: int = -1
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterUpdateBatchSchema(BaseModel):
|
||||||
|
"""Multiple parameter updates in one request."""
|
||||||
|
updates: list[ParameterUpdateSchema]
|
||||||
|
|
||||||
|
|
||||||
|
# ── WebSocket message schemas ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class WSMessageType(StrEnum):
|
||||||
|
"""WebSocket message types."""
|
||||||
|
FULL_STATE = "full_state"
|
||||||
|
PARAMETER_UPDATE = "parameter_update"
|
||||||
|
PARAMETER_BATCH = "parameter_batch"
|
||||||
|
TRANSPORT_COMMAND = "transport_command"
|
||||||
|
ERROR = "error"
|
||||||
|
SUBSCRIBE = "subscribe"
|
||||||
|
UNSUBSCRIBE = "unsubscribe"
|
||||||
|
|
||||||
|
|
||||||
|
class WSMessage(BaseModel):
|
||||||
|
"""A WebSocket protocol message."""
|
||||||
|
type: WSMessageType
|
||||||
|
payload: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ── API response wrappers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class APIResponse(BaseModel):
|
||||||
|
"""Standard API response wrapper."""
|
||||||
|
ok: bool = True
|
||||||
|
data: Optional[dict] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterSetRequest(BaseModel):
|
||||||
|
"""Request body for setting a parameter via REST."""
|
||||||
|
param_type: str = "volume"
|
||||||
|
value: float
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
"""Main network server — ties OSC, REST, and WebSocket together.
|
||||||
|
|
||||||
|
The NetworkServer is the top-level class that:
|
||||||
|
1. Creates and manages the OSC server (UDP)
|
||||||
|
2. Creates the FastAPI app with REST + WebSocket endpoints
|
||||||
|
3. Bridges the mixer DSP engine to all external protocols
|
||||||
|
4. Handles authentication and rate limiting
|
||||||
|
5. Provides a unified start/stop lifecycle
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.mixer.dsp_engine import DSPEngine
|
||||||
|
from src.network import NetworkServer
|
||||||
|
|
||||||
|
engine = DSPEngine()
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
server = NetworkServer(engine)
|
||||||
|
await server.start()
|
||||||
|
# ... mixer runs ...
|
||||||
|
await server.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, WebSocket, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from ..midi.types import (
|
||||||
|
ParameterType,
|
||||||
|
ParameterCategory,
|
||||||
|
MixerParameter,
|
||||||
|
)
|
||||||
|
from ..mixer.dsp_engine import DSPEngine
|
||||||
|
|
||||||
|
from .auth import APIKeyAuth, require_api_key, API_KEY_HEADER
|
||||||
|
from .rate_limiter import RateLimiter, rate_limit
|
||||||
|
from .osc_server import OSCServer
|
||||||
|
from .rest_api import MixerStateProvider, create_router
|
||||||
|
from .websocket import WebSocketManager
|
||||||
|
from .session import SessionManager
|
||||||
|
from .web_routes import create_web_routes, get_static_files_app
|
||||||
|
from .schemas import (
|
||||||
|
ChannelSchema,
|
||||||
|
MasterBusSchema,
|
||||||
|
MixerStateSchema,
|
||||||
|
PluginSchema,
|
||||||
|
TransportSchema,
|
||||||
|
RoutingSchema,
|
||||||
|
AuxBusSchema,
|
||||||
|
SubgroupSchema,
|
||||||
|
VCASchema,
|
||||||
|
EQSchema,
|
||||||
|
EQBandSchema,
|
||||||
|
CompressorSchema,
|
||||||
|
GateSchema,
|
||||||
|
FXSendsSchema,
|
||||||
|
ParameterUpdateSchema,
|
||||||
|
WSMessage,
|
||||||
|
WSMessageType,
|
||||||
|
RouteNodeSchema,
|
||||||
|
RoutingEdgeSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkServer:
|
||||||
|
"""Unified network server for the RPi Audio Mixer.
|
||||||
|
|
||||||
|
Manages OSC (UDP), REST (HTTP), and WebSocket servers, bridging
|
||||||
|
them to the DSP engine. Provides authentication, rate limiting,
|
||||||
|
and connection management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
engine: DSPEngine,
|
||||||
|
osc_host: str = "0.0.0.0",
|
||||||
|
osc_port: int = 9001,
|
||||||
|
http_host: str = "0.0.0.0",
|
||||||
|
http_port: int = 8080,
|
||||||
|
api_keys: list[str] | None = None,
|
||||||
|
rate_limit: float = 100.0,
|
||||||
|
rate_capacity: int = 200,
|
||||||
|
ws_max_connections: int = 20,
|
||||||
|
session_ttl: float = 86400,
|
||||||
|
web_dir: str | None = None,
|
||||||
|
audio_dir: str | None = None,
|
||||||
|
):
|
||||||
|
self._engine = engine
|
||||||
|
self._osc_host = osc_host
|
||||||
|
self._osc_port = osc_port
|
||||||
|
self._http_host = http_host
|
||||||
|
self._http_port = http_port
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
self._auth = APIKeyAuth(keys=api_keys)
|
||||||
|
|
||||||
|
# Session manager for WebSocket browser auth
|
||||||
|
self._session_manager = SessionManager(ttl=session_ttl)
|
||||||
|
|
||||||
|
# Settings storage
|
||||||
|
self._settings: dict = {
|
||||||
|
"sample_rate": 48000,
|
||||||
|
"buffer_size": 256,
|
||||||
|
"num_channels": len(engine.channels),
|
||||||
|
"osc_enabled": True,
|
||||||
|
"osc_port": osc_port,
|
||||||
|
"http_port": http_port,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rate limiter
|
||||||
|
self._rate_limiter = RateLimiter(
|
||||||
|
rate=rate_limit,
|
||||||
|
capacity=rate_capacity,
|
||||||
|
)
|
||||||
|
|
||||||
|
# State provider (bridge between REST API and DSP engine)
|
||||||
|
self._state_provider = MixerStateProvider()
|
||||||
|
self._setup_state_provider()
|
||||||
|
|
||||||
|
# OSC server
|
||||||
|
self._osc_server = OSCServer(host=osc_host, port=osc_port)
|
||||||
|
self._osc_server.set_dispatcher(self._handle_osc_parameter)
|
||||||
|
|
||||||
|
# WebSocket manager
|
||||||
|
self._ws_manager = WebSocketManager(
|
||||||
|
max_connections=ws_max_connections,
|
||||||
|
rate_limiter=self._rate_limiter,
|
||||||
|
)
|
||||||
|
self._ws_manager.set_state_provider(self._get_mixer_state)
|
||||||
|
self._ws_manager.set_parameter_handler(self._handle_ws_parameter)
|
||||||
|
self._ws_manager.set_transport_handler(self._handle_ws_transport)
|
||||||
|
|
||||||
|
# Web UI config (must be set before _build_app)
|
||||||
|
self._web_dir = web_dir
|
||||||
|
self._audio_dir = audio_dir or os.path.expanduser("~/mixer-audio")
|
||||||
|
|
||||||
|
# FastAPI app
|
||||||
|
self._app = self._build_app()
|
||||||
|
|
||||||
|
# Server handle
|
||||||
|
self._uvicorn_server: Optional[uvicorn.Server] = None
|
||||||
|
self._running = False
|
||||||
|
self._start_time: float = 0.0
|
||||||
|
|
||||||
|
# Register parameter callback on the DSP engine's registry
|
||||||
|
self._setup_parameter_forwarding()
|
||||||
|
|
||||||
|
# ── Settings management ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_settings(self) -> dict:
|
||||||
|
"""Get current server settings."""
|
||||||
|
return dict(self._settings)
|
||||||
|
|
||||||
|
def _set_settings(self, updates: dict) -> bool:
|
||||||
|
"""Apply settings updates. Returns True if all keys are valid."""
|
||||||
|
valid_keys = set(self._settings.keys())
|
||||||
|
for key in updates:
|
||||||
|
if key not in valid_keys:
|
||||||
|
logger.warning("Rejected unknown setting key: %s", key)
|
||||||
|
return False
|
||||||
|
self._settings.update(updates)
|
||||||
|
logger.info("Settings updated: %s", updates)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── State provider setup ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _setup_state_provider(self) -> None:
|
||||||
|
"""Wire the state provider to the DSP engine."""
|
||||||
|
sp = self._state_provider
|
||||||
|
|
||||||
|
sp.get_full_state = self._get_mixer_state
|
||||||
|
sp.get_channel = self._get_channel_schema
|
||||||
|
sp.get_all_channels = self._get_all_channels
|
||||||
|
sp.get_master = self._get_master_schema
|
||||||
|
sp.get_transport = self._get_transport_schema
|
||||||
|
sp.get_plugins = self._get_plugins_schema
|
||||||
|
sp.get_routing = self._get_routing_schema
|
||||||
|
sp.list_scenes = lambda: self._engine.automation.list_scenes()
|
||||||
|
sp.load_scene = lambda name: self._engine.load_snapshot(name)
|
||||||
|
sp.save_scene = lambda name: bool(self._engine.save_snapshot(name))
|
||||||
|
sp.handle_parameter = self._handle_api_parameter
|
||||||
|
sp.handle_transport = self._handle_transport_command
|
||||||
|
|
||||||
|
def _setup_parameter_forwarding(self) -> None:
|
||||||
|
"""Forward parameter changes from the DSP engine to WebSocket clients."""
|
||||||
|
# We don't use the ParameterRegistry callback here because the
|
||||||
|
# DSP engine already handles parameter dispatch through its
|
||||||
|
# handle_parameter method. Instead, we hook into that flow
|
||||||
|
# by wrapping the engine's parameter handler, or by subscribing
|
||||||
|
# to the ParameterRegistry.
|
||||||
|
|
||||||
|
# The engine's ParameterRegistry is accessible via the MIDI engine,
|
||||||
|
# but for simplicity, we forward from our own handle_parameter calls.
|
||||||
|
# The WebSocket manager broadcasts to all connected clients.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── FastAPI app construction ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_app(self) -> FastAPI:
|
||||||
|
"""Build the FastAPI application with all routes."""
|
||||||
|
app = FastAPI(
|
||||||
|
title="RPi Audio Mixer API",
|
||||||
|
description="Network control API for the Raspberry Pi Real-Time Audio Mixer",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS — allow local network access
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Health check (no auth required)
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "running": self._running}
|
||||||
|
|
||||||
|
# API info endpoint
|
||||||
|
@app.get("/api")
|
||||||
|
async def api_root():
|
||||||
|
return {
|
||||||
|
"name": "RPi Audio Mixer API",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"endpoints": {
|
||||||
|
"rest": f"http://{self._http_host}:{self._http_port}/api/v1",
|
||||||
|
"websocket": f"ws://{self._http_host}:{self._http_port}/ws",
|
||||||
|
"osc": f"osc://{self._osc_host}:{self._osc_port}",
|
||||||
|
"webui": f"http://{self._http_host}:{self._http_port}/",
|
||||||
|
},
|
||||||
|
"auth": f"Header: {API_KEY_HEADER}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# REST API router (auth + rate-limited)
|
||||||
|
api_router = create_router(
|
||||||
|
state=self._state_provider,
|
||||||
|
auth=self._auth,
|
||||||
|
rate_limiter=self._rate_limiter,
|
||||||
|
prefix="/api/v1",
|
||||||
|
)
|
||||||
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
# Web application routes (login, settings, file management)
|
||||||
|
web_routers = create_web_routes(
|
||||||
|
auth=self._auth,
|
||||||
|
session_manager=self._session_manager,
|
||||||
|
settings_getter=self._get_settings,
|
||||||
|
settings_setter=self._set_settings,
|
||||||
|
audio_dir=Path(self._audio_dir) if self._audio_dir else None,
|
||||||
|
)
|
||||||
|
for router in web_routers:
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
# WebSocket endpoint (supports both API key and session token)
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocket_endpoint(ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
|
||||||
|
# Get client IP for identification
|
||||||
|
client_host = "unknown"
|
||||||
|
if ws.client:
|
||||||
|
client_host = getattr(ws.client, "host", "unknown")
|
||||||
|
client_id = f"{client_host}:{id(ws)}"
|
||||||
|
|
||||||
|
# Try session token first (browser clients)
|
||||||
|
session_token = ws.query_params.get("session", "")
|
||||||
|
if session_token:
|
||||||
|
session = self._session_manager.validate_token(session_token)
|
||||||
|
if session:
|
||||||
|
logger.debug("WS session auth OK: %s", session.client_id)
|
||||||
|
await self._ws_manager.handle_connection(ws, client_id)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
await ws.send_text('{"type":"error","payload":{"message":"Invalid or expired session token"}}')
|
||||||
|
await ws.close(code=4001, reason="Invalid session token")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fall back to API key auth
|
||||||
|
api_key = ws.query_params.get("api_key", "")
|
||||||
|
if not api_key:
|
||||||
|
# Try reading first message as auth
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
auth_msg = await asyncio.wait_for(ws.receive_text(), timeout=5.0)
|
||||||
|
auth_data = json.loads(auth_msg)
|
||||||
|
api_key = auth_data.get("api_key", "")
|
||||||
|
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not self._auth.validate(api_key):
|
||||||
|
await ws.send_text('{"type":"error","payload":{"message":"Invalid API key"}}')
|
||||||
|
await ws.close(code=4001, reason="Invalid API key")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._ws_manager.handle_connection(ws, client_id)
|
||||||
|
|
||||||
|
# Stats endpoint (auth required)
|
||||||
|
@app.get("/api/v1/stats")
|
||||||
|
async def get_full_stats(request: Request):
|
||||||
|
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||||
|
if not self._auth.validate(api_key):
|
||||||
|
return JSONResponse(status_code=403, content={"error": "Invalid API key"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"engine": self._engine.stats,
|
||||||
|
"osc": self._osc_server.stats,
|
||||||
|
"ws": self._ws_manager.stats,
|
||||||
|
"rate_limiter": self._rate_limiter.stats,
|
||||||
|
"auth_keys": self._auth.get_keys_count(),
|
||||||
|
"sessions": self._session_manager.stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mount static web UI files at /
|
||||||
|
try:
|
||||||
|
static_app = get_static_files_app()
|
||||||
|
app.mount("/", static_app, name="webui")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not mount web UI static files: %s", exc)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
# ── Parameter handlers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_osc_parameter(self, param: MixerParameter, value: float) -> None:
|
||||||
|
"""Handle a parameter change from the OSC server."""
|
||||||
|
try:
|
||||||
|
self._engine.handle_parameter(param, value)
|
||||||
|
|
||||||
|
# Broadcast to WebSocket clients
|
||||||
|
asyncio.create_task(
|
||||||
|
self._ws_manager.broadcast_parameter_update(
|
||||||
|
param.param_type.value,
|
||||||
|
value,
|
||||||
|
param.channel,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error handling OSC parameter: %s", exc)
|
||||||
|
|
||||||
|
def _handle_api_parameter(self, param_type: str, value: float, channel: int) -> None:
|
||||||
|
"""Handle a parameter change from the REST API."""
|
||||||
|
try:
|
||||||
|
pt = ParameterType(param_type)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Unknown parameter type from API: %s", param_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine category and build MixerParameter
|
||||||
|
category = _infer_category(pt)
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=pt,
|
||||||
|
category=category,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._engine.handle_parameter(param, value)
|
||||||
|
|
||||||
|
# Broadcast to WebSocket clients (non-blocking)
|
||||||
|
asyncio.create_task(
|
||||||
|
self._ws_manager.broadcast_parameter_update(param_type, value, channel)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_ws_parameter(self, param_type: str, value: float, channel: int) -> None:
|
||||||
|
"""Handle a parameter change from a WebSocket client."""
|
||||||
|
self._handle_api_parameter(param_type, value, channel)
|
||||||
|
|
||||||
|
def _handle_ws_transport(self, command: str) -> None:
|
||||||
|
"""Handle a transport command from a WebSocket client."""
|
||||||
|
self._handle_transport_command(command)
|
||||||
|
|
||||||
|
def _handle_transport_command(self, command: str) -> None:
|
||||||
|
"""Handle a transport command."""
|
||||||
|
transport_map = {
|
||||||
|
"play": ParameterType.PLAY,
|
||||||
|
"stop": ParameterType.STOP,
|
||||||
|
"record": ParameterType.RECORD,
|
||||||
|
"loop": ParameterType.LOOP,
|
||||||
|
}
|
||||||
|
|
||||||
|
pt = transport_map.get(command.lower())
|
||||||
|
if pt is None:
|
||||||
|
logger.warning("Unknown transport command: %s", command)
|
||||||
|
return
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=pt,
|
||||||
|
category=ParameterCategory.TRANSPORT,
|
||||||
|
channel=-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._engine.handle_parameter(param, 1.0)
|
||||||
|
|
||||||
|
# ── State serialization ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_mixer_state(self) -> MixerStateSchema:
|
||||||
|
"""Build the full mixer state schema from the DSP engine."""
|
||||||
|
channels = self._get_all_channels()
|
||||||
|
master = self._get_master_schema()
|
||||||
|
transport = self._get_transport_schema()
|
||||||
|
plugins = self._get_plugins_schema()
|
||||||
|
routing = self._get_routing_schema()
|
||||||
|
scenes = self._engine.automation.list_scenes()
|
||||||
|
|
||||||
|
return MixerStateSchema(
|
||||||
|
timestamp=datetime.utcnow().isoformat(),
|
||||||
|
channels=channels,
|
||||||
|
master=master,
|
||||||
|
transport=transport,
|
||||||
|
plugins=plugins,
|
||||||
|
routing=routing,
|
||||||
|
scenes=scenes,
|
||||||
|
uptime_seconds=self.uptime,
|
||||||
|
osc_stats=self._osc_server.stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_channel_schema(self, index: int) -> Optional[ChannelSchema]:
|
||||||
|
"""Build a ChannelSchema from a DSP channel strip."""
|
||||||
|
ch = self._engine.get_channel(index)
|
||||||
|
if ch is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
state = ch.state
|
||||||
|
return ChannelSchema(
|
||||||
|
channel=index,
|
||||||
|
label=f"CH{index + 1}",
|
||||||
|
volume_db=state.volume,
|
||||||
|
pan=state.pan,
|
||||||
|
mute=state.mute,
|
||||||
|
solo=state.solo,
|
||||||
|
gain_db=state.gain,
|
||||||
|
phase_invert=state.phase_invert,
|
||||||
|
eq=EQSchema(
|
||||||
|
enabled=state.eq_enable,
|
||||||
|
low=EQBandSchema(freq_hz=state.eq_low_freq, gain_db=state.eq_low_gain, q=state.eq_low_q),
|
||||||
|
mid=EQBandSchema(freq_hz=state.eq_mid_freq, gain_db=state.eq_mid_gain, q=state.eq_mid_q),
|
||||||
|
high=EQBandSchema(freq_hz=state.eq_high_freq, gain_db=state.eq_high_gain, q=state.eq_high_q),
|
||||||
|
),
|
||||||
|
compressor=CompressorSchema(
|
||||||
|
threshold_db=state.comp_threshold,
|
||||||
|
ratio=state.comp_ratio,
|
||||||
|
attack_ms=state.comp_attack,
|
||||||
|
release_ms=state.comp_release,
|
||||||
|
makeup_gain_db=state.comp_gain,
|
||||||
|
),
|
||||||
|
gate=GateSchema(
|
||||||
|
threshold_db=state.gate_threshold,
|
||||||
|
range_db=state.gate_range,
|
||||||
|
),
|
||||||
|
fx_sends=FXSendsSchema(
|
||||||
|
send_a_db=state.fx_send_a,
|
||||||
|
send_b_db=state.fx_send_b,
|
||||||
|
),
|
||||||
|
has_dsp=ch.has_dsp,
|
||||||
|
plugins=list(ch._plugins.keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_all_channels(self) -> list[ChannelSchema]:
|
||||||
|
"""Get all channel schemas."""
|
||||||
|
return [
|
||||||
|
self._get_channel_schema(i)
|
||||||
|
for i in range(len(self._engine.channels))
|
||||||
|
]
|
||||||
|
|
||||||
|
def _get_master_schema(self) -> MasterBusSchema:
|
||||||
|
"""Build a MasterBusSchema from the bus manager."""
|
||||||
|
bm = self._engine.buses
|
||||||
|
m = bm.master
|
||||||
|
|
||||||
|
aux_buses = []
|
||||||
|
for aux in bm.aux_buses:
|
||||||
|
aux_buses.append(AuxBusSchema(
|
||||||
|
index=aux.index,
|
||||||
|
label=aux.label,
|
||||||
|
send_gain_db=aux.send_gain_db,
|
||||||
|
return_gain_db=aux.return_gain_db,
|
||||||
|
muted=aux.muted,
|
||||||
|
pre_fader=aux.pre_fader,
|
||||||
|
fx_plugin_id=aux.fx_plugin_id,
|
||||||
|
channel_sends={f"ch_{k}": v for k, v in aux.channel_sends.items()},
|
||||||
|
))
|
||||||
|
|
||||||
|
subgroups = []
|
||||||
|
for sg in bm.subgroups:
|
||||||
|
subgroups.append(SubgroupSchema(
|
||||||
|
index=sg.index,
|
||||||
|
label=sg.label,
|
||||||
|
volume_db=sg.volume_db,
|
||||||
|
pan=sg.pan,
|
||||||
|
muted=sg.muted,
|
||||||
|
solo=sg.solo,
|
||||||
|
is_stereo=sg.is_stereo,
|
||||||
|
members=list(sg.members),
|
||||||
|
))
|
||||||
|
|
||||||
|
vca_groups = []
|
||||||
|
for vca in bm.vca_groups:
|
||||||
|
vca_groups.append(VCASchema(
|
||||||
|
index=vca.index,
|
||||||
|
label=vca.label,
|
||||||
|
master_db=vca.master_db,
|
||||||
|
muted=vca.muted,
|
||||||
|
members=list(vca.members),
|
||||||
|
))
|
||||||
|
|
||||||
|
return MasterBusSchema(
|
||||||
|
volume_db=m.volume_db,
|
||||||
|
dim_db=m.dim_db,
|
||||||
|
dim_active=m.dim_active,
|
||||||
|
muted=m.muted,
|
||||||
|
mono=m.mono,
|
||||||
|
insert_enabled=m.insert_enabled,
|
||||||
|
aux_buses=aux_buses,
|
||||||
|
subgroups=subgroups,
|
||||||
|
vca_groups=vca_groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_transport_schema(self) -> TransportSchema:
|
||||||
|
"""Build a TransportSchema from the automation engine."""
|
||||||
|
auto = self._engine.automation
|
||||||
|
return TransportSchema(
|
||||||
|
playing=auto.is_playing,
|
||||||
|
recording=auto.is_recording,
|
||||||
|
loop=getattr(auto, "_loop", False),
|
||||||
|
tempo_bpm=120.0, # tempo is stored in engine config
|
||||||
|
position_sec=auto.current_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_plugins_schema(self) -> list[PluginSchema]:
|
||||||
|
"""Build plugin schemas from the OSC plugin registry."""
|
||||||
|
from ..mixer.osc_client import DEFAULT_PLUGIN_LAYOUT
|
||||||
|
|
||||||
|
plugins = []
|
||||||
|
for info in DEFAULT_PLUGIN_LAYOUT:
|
||||||
|
params = [
|
||||||
|
{"name": name, "index": idx, "value": 0.0}
|
||||||
|
for name, idx in info.param_map.items()
|
||||||
|
]
|
||||||
|
plugins.append(PluginSchema(
|
||||||
|
plugin_id=info.plugin_id,
|
||||||
|
name=info.name,
|
||||||
|
role=info.role,
|
||||||
|
channel=info.channel,
|
||||||
|
params=params,
|
||||||
|
active=True,
|
||||||
|
))
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def _get_routing_schema(self) -> RoutingSchema:
|
||||||
|
"""Build a RoutingSchema from the routing matrix."""
|
||||||
|
rm = self._engine.routing
|
||||||
|
data = rm.to_dict()
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
for n in data.get("nodes", []):
|
||||||
|
nodes.append(RouteNodeSchema(
|
||||||
|
node_id=n["node_id"],
|
||||||
|
type=n["type"],
|
||||||
|
label=n.get("label", ""),
|
||||||
|
channel=n.get("channel", -1),
|
||||||
|
jack_port=n.get("jack_port", ""),
|
||||||
|
is_stereo=n.get("is_stereo", False),
|
||||||
|
))
|
||||||
|
|
||||||
|
edges = []
|
||||||
|
for e in data.get("edges", []):
|
||||||
|
edges.append(RoutingEdgeSchema(
|
||||||
|
source=e["source"],
|
||||||
|
dest=e["dest"],
|
||||||
|
gain_db=e.get("gain_db", 0.0),
|
||||||
|
muted=e.get("muted", False),
|
||||||
|
is_active=e.get("is_active", True),
|
||||||
|
))
|
||||||
|
|
||||||
|
return RoutingSchema(
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
solo_nodes=data.get("solo_nodes", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start all network servers."""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
|
||||||
|
# Start OSC server
|
||||||
|
await self._osc_server.start()
|
||||||
|
|
||||||
|
# Start HTTP/WS server (uvicorn in the event loop)
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app=self._app,
|
||||||
|
host=self._http_host,
|
||||||
|
port=self._http_port,
|
||||||
|
log_level="info",
|
||||||
|
access_log=False,
|
||||||
|
)
|
||||||
|
self._uvicorn_server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
logger.info("Starting HTTP/WS server on %s:%d", self._http_host, self._http_port)
|
||||||
|
# Run uvicorn as a task so it doesn't block
|
||||||
|
self._uvicorn_task = asyncio.create_task(self._uvicorn_server.serve())
|
||||||
|
|
||||||
|
# Wait for uvicorn to be ready
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Network server started: OSC udp://%s:%d, REST http://%s:%d, WS ws://%s:%d/ws",
|
||||||
|
self._osc_host, self._osc_port,
|
||||||
|
self._http_host, self._http_port,
|
||||||
|
self._http_host, self._http_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop all network servers."""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# Stop OSC
|
||||||
|
await self._osc_server.stop()
|
||||||
|
|
||||||
|
# Stop HTTP/WS
|
||||||
|
if self._uvicorn_server:
|
||||||
|
self._uvicorn_server.should_exit = True
|
||||||
|
try:
|
||||||
|
if hasattr(self, "_uvicorn_task"):
|
||||||
|
await asyncio.wait_for(self._uvicorn_task, timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Uvicorn server did not shut down gracefully")
|
||||||
|
self._uvicorn_server = None
|
||||||
|
|
||||||
|
uptime = time.monotonic() - self._start_time
|
||||||
|
logger.info("Network server stopped (uptime: %.1fs)", uptime)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uptime(self) -> float:
|
||||||
|
if not self._start_time:
|
||||||
|
return 0.0
|
||||||
|
return time.monotonic() - self._start_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app(self) -> FastAPI:
|
||||||
|
"""The FastAPI application (for mounting in other ASGI servers)."""
|
||||||
|
return self._app
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_category(pt: ParameterType) -> ParameterCategory:
|
||||||
|
"""Infer the parameter category from its type."""
|
||||||
|
channel_params = {
|
||||||
|
ParameterType.VOLUME, ParameterType.PAN, ParameterType.MUTE,
|
||||||
|
ParameterType.SOLO, ParameterType.GAIN, ParameterType.PHASE_INVERT,
|
||||||
|
ParameterType.EQ_LOW_FREQ, ParameterType.EQ_LOW_GAIN, ParameterType.EQ_LOW_Q,
|
||||||
|
ParameterType.EQ_MID_FREQ, ParameterType.EQ_MID_GAIN, ParameterType.EQ_MID_Q,
|
||||||
|
ParameterType.EQ_HIGH_FREQ, ParameterType.EQ_HIGH_GAIN, ParameterType.EQ_HIGH_Q,
|
||||||
|
ParameterType.EQ_ENABLE,
|
||||||
|
ParameterType.COMP_THRESHOLD, ParameterType.COMP_RATIO, ParameterType.COMP_ATTACK,
|
||||||
|
ParameterType.COMP_RELEASE, ParameterType.COMP_GAIN,
|
||||||
|
ParameterType.GATE_THRESHOLD, ParameterType.GATE_RANGE,
|
||||||
|
ParameterType.FX_SEND_A, ParameterType.FX_SEND_B,
|
||||||
|
}
|
||||||
|
master_params = {
|
||||||
|
ParameterType.MASTER_VOLUME, ParameterType.MASTER_MUTE,
|
||||||
|
ParameterType.MASTER_DIM, ParameterType.MONITOR_VOLUME,
|
||||||
|
ParameterType.PHONES_VOLUME,
|
||||||
|
}
|
||||||
|
fx_params = {ParameterType.FX_RETURN_A, ParameterType.FX_RETURN_B}
|
||||||
|
transport_params = {
|
||||||
|
ParameterType.PLAY, ParameterType.STOP, ParameterType.RECORD,
|
||||||
|
ParameterType.LOOP, ParameterType.TEMPO, ParameterType.TAP_TEMPO,
|
||||||
|
}
|
||||||
|
utility_params = {
|
||||||
|
ParameterType.SNAPSHOT_LOAD, ParameterType.SNAPSHOT_SAVE,
|
||||||
|
ParameterType.SCENE_NEXT, ParameterType.SCENE_PREV,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pt in channel_params:
|
||||||
|
return ParameterCategory.CHANNEL
|
||||||
|
if pt in master_params:
|
||||||
|
return ParameterCategory.MASTER
|
||||||
|
if pt in fx_params:
|
||||||
|
return ParameterCategory.FX
|
||||||
|
if pt in transport_params:
|
||||||
|
return ParameterCategory.TRANSPORT
|
||||||
|
if pt in utility_params:
|
||||||
|
return ParameterCategory.UTILITY
|
||||||
|
return ParameterCategory.CHANNEL
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Session-based authentication for WebSocket connections.
|
||||||
|
|
||||||
|
Provides a token-based session system for browser clients:
|
||||||
|
- POST /auth/login → returns a session token
|
||||||
|
- WS /ws?session=TOKEN → validates the session token
|
||||||
|
|
||||||
|
Sessions expire after a configurable TTL. Tokens are HMAC-SHA256 to
|
||||||
|
avoid storing raw tokens on the server.
|
||||||
|
|
||||||
|
Design:
|
||||||
|
- Tokens are opaque to the client (HMAC-derived)
|
||||||
|
- Server stores session data in memory (dict) with expiry
|
||||||
|
- Stale sessions are evicted on validation or periodically
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default session TTL: 24 hours
|
||||||
|
DEFAULT_SESSION_TTL = 86400
|
||||||
|
|
||||||
|
# Server secret for token signing (generated at startup or from env)
|
||||||
|
_SERVER_SECRET: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_server_secret() -> str:
|
||||||
|
"""Get or generate the server secret for token signing."""
|
||||||
|
global _SERVER_SECRET
|
||||||
|
if _SERVER_SECRET is None:
|
||||||
|
env_secret = os.environ.get("MIXER_SESSION_SECRET", "")
|
||||||
|
if env_secret:
|
||||||
|
_SERVER_SECRET = env_secret
|
||||||
|
else:
|
||||||
|
_SERVER_SECRET = secrets.token_hex(32)
|
||||||
|
logger.info("Generated new session secret (set MIXER_SESSION_SECRET env var to persist)")
|
||||||
|
return _SERVER_SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def _make_token(session_id: str, salt: str) -> str:
|
||||||
|
"""Create an opaque token from a session ID and salt using HMAC-SHA256."""
|
||||||
|
secret = _get_server_secret()
|
||||||
|
data = f"{session_id}:{salt}"
|
||||||
|
mac = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return mac
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""A single user session."""
|
||||||
|
|
||||||
|
__slots__ = ("session_id", "client_id", "created_at", "expires_at", "metadata")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
client_id: str,
|
||||||
|
ttl: float = DEFAULT_SESSION_TTL,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.client_id = client_id
|
||||||
|
self.created_at = time.time()
|
||||||
|
self.expires_at = self.created_at + ttl
|
||||||
|
self.metadata = metadata or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expired(self) -> bool:
|
||||||
|
return time.time() > self.expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ttl_remaining(self) -> float:
|
||||||
|
return max(0.0, self.expires_at - time.time())
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Manages user sessions for WebSocket authentication.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
mgr = SessionManager(ttl=3600)
|
||||||
|
|
||||||
|
# Login: create a session
|
||||||
|
token = mgr.create_session("browser-chrome-1")
|
||||||
|
# Returns opaque token
|
||||||
|
|
||||||
|
# Validate: check a token
|
||||||
|
session = mgr.validate_token(token)
|
||||||
|
if session:
|
||||||
|
print(f"Valid session for {session.client_id}")
|
||||||
|
|
||||||
|
# Logout: destroy a session
|
||||||
|
mgr.destroy_session(token)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ttl: float = DEFAULT_SESSION_TTL, max_sessions: int = 100):
|
||||||
|
self._ttl = ttl
|
||||||
|
self._max_sessions = max_sessions
|
||||||
|
|
||||||
|
# In-memory storage: token → Session
|
||||||
|
self._sessions: dict[str, Session] = {}
|
||||||
|
|
||||||
|
# Reverse index: session_id → token (for revocation by ID)
|
||||||
|
self._session_ids: dict[str, str] = {}
|
||||||
|
|
||||||
|
self._total_created: int = 0
|
||||||
|
self._total_destroyed: int = 0
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
ttl: Optional[float] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a new session and return its opaque token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: Human-readable client identifier (e.g., browser user-agent).
|
||||||
|
metadata: Optional metadata stored with the session.
|
||||||
|
ttl: Override the default session TTL (seconds).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An opaque token string that can be used to validate the session.
|
||||||
|
"""
|
||||||
|
# Evict stale sessions first
|
||||||
|
self._evict_stale()
|
||||||
|
|
||||||
|
# Enforce max sessions
|
||||||
|
if len(self._sessions) >= self._max_sessions:
|
||||||
|
self._evict_oldest()
|
||||||
|
|
||||||
|
session_id = secrets.token_hex(16)
|
||||||
|
salt = secrets.token_hex(8)
|
||||||
|
token = _make_token(session_id, salt)
|
||||||
|
|
||||||
|
effective_ttl = ttl if ttl is not None else self._ttl
|
||||||
|
|
||||||
|
session = Session(
|
||||||
|
session_id=session_id,
|
||||||
|
client_id=client_id,
|
||||||
|
ttl=effective_ttl,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sessions[token] = session
|
||||||
|
self._session_ids[session_id] = token
|
||||||
|
self._total_created += 1
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Session created: %s for %s (expires in %ds)",
|
||||||
|
session_id, client_id, effective_ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
def validate_token(self, token: str) -> Optional[Session]:
|
||||||
|
"""Validate a session token and return the Session if valid.
|
||||||
|
|
||||||
|
Returns None if the token is invalid or expired.
|
||||||
|
Stale sessions are evicted during validation.
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session = self._sessions.get(token)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session.expired:
|
||||||
|
self._destroy(token, session)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def destroy_session(self, token: str) -> bool:
|
||||||
|
"""Destroy a session by token. Returns True if the session existed."""
|
||||||
|
session = self._sessions.get(token)
|
||||||
|
if session is None:
|
||||||
|
return False
|
||||||
|
self._destroy(token, session)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def destroy_by_id(self, session_id: str) -> bool:
|
||||||
|
"""Destroy a session by its internal ID."""
|
||||||
|
token = self._session_ids.pop(session_id, None)
|
||||||
|
if token is None:
|
||||||
|
return False
|
||||||
|
session = self._sessions.pop(token, None)
|
||||||
|
if session:
|
||||||
|
self._total_destroyed += 1
|
||||||
|
return session is not None
|
||||||
|
|
||||||
|
def get_session(self, token: str) -> Optional[Session]:
|
||||||
|
"""Get a session without validating expiry."""
|
||||||
|
return self._sessions.get(token)
|
||||||
|
|
||||||
|
def refresh_session(self, token: str, ttl: Optional[float] = None) -> bool:
|
||||||
|
"""Extend the TTL of a valid session."""
|
||||||
|
session = self._sessions.get(token)
|
||||||
|
if session is None or session.expired:
|
||||||
|
return False
|
||||||
|
effective_ttl = ttl if ttl is not None else self._ttl
|
||||||
|
session.expires_at = time.time() + effective_ttl
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Internal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _destroy(self, token: str, session: Session) -> None:
|
||||||
|
"""Remove a session from storage."""
|
||||||
|
self._sessions.pop(token, None)
|
||||||
|
self._session_ids.pop(session.session_id, None)
|
||||||
|
self._total_destroyed += 1
|
||||||
|
|
||||||
|
def _evict_stale(self) -> int:
|
||||||
|
"""Remove all expired sessions. Returns number evicted."""
|
||||||
|
now = time.time()
|
||||||
|
stale = [
|
||||||
|
(tok, sess)
|
||||||
|
for tok, sess in self._sessions.items()
|
||||||
|
if now > sess.expires_at
|
||||||
|
]
|
||||||
|
for tok, sess in stale:
|
||||||
|
self._destroy(tok, sess)
|
||||||
|
if stale:
|
||||||
|
logger.debug("Evicted %d stale sessions", len(stale))
|
||||||
|
return len(stale)
|
||||||
|
|
||||||
|
def _evict_oldest(self) -> None:
|
||||||
|
"""Evict the oldest session to make room."""
|
||||||
|
if not self._sessions:
|
||||||
|
return
|
||||||
|
oldest = min(self._sessions.values(), key=lambda s: s.created_at)
|
||||||
|
token = self._session_ids.get(oldest.session_id)
|
||||||
|
if token:
|
||||||
|
self._destroy(token, oldest)
|
||||||
|
logger.debug("Evicted oldest session to make room")
|
||||||
|
|
||||||
|
# ── Stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_sessions(self) -> int:
|
||||||
|
return len(self._sessions)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict:
|
||||||
|
self._evict_stale()
|
||||||
|
return {
|
||||||
|
"active_sessions": len(self._sessions),
|
||||||
|
"total_created": self._total_created,
|
||||||
|
"total_destroyed": self._total_destroyed,
|
||||||
|
"max_sessions": self._max_sessions,
|
||||||
|
"default_ttl": self._ttl,
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Web application routes — login, settings, file management, and static file serving.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
POST /api/v1/auth/login — exchange API key for session token
|
||||||
|
POST /api/v1/auth/logout — invalidate a session token
|
||||||
|
GET /api/v1/settings — get server settings
|
||||||
|
PUT /api/v1/settings — update server settings
|
||||||
|
GET /api/v1/files — list audio files
|
||||||
|
GET /api/v1/files/{name} — download an audio file
|
||||||
|
GET / — serve web UI (index.html)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import (
|
||||||
|
FileResponse,
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
PlainTextResponse,
|
||||||
|
)
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .auth import APIKeyAuth, API_KEY_HEADER
|
||||||
|
from .session import SessionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default web UI directory (relative to project root)
|
||||||
|
DEFAULT_WEB_DIR = Path(__file__).resolve().parent.parent.parent / "web"
|
||||||
|
|
||||||
|
# Default audio files directory
|
||||||
|
DEFAULT_AUDIO_DIR = Path.home() / "mixer-audio"
|
||||||
|
|
||||||
|
# Recognized audio file extensions
|
||||||
|
AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".aiff", ".aif", ".m4a"}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_login_router(
|
||||||
|
auth: APIKeyAuth,
|
||||||
|
session_manager: SessionManager,
|
||||||
|
) -> APIRouter:
|
||||||
|
"""Create the session auth router (login/logout)."""
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(request: Request):
|
||||||
|
"""Exchange an API key for a session token.
|
||||||
|
|
||||||
|
Request body: {"api_key": "your-key"} or X-API-Key header.
|
||||||
|
Returns: {"token": "session-token", "expires_in": 86400}
|
||||||
|
"""
|
||||||
|
# Try JSON body first, then header
|
||||||
|
api_key = ""
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
api_key = body.get("api_key", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
api_key = request.headers.get(API_KEY_HEADER, "").strip()
|
||||||
|
|
||||||
|
if not auth.validate(api_key):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||||
|
|
||||||
|
client_id = request.headers.get("user-agent", "unknown")
|
||||||
|
token = session_manager.create_session(client_id=client_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"token": token,
|
||||||
|
"expires_in": int(session_manager._ttl),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Invalidate a session token."""
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
token = ""
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
token = body.get("token", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing token")
|
||||||
|
|
||||||
|
destroyed = session_manager.destroy_session(token)
|
||||||
|
return {"ok": destroyed}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _make_settings_router(
|
||||||
|
settings_getter: Callable[[], dict],
|
||||||
|
settings_setter: Callable[[dict], bool],
|
||||||
|
) -> APIRouter:
|
||||||
|
"""Create the settings REST router."""
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/settings", tags=["settings"])
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_settings():
|
||||||
|
"""Get current server settings."""
|
||||||
|
return settings_getter()
|
||||||
|
|
||||||
|
@router.put("")
|
||||||
|
async def update_settings(request: Request):
|
||||||
|
"""Update server settings.
|
||||||
|
|
||||||
|
Request body: {"key": "value", ...}
|
||||||
|
Only known keys are accepted; unknown keys return 400.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Body must be a JSON object")
|
||||||
|
|
||||||
|
ok = settings_setter(body)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid setting keys")
|
||||||
|
|
||||||
|
return {"ok": True, "settings": settings_getter()}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _make_files_router(
|
||||||
|
audio_dir: Path,
|
||||||
|
) -> APIRouter:
|
||||||
|
"""Create the file management REST router."""
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/files", tags=["files"])
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_files():
|
||||||
|
"""List available audio files."""
|
||||||
|
if not audio_dir.exists():
|
||||||
|
return {"files": [], "directory": str(audio_dir), "exists": False}
|
||||||
|
|
||||||
|
files = []
|
||||||
|
try:
|
||||||
|
for entry in sorted(audio_dir.iterdir()):
|
||||||
|
if entry.is_file() and entry.suffix.lower() in AUDIO_EXTENSIONS:
|
||||||
|
stat = entry.stat()
|
||||||
|
files.append({
|
||||||
|
"name": entry.name,
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"modified": stat.st_mtime,
|
||||||
|
})
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=403, detail="Cannot read audio directory")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"files": files,
|
||||||
|
"directory": str(audio_dir),
|
||||||
|
"exists": True,
|
||||||
|
"count": len(files),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{filename:path}")
|
||||||
|
async def download_file(filename: str):
|
||||||
|
"""Download an audio file."""
|
||||||
|
file_path = (audio_dir / filename).resolve()
|
||||||
|
|
||||||
|
# Security: ensure the resolved path is within the audio directory
|
||||||
|
try:
|
||||||
|
file_path.relative_to(audio_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=403, detail="Path traversal denied")
|
||||||
|
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
||||||
|
|
||||||
|
if file_path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=403, detail="Not an audio file")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
filename=file_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def create_web_routes(
|
||||||
|
auth: APIKeyAuth,
|
||||||
|
session_manager: SessionManager,
|
||||||
|
settings_getter: Callable[[], dict],
|
||||||
|
settings_setter: Callable[[dict], bool],
|
||||||
|
web_dir: Optional[Path] = None,
|
||||||
|
audio_dir: Optional[Path] = None,
|
||||||
|
) -> list[APIRouter]:
|
||||||
|
"""Create all web application routers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth: API key authenticator for login.
|
||||||
|
session_manager: Session manager for WebSocket auth.
|
||||||
|
settings_getter: Returns current settings dict.
|
||||||
|
settings_setter: Accepts partial settings dict, returns True if valid.
|
||||||
|
web_dir: Directory containing web UI static files.
|
||||||
|
audio_dir: Directory for audio file management.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of APIRouter instances to include in the FastAPI app.
|
||||||
|
"""
|
||||||
|
if web_dir is None:
|
||||||
|
web_dir = DEFAULT_WEB_DIR
|
||||||
|
if audio_dir is None:
|
||||||
|
audio_dir = DEFAULT_AUDIO_DIR
|
||||||
|
|
||||||
|
routers = [
|
||||||
|
_make_login_router(auth, session_manager),
|
||||||
|
_make_settings_router(settings_getter, settings_setter),
|
||||||
|
_make_files_router(audio_dir),
|
||||||
|
]
|
||||||
|
|
||||||
|
return routers
|
||||||
|
|
||||||
|
|
||||||
|
def get_static_files_app(web_dir: Optional[Path] = None) -> StaticFiles:
|
||||||
|
"""Get the StaticFiles app for serving the web UI.
|
||||||
|
|
||||||
|
Returns a StaticFiles instance that can be mounted on the FastAPI app.
|
||||||
|
If the web directory doesn't exist, creates it with a placeholder index.html.
|
||||||
|
"""
|
||||||
|
if web_dir is None:
|
||||||
|
web_dir = DEFAULT_WEB_DIR
|
||||||
|
|
||||||
|
if not web_dir.exists():
|
||||||
|
web_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
_create_placeholder_ui(web_dir)
|
||||||
|
|
||||||
|
return StaticFiles(directory=str(web_dir), html=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_placeholder_ui(web_dir: Path) -> None:
|
||||||
|
"""Create a minimal placeholder web UI."""
|
||||||
|
index_path = web_dir / "index.html"
|
||||||
|
if not index_path.exists():
|
||||||
|
index_path.write_text("""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RPi Audio Mixer</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
.status { padding: 1rem; border-radius: 8px; margin: 1rem 0; }
|
||||||
|
.connected { background: #d4edda; color: #155724; }
|
||||||
|
.disconnected { background: #f8d7da; color: #721c24; }
|
||||||
|
input, button { padding: 0.5rem; margin: 0.25rem; }
|
||||||
|
#messages { background: #f5f5f5; padding: 1rem; border-radius: 8px; max-height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>RPi Audio Mixer</h1>
|
||||||
|
<div id="status" class="status disconnected">Disconnected</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" id="token" placeholder="Session token" style="width: 300px;">
|
||||||
|
<button onclick="connect()">Connect</button>
|
||||||
|
<button onclick="disconnect()">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin: 1rem 0;">
|
||||||
|
<button onclick="login()">Login (get session)</button>
|
||||||
|
<input type="text" id="apikey" placeholder="API Key" value="mixer-local">
|
||||||
|
</div>
|
||||||
|
<h3>Messages</h3>
|
||||||
|
<div id="messages"></div>
|
||||||
|
<script>
|
||||||
|
let ws = null;
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const messages = document.getElementById('messages');
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = new Date().toLocaleTimeString() + ' ' + msg;
|
||||||
|
messages.prepend(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const apiKey = document.getElementById('apikey').value;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-API-Key': apiKey},
|
||||||
|
body: JSON.stringify({api_key: apiKey}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.token) {
|
||||||
|
document.getElementById('token').value = data.token;
|
||||||
|
log('Logged in, token: ' + data.token.substring(0, 16) + '...');
|
||||||
|
} else {
|
||||||
|
log('Login failed: ' + JSON.stringify(data));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Login error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const token = document.getElementById('token').value;
|
||||||
|
if (!token) { log('No session token'); return; }
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(proto + '//' + location.host + '/ws?session=' + token);
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.textContent = 'Connected';
|
||||||
|
status.className = 'status connected';
|
||||||
|
log('WebSocket connected');
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
log(msg.type + ': ' + JSON.stringify(msg.payload).substring(0, 100));
|
||||||
|
} catch (err) {
|
||||||
|
log('Message: ' + e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = (e) => {
|
||||||
|
status.textContent = 'Disconnected';
|
||||||
|
status.className = 'status disconnected';
|
||||||
|
log('WebSocket closed: ' + e.code);
|
||||||
|
ws = null;
|
||||||
|
};
|
||||||
|
ws.onerror = (e) => log('WebSocket error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (ws) { ws.close(); ws = null; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>""")
|
||||||
|
logger.info("Created placeholder web UI at %s", index_path)
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""WebSocket manager for real-time bidirectional parameter updates.
|
||||||
|
|
||||||
|
Manages WebSocket client connections, broadcasting parameter updates
|
||||||
|
to all connected clients, and receiving parameter changes from clients.
|
||||||
|
|
||||||
|
Uses the `websockets` library for low-level WebSocket support, or
|
||||||
|
wraps FastAPI's built-in WebSocket for HTTP integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
WSMessage,
|
||||||
|
WSMessageType,
|
||||||
|
MixerStateSchema,
|
||||||
|
)
|
||||||
|
from .rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketManager:
|
||||||
|
"""Manages WebSocket connections and broadcasts.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
manager = WebSocketManager()
|
||||||
|
manager.set_state_provider(my_get_state_fn)
|
||||||
|
manager.set_parameter_handler(my_handle_param_fn)
|
||||||
|
|
||||||
|
# In FastAPI WebSocket endpoint:
|
||||||
|
await manager.handle_connection(websocket, client_id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_connections: int = 20,
|
||||||
|
rate_limiter: Optional[RateLimiter] = None,
|
||||||
|
):
|
||||||
|
self._max_connections = max_connections
|
||||||
|
self._rate_limiter = rate_limiter
|
||||||
|
|
||||||
|
# Connected clients: client_id → WebSocket
|
||||||
|
self._clients: dict[str, WebSocket] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
self._state_provider: Optional[Callable[[], MixerStateSchema]] = None
|
||||||
|
self._parameter_handler: Optional[Callable[[str, float, int], None]] = None
|
||||||
|
self._transport_handler: Optional[Callable[[str], None]] = None
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
self._total_connections: int = 0
|
||||||
|
self._total_messages: int = 0
|
||||||
|
self._total_broadcasts: int = 0
|
||||||
|
self._start_time: float = time.monotonic()
|
||||||
|
|
||||||
|
def set_state_provider(self, fn: Callable[[], MixerStateSchema]) -> None:
|
||||||
|
"""Set the callback that provides the current mixer state snapshot."""
|
||||||
|
self._state_provider = fn
|
||||||
|
|
||||||
|
def set_parameter_handler(self, fn: Callable[[str, float, int], None]) -> None:
|
||||||
|
"""Set the callback for incoming parameter changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fn: Callable(param_type: str, value: float, channel: int)
|
||||||
|
"""
|
||||||
|
self._parameter_handler = fn
|
||||||
|
|
||||||
|
def set_transport_handler(self, fn: Callable[[str], None]) -> None:
|
||||||
|
"""Set the callback for transport commands from clients."""
|
||||||
|
self._transport_handler = fn
|
||||||
|
|
||||||
|
async def handle_connection(self, websocket: WebSocket, client_id: str) -> None:
|
||||||
|
"""Handle a new WebSocket connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The FastAPI WebSocket connection.
|
||||||
|
client_id: Unique client identifier (IP-based).
|
||||||
|
"""
|
||||||
|
# Check connection limit
|
||||||
|
async with self._lock:
|
||||||
|
if len(self._clients) >= self._max_connections:
|
||||||
|
await websocket.close(code=1013, reason="Too many connections")
|
||||||
|
return
|
||||||
|
self._clients[client_id] = websocket
|
||||||
|
self._total_connections += 1
|
||||||
|
|
||||||
|
logger.info("WS client connected: %s (total: %d)", client_id, len(self._clients))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send full state on connect
|
||||||
|
await self._send_full_state(websocket)
|
||||||
|
|
||||||
|
# Main message loop
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
except RuntimeError:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Rate limit check
|
||||||
|
if self._rate_limiter:
|
||||||
|
if not await self._rate_limiter.check_ws(client_id):
|
||||||
|
await self._send_error(websocket, "Rate limit exceeded")
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._total_messages += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = WSMessage.model_validate_json(data)
|
||||||
|
await self._handle_client_message(client_id, msg)
|
||||||
|
except (json.JSONDecodeError, ValueError) as exc:
|
||||||
|
await self._send_error(websocket, f"Invalid message: {exc}")
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("WS error for %s: %s", client_id, exc)
|
||||||
|
finally:
|
||||||
|
await self._remove_client(client_id)
|
||||||
|
logger.info("WS client disconnected: %s (remaining: %d)", client_id, len(self._clients))
|
||||||
|
|
||||||
|
async def _send_full_state(self, websocket: WebSocket) -> None:
|
||||||
|
"""Send the full mixer state to a newly connected client."""
|
||||||
|
if self._state_provider:
|
||||||
|
try:
|
||||||
|
state = self._state_provider()
|
||||||
|
await self._send_json(websocket, {
|
||||||
|
"type": WSMessageType.FULL_STATE.value,
|
||||||
|
"payload": state.model_dump(),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error getting full state: %s", exc)
|
||||||
|
await self._send_error(websocket, "Failed to get mixer state")
|
||||||
|
|
||||||
|
async def broadcast(self, message: WSMessage) -> int:
|
||||||
|
"""Broadcast a message to all connected clients.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of clients the message was sent to.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
clients = list(self._clients.items())
|
||||||
|
|
||||||
|
self._total_broadcasts += 1
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"type": message.type.value,
|
||||||
|
"payload": message.payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for client_id, ws in clients:
|
||||||
|
try:
|
||||||
|
await ws.send_text(payload)
|
||||||
|
count += 1
|
||||||
|
except (WebSocketDisconnect, RuntimeError):
|
||||||
|
await self._remove_client(client_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Broadcast failed to %s: %s", client_id, exc)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def broadcast_parameter_update(
|
||||||
|
self,
|
||||||
|
param_type: str,
|
||||||
|
value: float,
|
||||||
|
channel: int = -1,
|
||||||
|
) -> int:
|
||||||
|
"""Broadcast a parameter update to all clients."""
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.PARAMETER_UPDATE,
|
||||||
|
payload={
|
||||||
|
"param_type": param_type,
|
||||||
|
"value": value,
|
||||||
|
"channel": channel,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await self.broadcast(msg)
|
||||||
|
|
||||||
|
async def broadcast_transport_update(self, state: dict) -> int:
|
||||||
|
"""Broadcast transport state changes."""
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.TRANSPORT_COMMAND,
|
||||||
|
payload=state,
|
||||||
|
)
|
||||||
|
return await self.broadcast(msg)
|
||||||
|
|
||||||
|
async def _handle_client_message(self, client_id: str, msg: WSMessage) -> None:
|
||||||
|
"""Handle an incoming WebSocket message from a client."""
|
||||||
|
ws = self._clients.get(client_id)
|
||||||
|
if not ws:
|
||||||
|
return
|
||||||
|
|
||||||
|
match msg.type:
|
||||||
|
case WSMessageType.PARAMETER_UPDATE:
|
||||||
|
payload = msg.payload
|
||||||
|
param_type = payload.get("param_type", "")
|
||||||
|
value = float(payload.get("value", 0.0))
|
||||||
|
channel = int(payload.get("channel", -1))
|
||||||
|
|
||||||
|
if self._parameter_handler:
|
||||||
|
self._parameter_handler(param_type, value, channel)
|
||||||
|
|
||||||
|
# Re-broadcast to other clients
|
||||||
|
await self.broadcast_parameter_update(param_type, value, channel)
|
||||||
|
|
||||||
|
case WSMessageType.PARAMETER_BATCH:
|
||||||
|
updates = msg.payload.get("updates", [])
|
||||||
|
for update in updates:
|
||||||
|
param_type = update.get("param_type", "")
|
||||||
|
value = float(update.get("value", 0.0))
|
||||||
|
channel = int(update.get("channel", -1))
|
||||||
|
|
||||||
|
if self._parameter_handler:
|
||||||
|
self._parameter_handler(param_type, value, channel)
|
||||||
|
|
||||||
|
# Broadcast the batch as individual updates
|
||||||
|
for update in updates:
|
||||||
|
await self.broadcast_parameter_update(
|
||||||
|
update.get("param_type", ""),
|
||||||
|
float(update.get("value", 0.0)),
|
||||||
|
int(update.get("channel", -1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
case WSMessageType.TRANSPORT_COMMAND:
|
||||||
|
command = msg.payload.get("command", "")
|
||||||
|
if self._transport_handler and command:
|
||||||
|
self._transport_handler(command)
|
||||||
|
await self.broadcast_transport_update(msg.payload)
|
||||||
|
|
||||||
|
case WSMessageType.SUBSCRIBE:
|
||||||
|
# Subscription filtering (future: per-client channel filters)
|
||||||
|
await self._send_json(ws, {
|
||||||
|
"type": "subscribed",
|
||||||
|
"payload": {"filter": msg.payload.get("filter", "all")},
|
||||||
|
})
|
||||||
|
|
||||||
|
case _:
|
||||||
|
await self._send_error(ws, f"Unsupported message type: {msg.type.value}")
|
||||||
|
|
||||||
|
async def _send_json(self, ws: WebSocket, data: dict) -> None:
|
||||||
|
"""Send a JSON payload to a WebSocket client."""
|
||||||
|
try:
|
||||||
|
await ws.send_text(json.dumps(data))
|
||||||
|
except (WebSocketDisconnect, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _send_error(self, ws: WebSocket, message: str) -> None:
|
||||||
|
"""Send an error message to a WebSocket client."""
|
||||||
|
await self._send_json(ws, {
|
||||||
|
"type": WSMessageType.ERROR.value,
|
||||||
|
"payload": {"message": message},
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _remove_client(self, client_id: str) -> None:
|
||||||
|
"""Remove a disconnected client."""
|
||||||
|
async with self._lock:
|
||||||
|
self._clients.pop(client_id, None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected_clients(self) -> int:
|
||||||
|
return len(self._clients)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict:
|
||||||
|
uptime = time.monotonic() - self._start_time
|
||||||
|
return {
|
||||||
|
"connected_clients": len(self._clients),
|
||||||
|
"total_connections": self._total_connections,
|
||||||
|
"total_messages": self._total_messages,
|
||||||
|
"total_broadcasts": self._total_broadcasts,
|
||||||
|
"uptime_seconds": round(uptime, 1),
|
||||||
|
"max_connections": self._max_connections,
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Plugin Manager — scan, register, and manage audio plugins for RPi Mixer.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Plugin scanning engine (LV2, VST3, LADSPA, NAM)
|
||||||
|
- SQLite registry with metadata caching
|
||||||
|
- Install/remove/update plugin bundles
|
||||||
|
- Blacklist for known-broken plugins
|
||||||
|
- Category tagging (amps, reverbs, delays, dynamics, etc.)
|
||||||
|
- NAM model support: download and manage .nam files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
PluginCategory,
|
||||||
|
PluginFormat,
|
||||||
|
PluginInfo,
|
||||||
|
PluginMeta,
|
||||||
|
PluginPort,
|
||||||
|
PluginStatus,
|
||||||
|
PluginBundle,
|
||||||
|
)
|
||||||
|
from .scanner import (
|
||||||
|
scan_all,
|
||||||
|
scan_format,
|
||||||
|
scan_lv2,
|
||||||
|
scan_vst3,
|
||||||
|
scan_ladspa,
|
||||||
|
scan_nam,
|
||||||
|
)
|
||||||
|
from .registry import (
|
||||||
|
PluginRegistry,
|
||||||
|
)
|
||||||
|
from .blacklist import (
|
||||||
|
PluginBlacklist,
|
||||||
|
BlacklistEntry,
|
||||||
|
BUILTIN_BLACKLIST,
|
||||||
|
)
|
||||||
|
from .categories import (
|
||||||
|
classify,
|
||||||
|
enrich_plugin,
|
||||||
|
enrich_batch,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
CATEGORY_GROUPS,
|
||||||
|
)
|
||||||
|
from .nam import (
|
||||||
|
NAMManager,
|
||||||
|
NAMModelMeta,
|
||||||
|
)
|
||||||
|
from .manager import (
|
||||||
|
PluginManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Types
|
||||||
|
"PluginCategory",
|
||||||
|
"PluginFormat",
|
||||||
|
"PluginInfo",
|
||||||
|
"PluginMeta",
|
||||||
|
"PluginPort",
|
||||||
|
"PluginStatus",
|
||||||
|
"PluginBundle",
|
||||||
|
# Scanner
|
||||||
|
"scan_all",
|
||||||
|
"scan_format",
|
||||||
|
"scan_lv2",
|
||||||
|
"scan_vst3",
|
||||||
|
"scan_ladspa",
|
||||||
|
"scan_nam",
|
||||||
|
# Registry
|
||||||
|
"PluginRegistry",
|
||||||
|
# Blacklist
|
||||||
|
"PluginBlacklist",
|
||||||
|
"BlacklistEntry",
|
||||||
|
"BUILTIN_BLACKLIST",
|
||||||
|
# Categories
|
||||||
|
"classify",
|
||||||
|
"enrich_plugin",
|
||||||
|
"enrich_batch",
|
||||||
|
"CATEGORY_LABELS",
|
||||||
|
"CATEGORY_GROUPS",
|
||||||
|
# NAM
|
||||||
|
"NAMManager",
|
||||||
|
"NAMModelMeta",
|
||||||
|
# Manager
|
||||||
|
"PluginManager",
|
||||||
|
]
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Plugin blacklist — known-broken plugins that should not be loaded.
|
||||||
|
|
||||||
|
Maintains a curated list of plugins known to crash, cause xruns, or produce
|
||||||
|
unusable output on Raspberry Pi 4B. The blacklist is pattern-based: each
|
||||||
|
entry matches against plugin URI, name, or bundle path using a glob-like syntax.
|
||||||
|
|
||||||
|
Built-in entries are shipped with the code; users can extend via
|
||||||
|
~/.config/rpi-mixer/blacklist.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BLACKLIST_PATH = Path.home() / ".config" / "rpi-mixer" / "blacklist.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BlacklistEntry:
|
||||||
|
"""A single blacklist rule."""
|
||||||
|
pattern: str # URI / name / path pattern (glob)
|
||||||
|
field: str = "uri" # Field to match against: uri, name, path
|
||||||
|
reason: str = "" # Human-readable explanation
|
||||||
|
severity: str = "block" # block (hard) or warn (advisory)
|
||||||
|
added_by: str = "builtin" # builtin or user
|
||||||
|
added_at: float = 0.0 # Unix timestamp
|
||||||
|
|
||||||
|
|
||||||
|
# ── Built-in blacklist ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BUILTIN_BLACKLIST: list[BlacklistEntry] = [
|
||||||
|
# ── Known RPi4B-incompatible plugins ──────────────────────────────────
|
||||||
|
|
||||||
|
# Heavy neural plugins that require x86-only libraries
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*NeuralAmpModeler*",
|
||||||
|
field="name",
|
||||||
|
reason="NAM LV2 standard models cause guaranteed xruns on RPi4B. "
|
||||||
|
"Use nano/feather models only. Standard models require 35%+ CPU "
|
||||||
|
"at single instance and are unbounded at 128f buffer.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="urn:nam:*",
|
||||||
|
field="uri",
|
||||||
|
reason="Auto-scanned NAM models are size-checked at scan time; "
|
||||||
|
"standard models will be flagged rpi4b_known_broken. "
|
||||||
|
"Only nano/feather models are loadable on RPi4B.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Plugins that depend on x86 SIMD (SSE/AVX)
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*Guitarix*",
|
||||||
|
field="name",
|
||||||
|
reason="Guitarix LV2 plugins use hand-optimized x86 SIMD (SSE2/SSE3). "
|
||||||
|
"ARM NEON fallbacks exist but are known to produce incorrect output "
|
||||||
|
"on some builds.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*guitarix*",
|
||||||
|
field="path",
|
||||||
|
reason="Guitarix builds may contain x86-optimized code paths.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Plugins known to crash Carla/JACK
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*CarlaRack*",
|
||||||
|
field="name",
|
||||||
|
reason="Carla internal plugins — these are host-internal and should not "
|
||||||
|
"be loaded as standalone plugins.",
|
||||||
|
severity="block",
|
||||||
|
),
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*carla*internal*",
|
||||||
|
field="path",
|
||||||
|
reason="Carla internal plugin directory — do not scan.",
|
||||||
|
severity="block",
|
||||||
|
),
|
||||||
|
|
||||||
|
# 32-bit plugins on 64-bit system
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*/i386-linux-gnu/*",
|
||||||
|
field="path",
|
||||||
|
reason="32-bit plugin on 64-bit ARM system. Cannot be loaded without "
|
||||||
|
"multiarch bridging (not supported on RPi4B).",
|
||||||
|
severity="block",
|
||||||
|
),
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*/lib32/*",
|
||||||
|
field="path",
|
||||||
|
reason="32-bit library directory — incompatible with 64-bit host.",
|
||||||
|
severity="block",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Debug/dev plugins that shouldn't be loaded in production
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*lv2#eg-*",
|
||||||
|
field="uri",
|
||||||
|
reason="LV2 example plugins — development reference only.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="urn:lv2:eg-*",
|
||||||
|
field="uri",
|
||||||
|
reason="LV2 example plugin.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
|
||||||
|
# GUI-only plugins (headless RPi)
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*qt5*",
|
||||||
|
field="path",
|
||||||
|
reason="Plugin depends on Qt5 GUI libraries — may fail on headless RPi4B.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*qt6*",
|
||||||
|
field="path",
|
||||||
|
reason="Plugin depends on Qt6 GUI libraries — may fail on headless RPi4B.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Broken known versions
|
||||||
|
BlacklistEntry(
|
||||||
|
pattern="*Calf*VintageDelay*",
|
||||||
|
field="name",
|
||||||
|
reason="Calf Vintage Delay (specific builds) produces NaN output on ARM "
|
||||||
|
"when feedback > 0 due to denormal handling bug.",
|
||||||
|
severity="warn",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginBlacklist:
|
||||||
|
"""Manages the blacklist of known-broken plugins.
|
||||||
|
|
||||||
|
Loads built-in entries and overlays user-defined entries from
|
||||||
|
~/.config/rpi-mixer/blacklist.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, blacklist_path: str | Path = DEFAULT_BLACKLIST_PATH):
|
||||||
|
self._path = Path(blacklist_path)
|
||||||
|
self._entries: dict[str, BlacklistEntry] = {}
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _reload(self) -> None:
|
||||||
|
"""(Re)load entries from built-in and user sources."""
|
||||||
|
self._entries.clear()
|
||||||
|
|
||||||
|
# Load built-ins
|
||||||
|
for entry in BUILTIN_BLACKLIST:
|
||||||
|
key = f"{entry.field}:{entry.pattern}"
|
||||||
|
self._entries[key] = entry
|
||||||
|
|
||||||
|
# Overlay user entries
|
||||||
|
user_entries = self._load_user_entries()
|
||||||
|
for entry in user_entries:
|
||||||
|
key = f"{entry.field}:{entry.pattern}"
|
||||||
|
self._entries[key] = entry
|
||||||
|
|
||||||
|
logger.debug("Loaded %d blacklist entries", len(self._entries))
|
||||||
|
|
||||||
|
def _load_user_entries(self) -> list[BlacklistEntry]:
|
||||||
|
"""Load user-defined blacklist entries from JSON."""
|
||||||
|
if not self._path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(self._path.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning("Failed to load user blacklist: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries: list[BlacklistEntry] = []
|
||||||
|
for item in data.get("entries", []):
|
||||||
|
entries.append(BlacklistEntry(
|
||||||
|
pattern=item.get("pattern", ""),
|
||||||
|
field=item.get("field", "uri"),
|
||||||
|
reason=item.get("reason", ""),
|
||||||
|
severity=item.get("severity", "block"),
|
||||||
|
added_by="user",
|
||||||
|
added_at=item.get("added_at", 0.0),
|
||||||
|
))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def check(self, uri: str, name: str, path: str = "") -> tuple[bool, list[BlacklistEntry]]:
|
||||||
|
"""Check if a plugin is blacklisted.
|
||||||
|
|
||||||
|
Returns (is_blacklisted, matching_entries).
|
||||||
|
"""
|
||||||
|
field_values = {
|
||||||
|
"uri": uri,
|
||||||
|
"name": name,
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
|
||||||
|
matches: list[BlacklistEntry] = []
|
||||||
|
for entry in self._entries.values():
|
||||||
|
value = field_values.get(entry.field, "")
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
if fnmatch.fnmatch(value, entry.pattern):
|
||||||
|
matches.append(entry)
|
||||||
|
|
||||||
|
return len(matches) > 0, matches
|
||||||
|
|
||||||
|
def is_blocked(self, uri: str, name: str, path: str = "") -> bool:
|
||||||
|
"""Quick check: returns True if the plugin has any 'block' severity match."""
|
||||||
|
blocked, matches = self.check(uri, name, path)
|
||||||
|
if not blocked:
|
||||||
|
return False
|
||||||
|
return any(m.severity == "block" for m in matches)
|
||||||
|
|
||||||
|
def has_warnings(self, uri: str, name: str, path: str = "") -> list[BlacklistEntry]:
|
||||||
|
"""Return advisory warning entries (not hard blocks)."""
|
||||||
|
_, matches = self.check(uri, name, path)
|
||||||
|
return [m for m in matches if m.severity == "warn"]
|
||||||
|
|
||||||
|
def add_user_entry(self, entry: BlacklistEntry) -> None:
|
||||||
|
"""Add a user-defined blacklist entry and persist."""
|
||||||
|
key = f"{entry.field}:{entry.pattern}"
|
||||||
|
entry.added_by = "user"
|
||||||
|
self._entries[key] = entry
|
||||||
|
self._save_user_entries()
|
||||||
|
|
||||||
|
def remove_user_entry(self, pattern: str, field: str = "uri") -> bool:
|
||||||
|
"""Remove a user-defined blacklist entry. Cannot remove built-ins."""
|
||||||
|
key = f"{field}:{pattern}"
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
if entry.added_by == "builtin":
|
||||||
|
logger.warning("Cannot remove built-in blacklist entry: %s", key)
|
||||||
|
return False
|
||||||
|
del self._entries[key]
|
||||||
|
self._save_user_entries()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _save_user_entries(self) -> None:
|
||||||
|
"""Persist user-defined entries to JSON."""
|
||||||
|
user_entries = [
|
||||||
|
{
|
||||||
|
"pattern": e.pattern,
|
||||||
|
"field": e.field,
|
||||||
|
"reason": e.reason,
|
||||||
|
"severity": e.severity,
|
||||||
|
"added_at": e.added_at,
|
||||||
|
}
|
||||||
|
for e in self._entries.values()
|
||||||
|
if e.added_by == "user"
|
||||||
|
]
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._path.write_text(json.dumps(
|
||||||
|
{"version": 1, "entries": user_entries},
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
def list_all(self) -> list[BlacklistEntry]:
|
||||||
|
"""Return all current blacklist entries."""
|
||||||
|
return sorted(self._entries.values(), key=lambda e: (e.field, e.pattern))
|
||||||
|
|
||||||
|
def list_builtin(self) -> list[BlacklistEntry]:
|
||||||
|
"""Return only built-in entries."""
|
||||||
|
return [e for e in self._entries.values() if e.added_by == "builtin"]
|
||||||
|
|
||||||
|
def list_user(self) -> list[BlacklistEntry]:
|
||||||
|
"""Return only user-defined entries."""
|
||||||
|
return [e for e in self._entries.values() if e.added_by == "user"]
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""Category tagging engine — heuristic classification for plugins.
|
||||||
|
|
||||||
|
While the scanner extracts category information from plugin manifests
|
||||||
|
(LV2 class URIs, VST3 subcategories), many plugins provide incomplete
|
||||||
|
or missing category data. This module provides a secondary classification
|
||||||
|
engine that uses keyword heuristics on plugin names, descriptions, and
|
||||||
|
paths to assign categories.
|
||||||
|
|
||||||
|
Also provides the canonical category taxonomy and human-readable labels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .types import PluginCategory, PluginInfo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Category metadata ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CATEGORY_LABELS: dict[PluginCategory, str] = {
|
||||||
|
PluginCategory.AMP: "Amplifier",
|
||||||
|
PluginCategory.AMP_BASS: "Bass Amp",
|
||||||
|
PluginCategory.AMP_GUITAR: "Guitar Amp",
|
||||||
|
PluginCategory.CABINET: "Cabinet Sim",
|
||||||
|
PluginCategory.IR_LOADER: "IR Loader",
|
||||||
|
PluginCategory.REVERB: "Reverb",
|
||||||
|
PluginCategory.DELAY: "Delay",
|
||||||
|
PluginCategory.CHORUS: "Chorus",
|
||||||
|
PluginCategory.FLANGER: "Flanger",
|
||||||
|
PluginCategory.PHASER: "Phaser",
|
||||||
|
PluginCategory.TREMOLO: "Tremolo",
|
||||||
|
PluginCategory.VIBRATO: "Vibrato",
|
||||||
|
PluginCategory.COMPRESSOR: "Compressor",
|
||||||
|
PluginCategory.LIMITER: "Limiter",
|
||||||
|
PluginCategory.GATE: "Noise Gate",
|
||||||
|
PluginCategory.EXPANDER: "Expander",
|
||||||
|
PluginCategory.EQ_PARAMETRIC: "Parametric EQ",
|
||||||
|
PluginCategory.EQ_GRAPHIC: "Graphic EQ",
|
||||||
|
PluginCategory.EQ_SHELVING: "Shelving EQ",
|
||||||
|
PluginCategory.FILTER: "Filter",
|
||||||
|
PluginCategory.DISTORTION: "Distortion",
|
||||||
|
PluginCategory.OVERDRIVE: "Overdrive",
|
||||||
|
PluginCategory.FUZZ: "Fuzz",
|
||||||
|
PluginCategory.PITCH_SHIFTER: "Pitch Shifter",
|
||||||
|
PluginCategory.OCTAVER: "Octaver",
|
||||||
|
PluginCategory.WAH: "Wah",
|
||||||
|
PluginCategory.SYNTH: "Synthesizer",
|
||||||
|
PluginCategory.SAMPLER: "Sampler",
|
||||||
|
PluginCategory.DRUM_MACHINE: "Drum Machine",
|
||||||
|
PluginCategory.UTILITY: "Utility",
|
||||||
|
PluginCategory.METER: "Meter",
|
||||||
|
PluginCategory.ANALYZER: "Analyzer",
|
||||||
|
PluginCategory.SPATIAL: "Spatial",
|
||||||
|
PluginCategory.NOISE_REDUCTION: "Noise Reduction",
|
||||||
|
PluginCategory.NAM_PROFILE: "NAM Profile",
|
||||||
|
PluginCategory.OTHER: "Other",
|
||||||
|
PluginCategory.UNKNOWN: "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY_GROUPS: dict[str, list[PluginCategory]] = {
|
||||||
|
"amp_modeling": [
|
||||||
|
PluginCategory.AMP, PluginCategory.AMP_BASS, PluginCategory.AMP_GUITAR,
|
||||||
|
PluginCategory.CABINET, PluginCategory.IR_LOADER, PluginCategory.NAM_PROFILE,
|
||||||
|
],
|
||||||
|
"reverb_delay": [
|
||||||
|
PluginCategory.REVERB, PluginCategory.DELAY,
|
||||||
|
],
|
||||||
|
"modulation": [
|
||||||
|
PluginCategory.CHORUS, PluginCategory.FLANGER, PluginCategory.PHASER,
|
||||||
|
PluginCategory.TREMOLO, PluginCategory.VIBRATO,
|
||||||
|
],
|
||||||
|
"dynamics": [
|
||||||
|
PluginCategory.COMPRESSOR, PluginCategory.LIMITER,
|
||||||
|
PluginCategory.GATE, PluginCategory.EXPANDER,
|
||||||
|
],
|
||||||
|
"eq_filter": [
|
||||||
|
PluginCategory.EQ_PARAMETRIC, PluginCategory.EQ_GRAPHIC,
|
||||||
|
PluginCategory.EQ_SHELVING, PluginCategory.FILTER,
|
||||||
|
],
|
||||||
|
"distortion": [
|
||||||
|
PluginCategory.DISTORTION, PluginCategory.OVERDRIVE, PluginCategory.FUZZ,
|
||||||
|
],
|
||||||
|
"pitch": [
|
||||||
|
PluginCategory.PITCH_SHIFTER, PluginCategory.OCTAVER, PluginCategory.WAH,
|
||||||
|
],
|
||||||
|
"instruments": [
|
||||||
|
PluginCategory.SYNTH, PluginCategory.SAMPLER, PluginCategory.DRUM_MACHINE,
|
||||||
|
],
|
||||||
|
"analysis": [
|
||||||
|
PluginCategory.METER, PluginCategory.ANALYZER,
|
||||||
|
],
|
||||||
|
"utility": [
|
||||||
|
PluginCategory.UTILITY, PluginCategory.SPATIAL, PluginCategory.NOISE_REDUCTION,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Keyword-to-category mapping ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
CATEGORY_KEYWORDS: list[tuple[list[str], PluginCategory]] = [
|
||||||
|
# Amp modeling
|
||||||
|
(["amp", "amplifier", "ampli", "guitar_amp", "bass_amp", "preamp",
|
||||||
|
"tube", "valve", "head", "combo", "gain_amp"], PluginCategory.AMP),
|
||||||
|
(["bass_amp", "bass_preamp"], PluginCategory.AMP_BASS),
|
||||||
|
(["guitar_amp", "guitar_preamp", "geetar"], PluginCategory.AMP_GUITAR),
|
||||||
|
(["cab", "cabinet", "cab_sim", "speaker_sim", "speaker_cab",
|
||||||
|
"ir_loader", "ir_conv", "impulse", "convolution"], PluginCategory.IR_LOADER),
|
||||||
|
|
||||||
|
# Reverb / Delay
|
||||||
|
(["reverb", "rev", "hall", "plate", "room", "spring", "shimmer",
|
||||||
|
"ambience", "early_reflections"], PluginCategory.REVERB),
|
||||||
|
(["delay", "echo", "tape_echo", "dub_delay", "ping_pong",
|
||||||
|
"delay_line", "bucket_brigade", "bdd"], PluginCategory.DELAY),
|
||||||
|
|
||||||
|
# Modulation
|
||||||
|
(["chorus", "ensemble", "choir_effect"], PluginCategory.CHORUS),
|
||||||
|
(["flanger", "flange", "jet"], PluginCategory.FLANGER),
|
||||||
|
(["phaser", "phase_shifter", "phase_90", "small_stone"], PluginCategory.PHASER),
|
||||||
|
(["tremolo", "trem", "tremolo_panner", "harmonic_trem"], PluginCategory.TREMOLO),
|
||||||
|
(["vibrato", "vibe", "univibe"], PluginCategory.VIBRATO),
|
||||||
|
|
||||||
|
# Dynamics
|
||||||
|
(["compressor", "comp", "compression", "dynamics_processor",
|
||||||
|
"leveling_amplifier", "leveller", "1176", "la2a", "vca_comp",
|
||||||
|
"optical_comp", "fet_comp"], PluginCategory.COMPRESSOR),
|
||||||
|
(["limiter", "limit", "maximizer", "maximiser", "brickwall",
|
||||||
|
"clipper", "soft_clip"], PluginCategory.LIMITER),
|
||||||
|
(["gate", "noise_gate", "expander", "downward_expander",
|
||||||
|
"transient_designer", "transient_shaper"], PluginCategory.GATE),
|
||||||
|
(["expander"], PluginCategory.EXPANDER),
|
||||||
|
|
||||||
|
# EQ / Filter
|
||||||
|
(["eq", "equalizer", "equaliser", "parametric", "param_eq",
|
||||||
|
"paragraphic", "band_eq", "tone_control", "tone_stack"], PluginCategory.EQ_PARAMETRIC),
|
||||||
|
(["graphic_eq", "geq", "graphic_eq_31"], PluginCategory.EQ_GRAPHIC),
|
||||||
|
(["shelving", "shelving_eq", "high_shelf", "low_shelf", "tilt_eq"], PluginCategory.EQ_SHELVING),
|
||||||
|
(["filter", "lpf", "hpf", "bpf", "notch", "lowpass", "highpass",
|
||||||
|
"bandpass", "moog_filter", "ladder_filter", "state_variable",
|
||||||
|
"svf", "comb_filter", "allpass"], PluginCategory.FILTER),
|
||||||
|
|
||||||
|
# Distortion
|
||||||
|
(["distortion", "dist", "saturation", "saturator", "exciter",
|
||||||
|
"harmonic_exciter", "enhancer", "crusher", "bit_crusher",
|
||||||
|
"sample_rate_reducer", "degradation", "waveshaper"], PluginCategory.DISTORTION),
|
||||||
|
(["overdrive", "od", "tube_screamer", "ts808", "ts9", "klon",
|
||||||
|
"centaur", "blues_driver", "sd1", "timmy"], PluginCategory.OVERDRIVE),
|
||||||
|
(["fuzz", "fuzz_face", "big_muff", "tone_bender", "super_fuzz",
|
||||||
|
"octave_fuzz", "gated_fuzz"], PluginCategory.FUZZ),
|
||||||
|
|
||||||
|
# Pitch
|
||||||
|
(["pitch_shifter", "pitch_shift", "harmonizer", "harmoniser",
|
||||||
|
"whammy", "pitch_bend", "transpose", "detune",
|
||||||
|
"microshift", "harmony"], PluginCategory.PITCH_SHIFTER),
|
||||||
|
(["octaver", "octave", "octave_down", "octave_up", "sub_octave",
|
||||||
|
"pog", "octron"], PluginCategory.OCTAVER),
|
||||||
|
(["wah", "wah_wah", "auto_wah", "envelope_filter",
|
||||||
|
"cry_baby", "vox_wah", "morley"], PluginCategory.WAH),
|
||||||
|
|
||||||
|
# Instruments
|
||||||
|
(["synth", "synthesizer", "synthesiser", "oscillator", "osc",
|
||||||
|
"wave_table", "fm_synth", "subtractive", "additive",
|
||||||
|
"granular", "physical_modeling", "string_machine"], PluginCategory.SYNTH),
|
||||||
|
(["sampler", "sample_player", "sample_playback", "multi_sample"], PluginCategory.SAMPLER),
|
||||||
|
(["drum", "drum_machine", "beatbox", "drum_synth", "kick",
|
||||||
|
"snare", "hihat", "percussion"], PluginCategory.DRUM_MACHINE),
|
||||||
|
|
||||||
|
# Analysis / Metering
|
||||||
|
(["meter", "vu", "level_meter", "peak_meter", "rms_meter",
|
||||||
|
"loudness", "lufs", "k_meter", "ppm", "correlation",
|
||||||
|
"goniometer", "phase_meter", "vectorscope"], PluginCategory.METER),
|
||||||
|
(["analyzer", "analyser", "spectrum", "spectrogram", "sonogram",
|
||||||
|
"fft", "frequency_analyzer", "tuner", "tune", "oscilloscope"], PluginCategory.ANALYZER),
|
||||||
|
|
||||||
|
# Utility
|
||||||
|
(["utility", "gain", "trim", "volume", "fader", "pan", "balance",
|
||||||
|
"mute", "solo", "phase", "polarity", "invert",
|
||||||
|
"splitter", "merger", "matrix", "router", "dc_offset",
|
||||||
|
"noise_generator", "tone_generator", "sine_wave"], PluginCategory.UTILITY),
|
||||||
|
|
||||||
|
# Spatial
|
||||||
|
(["spatial", "stereo", "width", "stereo_width", "imager",
|
||||||
|
"stereo_imager", "mid_side", "ms", "haas",
|
||||||
|
"panner", "auto_pan", "binaural", "spatializer"], PluginCategory.SPATIAL),
|
||||||
|
|
||||||
|
# Noise Reduction
|
||||||
|
(["noise", "denoiser", "noise_reduction", "nr", "noise_gate_2",
|
||||||
|
"noise_suppression", "de_noise", "hiss", "hum_removal",
|
||||||
|
"declick", "decrackle", "de_esser", "de_ess"], PluginCategory.NOISE_REDUCTION),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
name: str,
|
||||||
|
description: str = "",
|
||||||
|
uri: str = "",
|
||||||
|
path: str = "",
|
||||||
|
existing_categories: list[PluginCategory] | None = None,
|
||||||
|
) -> list[PluginCategory]:
|
||||||
|
"""Classify a plugin into categories using keyword heuristics.
|
||||||
|
|
||||||
|
If existing_categories are provided and contain anything other than
|
||||||
|
UNKNOWN/OTHER, they are returned as-is (manifest-based categories
|
||||||
|
take priority). Otherwise, heuristics are applied.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Plugin display name.
|
||||||
|
description: Plugin description (optional).
|
||||||
|
uri: Plugin URI (optional, for additional signal).
|
||||||
|
path: Plugin bundle path (optional).
|
||||||
|
existing_categories: Already-known categories from manifest parsing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of PluginCategory values (may be empty, single, or multiple).
|
||||||
|
"""
|
||||||
|
# If we have high-confidence categories from manifest, use those
|
||||||
|
if existing_categories and not all(
|
||||||
|
c in (PluginCategory.UNKNOWN, PluginCategory.OTHER)
|
||||||
|
for c in existing_categories
|
||||||
|
):
|
||||||
|
return existing_categories
|
||||||
|
|
||||||
|
# Build search corpus
|
||||||
|
corpus = f"{name.lower()} {description.lower()} {uri.lower()} {Path(path).name.lower()}"
|
||||||
|
|
||||||
|
# Split into tokens for better matching
|
||||||
|
tokens = set(corpus.replace("_", " ").replace("-", " ").split())
|
||||||
|
|
||||||
|
matched: list[PluginCategory] = []
|
||||||
|
|
||||||
|
for keywords, category in CATEGORY_KEYWORDS:
|
||||||
|
# Check for keyword presence in the corpus
|
||||||
|
if any(kw in corpus for kw in keywords):
|
||||||
|
matched.append(category)
|
||||||
|
continue
|
||||||
|
# Also check token-level matching for compound words
|
||||||
|
for kw in keywords:
|
||||||
|
kw_tokens = set(kw.split("_"))
|
||||||
|
if kw_tokens and kw_tokens.issubset(tokens):
|
||||||
|
matched.append(category)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen: set[PluginCategory] = set()
|
||||||
|
unique: list[PluginCategory] = []
|
||||||
|
for cat in matched:
|
||||||
|
if cat not in seen:
|
||||||
|
seen.add(cat)
|
||||||
|
unique.append(cat)
|
||||||
|
|
||||||
|
# Limit to most specific subcategories
|
||||||
|
# e.g., if AMP and AMP_GUITAR are both matched, prefer AMP_GUITAR
|
||||||
|
unique = _prefer_specific(unique)
|
||||||
|
|
||||||
|
if not unique:
|
||||||
|
unique = [PluginCategory.UNKNOWN]
|
||||||
|
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_specific(categories: list[PluginCategory]) -> list[PluginCategory]:
|
||||||
|
"""Prefer more specific subcategories over generic ones.
|
||||||
|
|
||||||
|
Example: [AMP, AMP_GUITAR] -> [AMP_GUITAR]
|
||||||
|
"""
|
||||||
|
# Parent-child relationships
|
||||||
|
specificity: dict[PluginCategory, PluginCategory | None] = {
|
||||||
|
PluginCategory.AMP_BASS: PluginCategory.AMP,
|
||||||
|
PluginCategory.AMP_GUITAR: PluginCategory.AMP,
|
||||||
|
PluginCategory.OVERDRIVE: PluginCategory.DISTORTION,
|
||||||
|
PluginCategory.FUZZ: PluginCategory.DISTORTION,
|
||||||
|
PluginCategory.EQ_GRAPHIC: PluginCategory.EQ_PARAMETRIC,
|
||||||
|
PluginCategory.EQ_SHELVING: PluginCategory.EQ_PARAMETRIC,
|
||||||
|
}
|
||||||
|
|
||||||
|
to_remove: set[PluginCategory] = set()
|
||||||
|
for cat in categories:
|
||||||
|
parent = specificity.get(cat)
|
||||||
|
if parent and parent in categories:
|
||||||
|
to_remove.add(parent)
|
||||||
|
|
||||||
|
return [c for c in categories if c not in to_remove]
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_plugin(plugin: PluginInfo) -> PluginInfo:
|
||||||
|
"""Enrich a PluginInfo's categories using heuristic classification.
|
||||||
|
|
||||||
|
If the plugin already has meaningful categories (not UNKNOWN/OTHER),
|
||||||
|
they are left alone. Otherwise, the name, description, URI, and path
|
||||||
|
are used to assign categories.
|
||||||
|
|
||||||
|
Returns the same PluginInfo instance (mutated in place).
|
||||||
|
"""
|
||||||
|
new_cats = classify(
|
||||||
|
name=plugin.meta.name,
|
||||||
|
description=plugin.meta.description,
|
||||||
|
uri=plugin.meta.uri,
|
||||||
|
path=plugin.bundle_path or plugin.library_path,
|
||||||
|
existing_categories=plugin.categories,
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_cats != plugin.categories:
|
||||||
|
logger.debug(
|
||||||
|
"Reclassified %s: %s → %s",
|
||||||
|
plugin.meta.name,
|
||||||
|
plugin.categories,
|
||||||
|
new_cats,
|
||||||
|
)
|
||||||
|
plugin.categories = new_cats
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_batch(plugins: list[PluginInfo]) -> list[PluginInfo]:
|
||||||
|
"""Enrich categories for a batch of plugins."""
|
||||||
|
for plugin in plugins:
|
||||||
|
enrich_plugin(plugin)
|
||||||
|
return plugins
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"""Plugin manager — install, remove, update plugin bundles.
|
||||||
|
|
||||||
|
Coordinates between the scanner, registry, blacklist, and category
|
||||||
|
classifier to provide a unified plugin lifecycle API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .blacklist import PluginBlacklist
|
||||||
|
from .categories import enrich_plugin
|
||||||
|
from .nam import NAMManager, NAMModelMeta
|
||||||
|
from .registry import PluginRegistry
|
||||||
|
from .scanner import scan_all, scan_format
|
||||||
|
from .types import (
|
||||||
|
PluginBundle,
|
||||||
|
PluginCategory,
|
||||||
|
PluginFormat,
|
||||||
|
PluginInfo,
|
||||||
|
PluginStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManager:
|
||||||
|
"""Top-level plugin lifecycle manager.
|
||||||
|
|
||||||
|
Provides a unified interface for:
|
||||||
|
- Scanning filesystem for plugins
|
||||||
|
- Registering plugins in the SQLite database
|
||||||
|
- Installing/removing/updating plugin bundles
|
||||||
|
- Blacklist checking
|
||||||
|
- Category enrichment
|
||||||
|
- NAM model management
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
registry: PluginRegistry | None = None,
|
||||||
|
blacklist: PluginBlacklist | None = None,
|
||||||
|
nam_manager: NAMManager | None = None,
|
||||||
|
):
|
||||||
|
self.registry = registry or PluginRegistry()
|
||||||
|
self.blacklist = blacklist or PluginBlacklist()
|
||||||
|
self.nam = nam_manager or NAMManager()
|
||||||
|
|
||||||
|
# ── Scan & Sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def scan(self) -> list[PluginInfo]:
|
||||||
|
"""Run a full filesystem scan and return discovered plugins."""
|
||||||
|
plugins = scan_all()
|
||||||
|
|
||||||
|
# Enrich categories
|
||||||
|
for plugin in plugins:
|
||||||
|
enrich_plugin(plugin)
|
||||||
|
|
||||||
|
# Apply blacklist
|
||||||
|
for plugin in plugins:
|
||||||
|
if self.blacklist.is_blocked(
|
||||||
|
plugin.meta.uri, plugin.meta.name, plugin.bundle_path
|
||||||
|
):
|
||||||
|
plugin.status = PluginStatus.BLACKLISTED
|
||||||
|
|
||||||
|
# Apply size-based NAM filtering
|
||||||
|
for plugin in plugins:
|
||||||
|
if plugin.meta.format == PluginFormat.NAM:
|
||||||
|
if plugin.nam_model_size == "standard":
|
||||||
|
plugin.status = PluginStatus.DISABLED
|
||||||
|
plugin.error_message = (
|
||||||
|
"Standard NAM models are known to cause xruns on RPi4B. "
|
||||||
|
"Use nano or feather models instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def sync(self) -> dict:
|
||||||
|
"""Scan and synchronize the registry.
|
||||||
|
|
||||||
|
Returns the sync result dict: {inserted, updated, stale}.
|
||||||
|
"""
|
||||||
|
plugins = self.scan()
|
||||||
|
result = self.registry.sync_from_scan(plugins)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Install ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def install_bundle(self, bundle: PluginBundle) -> PluginInfo | None:
|
||||||
|
"""Install a plugin from a bundle descriptor.
|
||||||
|
|
||||||
|
Downloads the bundle, extracts it, optionally runs a build script,
|
||||||
|
and registers the resulting plugin.
|
||||||
|
|
||||||
|
Returns the PluginInfo if successful, None on failure.
|
||||||
|
"""
|
||||||
|
logger.info("Installing plugin bundle: %s %s", bundle.name, bundle.version)
|
||||||
|
|
||||||
|
# Determine install path based on format
|
||||||
|
format_install_dirs = {
|
||||||
|
PluginFormat.LV2: Path("/usr/local/lib/lv2"),
|
||||||
|
PluginFormat.VST3: Path("/usr/local/lib/vst3"),
|
||||||
|
PluginFormat.LADSPA: Path("/usr/local/lib/ladspa"),
|
||||||
|
PluginFormat.VST2: Path("/usr/local/lib/vst"),
|
||||||
|
}
|
||||||
|
install_dir = format_install_dirs.get(bundle.format, Path.home() / ".lv2")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="rpi-mixer-install-") as tmpdir:
|
||||||
|
tmp = Path(tmpdir)
|
||||||
|
archive_path = tmp / f"{bundle.name}.{bundle.source_type}"
|
||||||
|
|
||||||
|
# Download
|
||||||
|
try:
|
||||||
|
self._download_file(bundle.source_url, archive_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Download failed for %s: %s", bundle.name, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify checksum
|
||||||
|
if bundle.checksum_sha256:
|
||||||
|
import hashlib
|
||||||
|
actual = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
||||||
|
if actual.lower() != bundle.checksum_sha256.lower():
|
||||||
|
logger.error("Checksum mismatch for %s", bundle.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
extract_dir = tmp / "extracted"
|
||||||
|
extract_dir.mkdir()
|
||||||
|
try:
|
||||||
|
self._extract(archive_path, extract_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Extraction failed for %s: %s", bundle.name, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Run build script if specified
|
||||||
|
if bundle.build_script:
|
||||||
|
build_script_path = extract_dir / bundle.build_script
|
||||||
|
if build_script_path.exists():
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["bash", str(build_script_path)],
|
||||||
|
cwd=str(extract_dir),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error("Build failed for %s: %s\n%s", bundle.name, e, e.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Copy files to install destination
|
||||||
|
install_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if bundle.install_paths:
|
||||||
|
for src_rel, dest_rel in bundle.install_paths.items():
|
||||||
|
src = extract_dir / src_rel
|
||||||
|
dest = install_dir / dest_rel
|
||||||
|
if src.is_dir():
|
||||||
|
if dest.exists():
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
shutil.copytree(src, dest)
|
||||||
|
else:
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
else:
|
||||||
|
# Default: copy everything to install dir
|
||||||
|
bundle_name = extract_dir.name
|
||||||
|
dest = install_dir / bundle_name
|
||||||
|
if dest.exists():
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
shutil.copytree(extract_dir, dest)
|
||||||
|
|
||||||
|
# After install, re-scan to pick up the new plugin
|
||||||
|
plugins = scan_format(bundle.format)
|
||||||
|
for plugin in plugins:
|
||||||
|
if plugin.meta.name.lower() == bundle.name.lower():
|
||||||
|
enrich_plugin(plugin)
|
||||||
|
self.registry.upsert(plugin)
|
||||||
|
logger.info("Installed and registered: %s", plugin.meta.name)
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
logger.warning("Plugin %s installed but not detected by scan", bundle.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def install_local(self, source_path: str | Path, format: PluginFormat) -> PluginInfo | None:
|
||||||
|
"""Install a locally-available plugin by copying it to the appropriate directory."""
|
||||||
|
src = Path(source_path)
|
||||||
|
if not src.exists():
|
||||||
|
logger.error("Source not found: %s", src)
|
||||||
|
return None
|
||||||
|
|
||||||
|
format_install_dirs = {
|
||||||
|
PluginFormat.LV2: Path("/usr/local/lib/lv2"),
|
||||||
|
PluginFormat.VST3: Path("/usr/local/lib/vst3"),
|
||||||
|
PluginFormat.LADSPA: Path("/usr/local/lib/ladspa"),
|
||||||
|
}
|
||||||
|
install_dir = format_install_dirs.get(
|
||||||
|
format, Path.home() / f".{format.value}"
|
||||||
|
)
|
||||||
|
install_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if src.is_dir():
|
||||||
|
dest = install_dir / src.name
|
||||||
|
if dest.exists():
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
shutil.copytree(src, dest)
|
||||||
|
else:
|
||||||
|
dest = install_dir / src.name
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
logger.info("Installed local plugin to %s", dest)
|
||||||
|
|
||||||
|
# Re-scan
|
||||||
|
plugins = scan_format(format)
|
||||||
|
for plugin in plugins:
|
||||||
|
if str(dest) in plugin.bundle_path or plugin.bundle_path == str(dest):
|
||||||
|
enrich_plugin(plugin)
|
||||||
|
self.registry.upsert(plugin)
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Remove ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def remove(self, uri: str, delete_files: bool = False) -> bool:
|
||||||
|
"""Remove a plugin from the registry.
|
||||||
|
|
||||||
|
If delete_files is True, also removes the plugin files from disk.
|
||||||
|
|
||||||
|
Returns True if the plugin was found and removed.
|
||||||
|
"""
|
||||||
|
plugin = self.registry.get(uri)
|
||||||
|
if plugin is None:
|
||||||
|
logger.warning("Plugin not found in registry: %s", uri)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if delete_files and plugin.bundle_path:
|
||||||
|
bundle = Path(plugin.bundle_path)
|
||||||
|
if bundle.exists():
|
||||||
|
try:
|
||||||
|
if bundle.is_dir():
|
||||||
|
shutil.rmtree(bundle)
|
||||||
|
else:
|
||||||
|
bundle.unlink()
|
||||||
|
logger.info("Deleted plugin files: %s", bundle)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed to delete plugin files: %s", e)
|
||||||
|
|
||||||
|
return self.registry.delete(uri)
|
||||||
|
|
||||||
|
def remove_by_name(self, name: str, delete_files: bool = False) -> bool:
|
||||||
|
"""Remove a plugin by name."""
|
||||||
|
plugin = self.registry.get_by_name(name)
|
||||||
|
if plugin is None:
|
||||||
|
logger.warning("Plugin not found: %s", name)
|
||||||
|
return False
|
||||||
|
return self.remove(plugin.meta.uri, delete_files)
|
||||||
|
|
||||||
|
# ── Update ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def update(self, uri: str) -> PluginInfo | None:
|
||||||
|
"""Refresh a plugin's metadata by re-scanning it.
|
||||||
|
|
||||||
|
Returns the updated PluginInfo, or None if the plugin is gone.
|
||||||
|
"""
|
||||||
|
plugin = self.registry.get(uri)
|
||||||
|
if plugin is None:
|
||||||
|
logger.warning("Plugin not found: %s", uri)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Re-scan the format
|
||||||
|
plugins = scan_format(plugin.meta.format)
|
||||||
|
for scanned in plugins:
|
||||||
|
if scanned.meta.uri == uri:
|
||||||
|
enrich_plugin(scanned)
|
||||||
|
scanned.installed_at = plugin.installed_at
|
||||||
|
scanned.updated_at = time.time()
|
||||||
|
|
||||||
|
# Re-check blacklist
|
||||||
|
if self.blacklist.is_blocked(
|
||||||
|
scanned.meta.uri, scanned.meta.name, scanned.bundle_path
|
||||||
|
):
|
||||||
|
scanned.status = PluginStatus.BLACKLISTED
|
||||||
|
|
||||||
|
self.registry.upsert(scanned)
|
||||||
|
return scanned
|
||||||
|
|
||||||
|
# Plugin not found by re-scan → mark stale
|
||||||
|
self.registry.update(uri, status=PluginStatus.STALE.value)
|
||||||
|
logger.info("Plugin %s not found on re-scan, marked stale", uri)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_all(self) -> dict:
|
||||||
|
"""Re-scan all installed plugins to refresh metadata.
|
||||||
|
|
||||||
|
Returns {updated, stale_uris}.
|
||||||
|
"""
|
||||||
|
updated_count = 0
|
||||||
|
stale_uris: list[str] = []
|
||||||
|
|
||||||
|
for plugin in self.registry.list_all():
|
||||||
|
result = self.update(plugin.meta.uri)
|
||||||
|
if result:
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
stale_uris.append(plugin.meta.uri)
|
||||||
|
|
||||||
|
return {"updated": updated_count, "stale": stale_uris}
|
||||||
|
|
||||||
|
# ── Blacklist management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def blacklist_plugin(self, uri: str) -> bool:
|
||||||
|
"""Mark a plugin as blacklisted."""
|
||||||
|
return self.registry.update(uri, status=PluginStatus.BLACKLISTED.value)
|
||||||
|
|
||||||
|
def unblacklist_plugin(self, uri: str) -> bool:
|
||||||
|
"""Restore a blacklisted plugin to active status."""
|
||||||
|
return self.registry.update(uri, status=PluginStatus.ACTIVE.value)
|
||||||
|
|
||||||
|
def enable_plugin(self, uri: str) -> bool:
|
||||||
|
"""Enable a disabled plugin."""
|
||||||
|
return self.registry.update(uri, status=PluginStatus.ACTIVE.value)
|
||||||
|
|
||||||
|
def disable_plugin(self, uri: str) -> bool:
|
||||||
|
"""Disable a plugin without removing it."""
|
||||||
|
return self.registry.update(uri, status=PluginStatus.DISABLED.value)
|
||||||
|
|
||||||
|
# ── NAM model management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def nam_download(self, url: str, name: str | None = None) -> PluginInfo | None:
|
||||||
|
"""Download a NAM model and register it."""
|
||||||
|
path = self.nam.download(url, name)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta = self.nam.get_model(path.stem)
|
||||||
|
if meta is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = self.nam.to_plugin_info(meta)
|
||||||
|
enrich_plugin(info)
|
||||||
|
self.registry.upsert(info)
|
||||||
|
return info
|
||||||
|
|
||||||
|
def nam_install_local(self, source: str, name: str | None = None) -> PluginInfo | None:
|
||||||
|
"""Install a local .nam file and register it."""
|
||||||
|
path = self.nam.install_local(source, name)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta = self.nam.get_model(path.stem)
|
||||||
|
if meta is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = self.nam.to_plugin_info(meta)
|
||||||
|
enrich_plugin(info)
|
||||||
|
self.registry.upsert(info)
|
||||||
|
return info
|
||||||
|
|
||||||
|
def nam_remove(self, name: str) -> bool:
|
||||||
|
"""Remove a NAM model by name."""
|
||||||
|
# Remove from registry
|
||||||
|
model_path = self.nam._model_path(name)
|
||||||
|
sha = __import__("hashlib").sha256(str(model_path).encode()).hexdigest()[:16]
|
||||||
|
uri = f"urn:nam:{name}:{sha}"
|
||||||
|
self.registry.delete(uri)
|
||||||
|
|
||||||
|
# Remove from disk
|
||||||
|
return self.nam.remove(name)
|
||||||
|
|
||||||
|
def nam_list(self) -> list[PluginInfo]:
|
||||||
|
"""List all installed NAM models as PluginInfo objects."""
|
||||||
|
return self.registry.list_nam_models()
|
||||||
|
|
||||||
|
# ── Query helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_plugins(
|
||||||
|
self,
|
||||||
|
format: PluginFormat | None = None,
|
||||||
|
category: PluginCategory | None = None,
|
||||||
|
loadable_only: bool = False,
|
||||||
|
) -> list[PluginInfo]:
|
||||||
|
"""Query plugins with optional filters."""
|
||||||
|
if format:
|
||||||
|
plugins = self.registry.list_by_format(format)
|
||||||
|
elif category:
|
||||||
|
plugins = self.registry.list_by_category(category)
|
||||||
|
else:
|
||||||
|
plugins = self.registry.list_all()
|
||||||
|
|
||||||
|
if loadable_only:
|
||||||
|
plugins = [p for p in plugins if p.is_loadable]
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[PluginInfo]:
|
||||||
|
"""Search plugins by name, description, or author."""
|
||||||
|
return self.registry.search(query)
|
||||||
|
|
||||||
|
# ── Internal helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _download_file(url: str, dest: Path) -> None:
|
||||||
|
"""Download a file from a URL to a local path."""
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
req = Request(url, headers={"User-Agent": "rpi-mixer-plugin-mgr/1.0"})
|
||||||
|
with urlopen(req, timeout=300) as response:
|
||||||
|
dest.write_bytes(response.read())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract(archive_path: Path, dest_dir: Path) -> None:
|
||||||
|
"""Extract an archive (tar.gz, tar.bz2, tar.xz, zip) to a directory."""
|
||||||
|
fname = archive_path.name.lower()
|
||||||
|
if fname.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
|
||||||
|
with tarfile.open(archive_path) as tar:
|
||||||
|
tar.extractall(path=dest_dir)
|
||||||
|
elif fname.endswith(".zip"):
|
||||||
|
with zipfile.ZipFile(archive_path) as zf:
|
||||||
|
zf.extractall(dest_dir)
|
||||||
|
else:
|
||||||
|
# Try tar first, then zip
|
||||||
|
try:
|
||||||
|
with tarfile.open(archive_path) as tar:
|
||||||
|
tar.extractall(path=dest_dir)
|
||||||
|
except tarfile.ReadError:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(archive_path) as zf:
|
||||||
|
zf.extractall(dest_dir)
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise ValueError(f"Cannot extract {archive_path}: unknown format")
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"""NAM (Neural Amp Modeler) model management.
|
||||||
|
|
||||||
|
Handles downloading, installing, and managing .nam model files
|
||||||
|
for use with the NAM LV2 plugin on Raspberry Pi 4B.
|
||||||
|
|
||||||
|
Models are categorized by size (nano/feather/standard/custom) and
|
||||||
|
only nano/feather models are guaranteed to run without xruns on RPi4B.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
from .types import PluginCategory, PluginFormat, PluginInfo, PluginMeta, PluginStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_NAM_DIR = Path.home() / ".config" / "rpi-mixer" / "nam"
|
||||||
|
DEFAULT_NAM_LV2_MODEL_DIR = Path.home() / ".lv2" / "nam-models"
|
||||||
|
|
||||||
|
NAM_MODEL_URLS = {
|
||||||
|
# Known good nano/feather models for RPi4B
|
||||||
|
"nam_nano_example": {
|
||||||
|
"url": "https://github.com/sdatkinson/NeuralAmpModelerModelZoo/raw/main/models/nano/example.nam",
|
||||||
|
"name": "Example Nano",
|
||||||
|
"size": "nano",
|
||||||
|
"author": "NAM Community",
|
||||||
|
"description": "Lightweight example profile — suitable for RPi4B",
|
||||||
|
"sha256": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NAMModelMeta:
|
||||||
|
"""Metadata for a NAM model file."""
|
||||||
|
name: str
|
||||||
|
path: str # Local path
|
||||||
|
url: str = "" # Source URL
|
||||||
|
size_category: str = "feather" # nano, feather, standard, custom
|
||||||
|
file_size_bytes: int = 0
|
||||||
|
file_size_mb: float = 0.0
|
||||||
|
author: str = ""
|
||||||
|
description: str = ""
|
||||||
|
version: str = ""
|
||||||
|
sha256: str = ""
|
||||||
|
installed_at: float = 0.0
|
||||||
|
rpi4b_compatible: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class NAMManager:
|
||||||
|
"""Manages NAM model files — download, install, remove, list."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
models_dir: str | Path = DEFAULT_NAM_DIR,
|
||||||
|
lv2_model_dir: str | Path = DEFAULT_NAM_LV2_MODEL_DIR,
|
||||||
|
):
|
||||||
|
self._models_dir = Path(models_dir)
|
||||||
|
self._lv2_model_dir = Path(lv2_model_dir)
|
||||||
|
self._models_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ── Path helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _model_path(self, name: str) -> Path:
|
||||||
|
"""Resolve the path for a named model file."""
|
||||||
|
safe_name = name.replace(" ", "_").replace("/", "_")
|
||||||
|
if not safe_name.endswith(".nam"):
|
||||||
|
safe_name += ".nam"
|
||||||
|
return self._models_dir / safe_name
|
||||||
|
|
||||||
|
def _link_lv2_path(self, model_path: Path) -> Path:
|
||||||
|
"""Determine where to symlink the model for NAM LV2 plugin access."""
|
||||||
|
self._lv2_model_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return self._lv2_model_dir / model_path.name
|
||||||
|
|
||||||
|
# ── Size classification ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify_size(file_path_or_bytes: Path | int) -> str:
|
||||||
|
"""Classify a model by its file size.
|
||||||
|
|
||||||
|
Returns one of: nano (< 1MB), feather (1-10MB), standard (10-50MB), custom (>50MB)
|
||||||
|
"""
|
||||||
|
if isinstance(file_path_or_bytes, Path):
|
||||||
|
size_mb = file_path_or_bytes.stat().st_size / (1024 * 1024)
|
||||||
|
else:
|
||||||
|
size_mb = file_path_or_bytes / (1024 * 1024)
|
||||||
|
if size_mb < 1:
|
||||||
|
return "nano"
|
||||||
|
elif size_mb < 10:
|
||||||
|
return "feather"
|
||||||
|
elif size_mb < 50:
|
||||||
|
return "standard"
|
||||||
|
return "custom"
|
||||||
|
|
||||||
|
# ── Download ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
name: str | None = None,
|
||||||
|
sha256_expected: str = "",
|
||||||
|
timeout: int = 120,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Download a .nam model file from a URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Download URL for the .nam file.
|
||||||
|
name: Local name for the model. Derived from URL if not given.
|
||||||
|
sha256_expected: Optional SHA-256 to verify the download.
|
||||||
|
timeout: HTTP timeout in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the downloaded file, or None on failure.
|
||||||
|
"""
|
||||||
|
if name is None:
|
||||||
|
# Derive name from URL filename
|
||||||
|
name = url.rstrip("/").split("/")[-1]
|
||||||
|
if name.endswith(".nam"):
|
||||||
|
name = name[:-4]
|
||||||
|
|
||||||
|
dest = self._model_path(name)
|
||||||
|
|
||||||
|
logger.info("Downloading NAM model from %s → %s", url, dest)
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(url, headers={"User-Agent": "rpi-mixer-nam-manager/1.0"})
|
||||||
|
with urlopen(req, timeout=timeout) as response:
|
||||||
|
data = response.read()
|
||||||
|
except (URLError, HTTPError, OSError) as e:
|
||||||
|
logger.error("Failed to download NAM model from %s: %s", url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify SHA-256 if provided
|
||||||
|
if sha256_expected:
|
||||||
|
actual = hashlib.sha256(data).hexdigest()
|
||||||
|
if actual.lower() != sha256_expected.lower():
|
||||||
|
logger.error(
|
||||||
|
"SHA-256 mismatch for %s: expected %s, got %s",
|
||||||
|
name, sha256_expected, actual,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Write the file
|
||||||
|
try:
|
||||||
|
dest.write_bytes(data)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed to write NAM model to %s: %s", dest, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
size_cat = self._classify_size(dest)
|
||||||
|
logger.info(
|
||||||
|
"Downloaded NAM model %s (%s, %.1f MB)",
|
||||||
|
name, size_cat, dest.stat().st_size / (1024 * 1024),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Symlink to LV2 model directory if feasible
|
||||||
|
self._link_model(dest)
|
||||||
|
|
||||||
|
return dest
|
||||||
|
|
||||||
|
def download_known(self, model_key: str) -> Path | None:
|
||||||
|
"""Download a known-good model from the built-in catalog."""
|
||||||
|
model_info = NAM_MODEL_URLS.get(model_key)
|
||||||
|
if model_info is None:
|
||||||
|
logger.error("Unknown model key: %s", model_key)
|
||||||
|
return None
|
||||||
|
return self.download(
|
||||||
|
url=model_info["url"],
|
||||||
|
name=model_info["name"],
|
||||||
|
sha256_expected=model_info.get("sha256", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def download_from_github(
|
||||||
|
self,
|
||||||
|
repo: str,
|
||||||
|
model_path: str,
|
||||||
|
name: str | None = None,
|
||||||
|
branch: str = "main",
|
||||||
|
) -> Path | None:
|
||||||
|
"""Download a .nam file from a GitHub repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Owner/repo (e.g., "sdatkinson/NeuralAmpModelerModelZoo").
|
||||||
|
model_path: Path to the .nam file within the repo.
|
||||||
|
name: Local name (derived from model_path if not given).
|
||||||
|
branch: Branch or tag (default: main).
|
||||||
|
"""
|
||||||
|
url = f"https://raw.githubusercontent.com/{repo}/{branch}/{model_path}"
|
||||||
|
return self.download(url, name=name)
|
||||||
|
|
||||||
|
# ── Install / Manage local models ───────────────────────────────────────
|
||||||
|
|
||||||
|
def install_local(self, source_path: str | Path, name: str | None = None) -> Path | None:
|
||||||
|
"""Install a local .nam file into the managed models directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Path to an existing .nam file.
|
||||||
|
name: Name for the installed model (defaults to source filename).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the installed file, or None on failure.
|
||||||
|
"""
|
||||||
|
src = Path(source_path)
|
||||||
|
if not src.exists():
|
||||||
|
logger.error("NAM source file not found: %s", src)
|
||||||
|
return None
|
||||||
|
if not src.suffix == ".nam":
|
||||||
|
logger.warning("File does not have .nam extension: %s", src)
|
||||||
|
# Still allow it, just warn
|
||||||
|
|
||||||
|
if name is None:
|
||||||
|
name = src.stem
|
||||||
|
|
||||||
|
dest = self._model_path(name)
|
||||||
|
|
||||||
|
if dest.exists():
|
||||||
|
logger.warning("Model already exists at %s, overwriting", dest)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed to copy NAM model from %s to %s: %s", src, dest, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
size_cat = self._classify_size(dest)
|
||||||
|
logger.info("Installed NAM model %s (%s, %.1f MB)", name, size_cat, dest.stat().st_size / (1024 * 1024))
|
||||||
|
|
||||||
|
self._link_model(dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
def _link_model(self, model_path: Path) -> bool:
|
||||||
|
"""Create a symlink in the LV2 model directory for NAM LV2 access."""
|
||||||
|
if not model_path.exists():
|
||||||
|
return False
|
||||||
|
link_path = self._link_lv2_path(model_path)
|
||||||
|
try:
|
||||||
|
if link_path.exists() or link_path.is_symlink():
|
||||||
|
link_path.unlink()
|
||||||
|
os.symlink(model_path, link_path)
|
||||||
|
logger.debug("Linked %s → %s", model_path, link_path)
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Failed to symlink NAM model %s: %s", model_path, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove(self, name: str) -> bool:
|
||||||
|
"""Remove a managed NAM model file by name.
|
||||||
|
|
||||||
|
Also removes the LV2 symlink if it exists.
|
||||||
|
|
||||||
|
Returns True if the model was found and removed.
|
||||||
|
"""
|
||||||
|
model_path = self._model_path(name)
|
||||||
|
if not model_path.exists():
|
||||||
|
logger.warning("NAM model not found: %s", model_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Remove LV2 symlink
|
||||||
|
link_path = self._link_lv2_path(model_path)
|
||||||
|
if link_path.exists() or link_path.is_symlink():
|
||||||
|
try:
|
||||||
|
link_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_path.unlink()
|
||||||
|
logger.info("Removed NAM model: %s", name)
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed to remove NAM model %s: %s", model_path, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def rename(self, old_name: str, new_name: str) -> bool:
|
||||||
|
"""Rename a managed NAM model."""
|
||||||
|
old_path = self._model_path(old_name)
|
||||||
|
if not old_path.exists():
|
||||||
|
return False
|
||||||
|
new_path = self._model_path(new_name)
|
||||||
|
try:
|
||||||
|
old_path.rename(new_path)
|
||||||
|
logger.info("Renamed NAM model %s → %s", old_name, new_name)
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed to rename NAM model: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Listing ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_models(self) -> list[NAMModelMeta]:
|
||||||
|
"""List all managed NAM model files with metadata."""
|
||||||
|
models: list[NAMModelMeta] = []
|
||||||
|
|
||||||
|
for f in sorted(self._models_dir.glob("*.nam")):
|
||||||
|
size_cat = self._classify_size(f)
|
||||||
|
file_size = f.stat().st_size
|
||||||
|
rpi4b_ok = size_cat in ("nano", "feather")
|
||||||
|
|
||||||
|
models.append(NAMModelMeta(
|
||||||
|
name=f.stem,
|
||||||
|
path=str(f),
|
||||||
|
size_category=size_cat,
|
||||||
|
file_size_bytes=file_size,
|
||||||
|
file_size_mb=file_size / (1024 * 1024),
|
||||||
|
rpi4b_compatible=rpi4b_ok,
|
||||||
|
installed_at=f.stat().st_mtime,
|
||||||
|
))
|
||||||
|
|
||||||
|
return models
|
||||||
|
|
||||||
|
def list_compatible(self) -> list[NAMModelMeta]:
|
||||||
|
"""List only RPi4B-compatible models (nano/feather)."""
|
||||||
|
return [m for m in self.list_models() if m.rpi4b_compatible]
|
||||||
|
|
||||||
|
def count_models(self) -> dict:
|
||||||
|
"""Return model counts by size category."""
|
||||||
|
counts = {"nano": 0, "feather": 0, "standard": 0, "custom": 0}
|
||||||
|
for m in self.list_models():
|
||||||
|
counts[m.size_category] = counts.get(m.size_category, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def get_model(self, name: str) -> NAMModelMeta | None:
|
||||||
|
"""Get metadata for a specific model."""
|
||||||
|
model_path = self._model_path(name)
|
||||||
|
if not model_path.exists():
|
||||||
|
return None
|
||||||
|
size_cat = self._classify_size(model_path)
|
||||||
|
file_size = model_path.stat().st_size
|
||||||
|
return NAMModelMeta(
|
||||||
|
name=name,
|
||||||
|
path=str(model_path),
|
||||||
|
size_category=size_cat,
|
||||||
|
file_size_bytes=file_size,
|
||||||
|
file_size_mb=file_size / (1024 * 1024),
|
||||||
|
rpi4b_compatible=size_cat in ("nano", "feather"),
|
||||||
|
installed_at=model_path.stat().st_mtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── PluginInfo integration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def to_plugin_info(self, model: NAMModelMeta) -> PluginInfo:
|
||||||
|
"""Convert a NAMModelMeta to a PluginInfo for registry ingestion."""
|
||||||
|
sha = hashlib.sha256(model.path.encode()).hexdigest()[:16]
|
||||||
|
return PluginInfo(
|
||||||
|
meta=PluginMeta(
|
||||||
|
name=model.name,
|
||||||
|
uri=f"urn:nam:{model.name}:{sha}",
|
||||||
|
format=PluginFormat.NAM,
|
||||||
|
version=model.version,
|
||||||
|
author=model.author,
|
||||||
|
description=model.description,
|
||||||
|
arch="aarch64",
|
||||||
|
arm_optimized=True,
|
||||||
|
),
|
||||||
|
categories=[PluginCategory.NAM_PROFILE],
|
||||||
|
bundle_path=str(self._models_dir),
|
||||||
|
library_path="",
|
||||||
|
nam_model_path=model.path,
|
||||||
|
nam_model_size=model.size_category,
|
||||||
|
status=PluginStatus.ACTIVE,
|
||||||
|
scanned_at=time.time(),
|
||||||
|
installed_at=model.installed_at,
|
||||||
|
estimated_cpu_pct={
|
||||||
|
"nano": 8.0, "feather": 18.0, "standard": 35.0, "custom": 50.0
|
||||||
|
}.get(model.size_category, 30.0),
|
||||||
|
rpi4b_known_good=model.rpi4b_compatible,
|
||||||
|
rpi4b_known_broken=not model.rpi4b_compatible and model.size_category == "standard",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── NAM model database ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_model_db_path(self) -> Path:
|
||||||
|
"""Path to the NAM model metadata database (JSON index)."""
|
||||||
|
return self._models_dir / "models.json"
|
||||||
|
|
||||||
|
def save_model_db(self, models: list[NAMModelMeta]) -> None:
|
||||||
|
"""Save model metadata to the JSON index."""
|
||||||
|
db: dict = {
|
||||||
|
"version": 1,
|
||||||
|
"updated": time.time(),
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": m.name,
|
||||||
|
"path": m.path,
|
||||||
|
"url": m.url,
|
||||||
|
"size_category": m.size_category,
|
||||||
|
"file_size_bytes": m.file_size_bytes,
|
||||||
|
"author": m.author,
|
||||||
|
"description": m.description,
|
||||||
|
"version": m.version,
|
||||||
|
"sha256": m.sha256,
|
||||||
|
"rpi4b_compatible": m.rpi4b_compatible,
|
||||||
|
}
|
||||||
|
for m in models
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.get_model_db_path().write_text(json.dumps(db, indent=2))
|
||||||
|
|
||||||
|
def load_model_db(self) -> list[NAMModelMeta]:
|
||||||
|
"""Load model metadata from the JSON index."""
|
||||||
|
db_path = self.get_model_db_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return self.list_models()
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = json.loads(db_path.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return self.list_models()
|
||||||
|
|
||||||
|
models: list[NAMModelMeta] = []
|
||||||
|
for item in db.get("models", []):
|
||||||
|
models.append(NAMModelMeta(
|
||||||
|
name=item.get("name", ""),
|
||||||
|
path=item.get("path", ""),
|
||||||
|
url=item.get("url", ""),
|
||||||
|
size_category=item.get("size_category", "standard"),
|
||||||
|
file_size_bytes=item.get("file_size_bytes", 0),
|
||||||
|
author=item.get("author", ""),
|
||||||
|
description=item.get("description", ""),
|
||||||
|
version=item.get("version", ""),
|
||||||
|
sha256=item.get("sha256", ""),
|
||||||
|
rpi4b_compatible=item.get("rpi4b_compatible", False),
|
||||||
|
))
|
||||||
|
return models
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""Plugin registry database — SQLite-backed persistence for scanned plugins.
|
||||||
|
|
||||||
|
Provides CRUD operations, metadata caching, and querying by category,
|
||||||
|
format, status, and other attributes. Uses a single SQLite database
|
||||||
|
file at ~/.config/rpi-mixer/plugins.db by default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator, Optional
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
PluginCategory,
|
||||||
|
PluginFormat,
|
||||||
|
PluginInfo,
|
||||||
|
PluginStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_DB_PATH = Path.home() / ".config" / "rpi-mixer" / "plugins.db"
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
CREATE_TABLE_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
|
-- Identity
|
||||||
|
uri TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
version TEXT DEFAULT '',
|
||||||
|
author TEXT DEFAULT '',
|
||||||
|
license TEXT DEFAULT '',
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
homepage TEXT DEFAULT '',
|
||||||
|
project TEXT DEFAULT '',
|
||||||
|
|
||||||
|
-- Platform
|
||||||
|
arch TEXT DEFAULT 'aarch64',
|
||||||
|
os TEXT DEFAULT 'linux',
|
||||||
|
arm_optimized INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Classification
|
||||||
|
categories TEXT DEFAULT '',
|
||||||
|
audio_inputs INTEGER DEFAULT 0,
|
||||||
|
audio_outputs INTEGER DEFAULT 0,
|
||||||
|
midi_inputs INTEGER DEFAULT 0,
|
||||||
|
midi_outputs INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Filesystem
|
||||||
|
bundle_path TEXT DEFAULT '',
|
||||||
|
library_path TEXT DEFAULT '',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
error_message TEXT DEFAULT '',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
scanned_at REAL DEFAULT 0.0,
|
||||||
|
installed_at REAL DEFAULT 0.0,
|
||||||
|
updated_at REAL DEFAULT 0.0,
|
||||||
|
|
||||||
|
-- Performance
|
||||||
|
estimated_cpu_pct REAL DEFAULT 0.0,
|
||||||
|
estimated_ram_mb REAL DEFAULT 0.0,
|
||||||
|
rpi4b_known_good INTEGER DEFAULT 0,
|
||||||
|
rpi4b_known_broken INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- NAM
|
||||||
|
nam_model_path TEXT DEFAULT '',
|
||||||
|
nam_model_size TEXT DEFAULT '',
|
||||||
|
|
||||||
|
-- Bookkeeping
|
||||||
|
_created_at REAL DEFAULT (strftime('%s', 'now')),
|
||||||
|
_updated_at REAL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugins_format ON plugins(format);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name COLLATE NOCASE);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugins_scanned ON plugins(scanned_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRegistry:
|
||||||
|
"""SQLite-backed plugin registry.
|
||||||
|
|
||||||
|
Thread-safe for reads. Write operations should be serialised externally
|
||||||
|
if used concurrently (SQLite's own locking handles most cases).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | Path = DEFAULT_DB_PATH):
|
||||||
|
self._db_path = Path(db_path)
|
||||||
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
"""Create tables and indexes if they don't exist."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
conn.executescript(CREATE_TABLE_SQL)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO plugins (uri, name, format, status) "
|
||||||
|
"VALUES ('_schema_version', 'schema', 'meta', 'active')"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE plugins SET version = ? WHERE uri = '_schema_version'",
|
||||||
|
(str(SCHEMA_VERSION),)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Get a database connection. Auto-closes on context exit."""
|
||||||
|
conn = sqlite3.connect(str(self._db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ── CRUD ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def insert(self, plugin: PluginInfo) -> bool:
|
||||||
|
"""Insert a new plugin into the registry.
|
||||||
|
|
||||||
|
Returns True if inserted, False if a plugin with the same URI already exists.
|
||||||
|
"""
|
||||||
|
data = plugin.to_dict()
|
||||||
|
columns = ", ".join(data.keys())
|
||||||
|
placeholders = ", ".join("?" for _ in data)
|
||||||
|
values = list(data.values())
|
||||||
|
|
||||||
|
with self._conn() as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO plugins ({columns}) VALUES ({placeholders})",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
logger.debug("Plugin %s already exists in registry", plugin.meta.uri)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def upsert(self, plugin: PluginInfo) -> bool:
|
||||||
|
"""Insert or update a plugin in the registry.
|
||||||
|
|
||||||
|
Returns True if the row was modified.
|
||||||
|
"""
|
||||||
|
data = plugin.to_dict()
|
||||||
|
set_clause = ", ".join(f"{k} = ?" for k in data)
|
||||||
|
values = list(data.values())
|
||||||
|
|
||||||
|
with self._conn() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f"INSERT OR REPLACE INTO plugins ({', '.join(data.keys())}) "
|
||||||
|
f"VALUES ({', '.join('?' for _ in data)})",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def update(self, uri: str, **kwargs: Any) -> bool:
|
||||||
|
"""Update specific fields of a plugin by URI."""
|
||||||
|
if not kwargs:
|
||||||
|
return False
|
||||||
|
|
||||||
|
set_parts = [f"{k} = ?" for k in kwargs]
|
||||||
|
values = list(kwargs.values()) + [uri]
|
||||||
|
|
||||||
|
with self._conn() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f"UPDATE plugins SET {', '.join(set_parts)}, _updated_at = ? "
|
||||||
|
f"WHERE uri = ?",
|
||||||
|
list(kwargs.values()) + [time.time(), uri],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def delete(self, uri: str) -> bool:
|
||||||
|
"""Soft-delete a plugin (marks status=removed)."""
|
||||||
|
return self.update(uri, status=PluginStatus.REMOVED.value)
|
||||||
|
|
||||||
|
def purge(self, uri: str) -> bool:
|
||||||
|
"""Hard-delete a plugin from the registry."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
cursor = conn.execute("DELETE FROM plugins WHERE uri = ?", (uri,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
# ── Querying ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get(self, uri: str) -> PluginInfo | None:
|
||||||
|
"""Look up a single plugin by URI."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE uri = ?", (uri,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return PluginInfo.from_dict(dict(row))
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> PluginInfo | None:
|
||||||
|
"""Look up a plugin by name (case-insensitive)."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE name COLLATE NOCASE = ?",
|
||||||
|
(name,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return PluginInfo.from_dict(dict(row))
|
||||||
|
|
||||||
|
def list_all(self, include_removed: bool = False) -> list[PluginInfo]:
|
||||||
|
"""Return all plugins in the registry."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
if include_removed:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE uri != '_schema_version' "
|
||||||
|
"ORDER BY format, name COLLATE NOCASE"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE uri != '_schema_version' "
|
||||||
|
"AND status != 'removed' "
|
||||||
|
"ORDER BY format, name COLLATE NOCASE"
|
||||||
|
).fetchall()
|
||||||
|
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def list_by_format(self, format: PluginFormat) -> list[PluginInfo]:
|
||||||
|
"""List plugins of a specific format."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE format = ? AND status != 'removed' "
|
||||||
|
"ORDER BY name COLLATE NOCASE",
|
||||||
|
(format.value,)
|
||||||
|
).fetchall()
|
||||||
|
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def list_by_category(self, category: PluginCategory) -> list[PluginInfo]:
|
||||||
|
"""List plugins matching a specific category."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE categories LIKE ? AND status != 'removed' "
|
||||||
|
"ORDER BY name COLLATE NOCASE",
|
||||||
|
(f"%{category.value}%",)
|
||||||
|
).fetchall()
|
||||||
|
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def list_by_status(self, status: PluginStatus) -> list[PluginInfo]:
|
||||||
|
"""List plugins with a specific status."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE status = ? ORDER BY name COLLATE NOCASE",
|
||||||
|
(status.value,)
|
||||||
|
).fetchall()
|
||||||
|
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def list_loadable(self) -> list[PluginInfo]:
|
||||||
|
"""Return only plugins that are active (loadable)."""
|
||||||
|
return self.list_by_status(PluginStatus.ACTIVE)
|
||||||
|
|
||||||
|
def list_blacklisted(self) -> list[PluginInfo]:
|
||||||
|
"""Return blacklisted plugins."""
|
||||||
|
return self.list_by_status(PluginStatus.BLACKLISTED)
|
||||||
|
|
||||||
|
def list_stale(self) -> list[PluginInfo]:
|
||||||
|
"""Return stale plugins (files missing)."""
|
||||||
|
# Stale plugins are those with status 'stale' or those whose
|
||||||
|
# bundle_path doesn't exist on disk
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE status = 'stale' "
|
||||||
|
"ORDER BY name COLLATE NOCASE"
|
||||||
|
).fetchall()
|
||||||
|
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def list_rpi4b_verified(self) -> list[PluginInfo]:
|
||||||
|
"""List plugins verified working on RPi4B."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE rpi4b_known_good = 1 "
|
||||||
|
"AND status != 'removed' ORDER BY name COLLATE NOCASE"
|
||||||
|
).fetchall()
|
||||||
|
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def list_nam_models(self) -> list[PluginInfo]:
|
||||||
|
"""List NAM model plugins."""
|
||||||
|
return self.list_by_format(PluginFormat.NAM)
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[PluginInfo]:
|
||||||
|
"""Full-text search across plugin names, descriptions, and authors."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
like = f"%{query}%"
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM plugins WHERE "
|
||||||
|
"(name LIKE ? OR description LIKE ? OR author LIKE ? OR project LIKE ?)"
|
||||||
|
"AND status != 'removed' AND uri != '_schema_version' "
|
||||||
|
"ORDER BY name COLLATE NOCASE",
|
||||||
|
(like, like, like, like)
|
||||||
|
).fetchall()
|
||||||
|
return [PluginInfo.from_dict(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
"""Return total number of active plugins."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) as c FROM plugins "
|
||||||
|
"WHERE status != 'removed' AND uri != '_schema_version'"
|
||||||
|
).fetchone()
|
||||||
|
return row["c"] if row else 0
|
||||||
|
|
||||||
|
def count_by_format(self) -> dict[str, int]:
|
||||||
|
"""Return plugin count per format."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT format, COUNT(*) as c FROM plugins "
|
||||||
|
"WHERE status != 'removed' AND uri != '_schema_version' "
|
||||||
|
"GROUP BY format"
|
||||||
|
).fetchall()
|
||||||
|
return {r["format"]: r["c"] for r in rows}
|
||||||
|
|
||||||
|
def stats(self) -> dict:
|
||||||
|
"""Return aggregate registry statistics."""
|
||||||
|
counts = self.count_by_format()
|
||||||
|
return {
|
||||||
|
"total": sum(counts.values()),
|
||||||
|
"by_format": counts,
|
||||||
|
"blacklisted": len(self.list_blacklisted()),
|
||||||
|
"stale": len(self.list_stale()),
|
||||||
|
"rpi4b_verified": len(self.list_rpi4b_verified()),
|
||||||
|
"nam_models": len(self.list_nam_models()),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Batch operations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sync_from_scan(self, scanned: list[PluginInfo]) -> dict:
|
||||||
|
"""Synchronise the registry with a scan result.
|
||||||
|
|
||||||
|
- New plugins are inserted.
|
||||||
|
- Existing plugins are updated (metadata refreshed).
|
||||||
|
- Plugins no longer present are marked stale.
|
||||||
|
|
||||||
|
Returns a dict with counts for {inserted, updated, stale}.
|
||||||
|
"""
|
||||||
|
existing_uris = set()
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT uri FROM plugins WHERE uri != '_schema_version'"
|
||||||
|
).fetchall()
|
||||||
|
existing_uris = {r["uri"] for r in rows}
|
||||||
|
|
||||||
|
scanned_uris = {p.meta.uri for p in scanned}
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
for plugin in scanned:
|
||||||
|
if plugin.meta.uri in existing_uris:
|
||||||
|
if self.upsert(plugin):
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
if self.insert(plugin):
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
# Mark plugins no longer found as stale
|
||||||
|
stale_uris = existing_uris - scanned_uris
|
||||||
|
stale_count = 0
|
||||||
|
for uri in stale_uris:
|
||||||
|
if self.update(uri, status=PluginStatus.STALE.value):
|
||||||
|
stale_count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Registry sync: %d inserted, %d updated, %d marked stale",
|
||||||
|
inserted, updated, stale_count,
|
||||||
|
)
|
||||||
|
return {"inserted": inserted, "updated": updated, "stale": stale_count}
|
||||||
|
|
||||||
|
def vacuum(self) -> None:
|
||||||
|
"""Compact and optimize the database."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
conn.execute("PRAGMA optimize")
|
||||||
|
conn.execute("VACUUM")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""No-op — connections are managed per-operation."""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
"""Plugin scanning engine — LV2 manifest parsing and VST3 descriptor reading.
|
||||||
|
|
||||||
|
Scans the filesystem for installed plugins, parses their metadata, and
|
||||||
|
returns a list of PluginInfo objects suitable for registry ingestion.
|
||||||
|
|
||||||
|
LV2: Parses manifest.ttl (Turtle/RDF) for plugin metadata.
|
||||||
|
VST3: Reads moduleinfo.json descriptors (VST3 SDK >= 3.7.0).
|
||||||
|
LADSPA: Parses .so ELF metadata and ladspa.rdf descriptors.
|
||||||
|
NAM: Scans .nam files and reads their embedded JSON metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
PluginCategory,
|
||||||
|
PluginFormat,
|
||||||
|
PluginInfo,
|
||||||
|
PluginMeta,
|
||||||
|
PluginPort,
|
||||||
|
PluginStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default scan paths for Raspberry Pi OS (RPi OS Lite 64-bit)
|
||||||
|
DEFAULT_LV2_PATHS = [
|
||||||
|
"/usr/lib/lv2",
|
||||||
|
"/usr/local/lib/lv2",
|
||||||
|
"/usr/lib/aarch64-linux-gnu/lv2",
|
||||||
|
str(Path.home() / ".lv2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_VST3_PATHS = [
|
||||||
|
"/usr/lib/vst3",
|
||||||
|
"/usr/local/lib/vst3",
|
||||||
|
str(Path.home() / ".vst3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_LADSPA_PATHS = [
|
||||||
|
"/usr/lib/ladspa",
|
||||||
|
"/usr/local/lib/ladspa",
|
||||||
|
str(Path.home() / ".ladspa"),
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_NAM_PATHS = [
|
||||||
|
str(Path.home() / ".config" / "rpi-mixer" / "nam"),
|
||||||
|
str(Path.home() / ".lv2" / "nam-models"),
|
||||||
|
"/usr/share/nam",
|
||||||
|
]
|
||||||
|
|
||||||
|
CACHE_TTL_SECONDS = 3600 # Re-scan after 1 hour
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LV2 Scanner
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Minimal Turtle/RDF parser for LV2 manifest.ttl.
|
||||||
|
# LV2 manifests use a simple RDF/Turtle subset — we don't need a full parser.
|
||||||
|
|
||||||
|
_LV2_CLASS_MAP: dict[str, PluginCategory] = {
|
||||||
|
"http://lv2plug.in/ns/lv2core#DelayPlugin": PluginCategory.DELAY,
|
||||||
|
"http://lv2plug.in/ns/lv2core#ReverbPlugin": PluginCategory.REVERB,
|
||||||
|
"http://lv2plug.in/ns/lv2core#ChorusPlugin": PluginCategory.CHORUS,
|
||||||
|
"http://lv2plug.in/ns/lv2core#FlangerPlugin": PluginCategory.FLANGER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#PhaserPlugin": PluginCategory.PHASER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#CompressorPlugin": PluginCategory.COMPRESSOR,
|
||||||
|
"http://lv2plug.in/ns/lv2core#LimiterPlugin": PluginCategory.LIMITER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#GatePlugin": PluginCategory.GATE,
|
||||||
|
"http://lv2plug.in/ns/lv2core#ExpanderPlugin": PluginCategory.EXPANDER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#EQPlugin": PluginCategory.EQ_PARAMETRIC,
|
||||||
|
"http://lv2plug.in/ns/lv2core#FilterPlugin": PluginCategory.FILTER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#DistortionPlugin": PluginCategory.DISTORTION,
|
||||||
|
"http://lv2plug.in/ns/lv2core#WaveshaperPlugin": PluginCategory.DISTORTION,
|
||||||
|
"http://lv2plug.in/ns/lv2core#SimulatorPlugin": PluginCategory.AMP,
|
||||||
|
"http://lv2plug.in/ns/lv2core#SpatialPlugin": PluginCategory.SPATIAL,
|
||||||
|
"http://lv2plug.in/ns/lv2core#SpectralPlugin": PluginCategory.ANALYZER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#PitchPlugin": PluginCategory.PITCH_SHIFTER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#AnalyserPlugin": PluginCategory.ANALYZER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#MeterPlugin": PluginCategory.METER,
|
||||||
|
"http://lv2plug.in/ns/lv2core#UtilityPlugin": PluginCategory.UTILITY,
|
||||||
|
"http://lv2plug.in/ns/lv2core#InstrumentPlugin": PluginCategory.SYNTH,
|
||||||
|
"http://lv2plug.in/ns/lv2core#GeneratorPlugin": PluginCategory.SYNTH,
|
||||||
|
"http://lv2plug.in/ns/lv2core#OscillatorPlugin": PluginCategory.SYNTH,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Category keywords for best-effort matching when class URI isn't in the map
|
||||||
|
_CATEGORY_KEYWORDS: list[tuple[list[str], PluginCategory]] = [
|
||||||
|
(["amp", "amplifier", "guitar_amp", "bass_amp", "tube"], PluginCategory.AMP),
|
||||||
|
(["cab", "cabinet", "ir_loader", "impulse_response", "convolution"], PluginCategory.IR_LOADER),
|
||||||
|
(["reverb", "hall", "plate", "room", "spring"], PluginCategory.REVERB),
|
||||||
|
(["delay", "echo", "tape_echo"], PluginCategory.DELAY),
|
||||||
|
(["chorus", "ensemble"], PluginCategory.CHORUS),
|
||||||
|
(["flanger", "flange"], PluginCategory.FLANGER),
|
||||||
|
(["phaser", "phase"], PluginCategory.PHASER),
|
||||||
|
(["tremolo"], PluginCategory.TREMOLO),
|
||||||
|
(["vibrato"], PluginCategory.VIBRATO),
|
||||||
|
(["compressor", "comp", "dynamics", "leveler"], PluginCategory.COMPRESSOR),
|
||||||
|
(["limiter", "limit", "maximizer"], PluginCategory.LIMITER),
|
||||||
|
(["gate", "noise_gate", "expander"], PluginCategory.GATE),
|
||||||
|
(["eq", "equalizer", "equaliser", "parametric", "graphic", "shelving", "tone"], PluginCategory.EQ_PARAMETRIC),
|
||||||
|
(["filter", "lpf", "hpf", "bpf", "lowpass", "highpass", "bandpass"], PluginCategory.FILTER),
|
||||||
|
(["distortion", "dist", "overdrive", "fuzz", "saturation", "crunch"], PluginCategory.DISTORTION),
|
||||||
|
(["overdrive", "od"], PluginCategory.OVERDRIVE),
|
||||||
|
(["fuzz"], PluginCategory.FUZZ),
|
||||||
|
(["pitch", "shifter", "harmonizer", "harmoniser"], PluginCategory.PITCH_SHIFTER),
|
||||||
|
(["octaver", "octave", "sub_octave"], PluginCategory.OCTAVER),
|
||||||
|
(["wah", "auto_wah", "envelope_filter"], PluginCategory.WAH),
|
||||||
|
(["synth", "synthesizer", "oscillator", "generator", "soundfont"], PluginCategory.SYNTH),
|
||||||
|
(["sampler", "sample_player", "drum"], PluginCategory.SAMPLER),
|
||||||
|
(["meter", "vu", "level", "loudness", "peak"], PluginCategory.METER),
|
||||||
|
(["analyzer", "analyser", "spectrum", "spectrogram", "fft", "tuner"], PluginCategory.ANALYZER),
|
||||||
|
(["utility", "gain", "volume", "pan", "balance", "mute", "phase", "polarity"], PluginCategory.UTILITY),
|
||||||
|
(["spatial", "stereo", "width", "imager", "panner"], PluginCategory.SPATIAL),
|
||||||
|
(["noise", "denoiser", "nr", "reduction"], PluginCategory.NOISE_REDUCTION),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_str(s: str) -> str:
|
||||||
|
"""Short SHA-256 hash for use as a synthetic URI."""
|
||||||
|
return hashlib.sha256(s.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_turtle_statement(line: str) -> tuple[str, str, str] | None:
|
||||||
|
"""Parse a simple RDF/Turtle triple (subject predicate object .).
|
||||||
|
|
||||||
|
Returns (subject, predicate, object) or None for comments/blank lines.
|
||||||
|
Handles the subset used by LV2 manifests — not full Turtle compliance.
|
||||||
|
"""
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove trailing '.' and optional ';' at end
|
||||||
|
line = line.rstrip(" .;")
|
||||||
|
|
||||||
|
# Split on whitespace, respecting angle brackets and quotes
|
||||||
|
# Simplified: find three tokens
|
||||||
|
parts = line.split(None, 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
subj, pred, obj = parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
|
# Strip angle brackets from URIs
|
||||||
|
subj = subj.strip("<>")
|
||||||
|
pred = pred.strip("<>")
|
||||||
|
obj = obj.strip("<>").strip('"')
|
||||||
|
|
||||||
|
return subj, pred, obj
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lv2_manifest(manifest_path: Path) -> list[dict]:
|
||||||
|
"""Parse an LV2 manifest.ttl file and return a list of plugin descriptors.
|
||||||
|
|
||||||
|
Each descriptor is a dict containing all the parsed RDF triples.
|
||||||
|
"""
|
||||||
|
if not manifest_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
plugins: list[dict] = []
|
||||||
|
current: dict | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = manifest_path.read_text(errors="replace")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Cannot read LV2 manifest %s: %s", manifest_path, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
stmt = _parse_turtle_statement(line)
|
||||||
|
if stmt is None:
|
||||||
|
continue
|
||||||
|
subj, pred, obj = stmt
|
||||||
|
|
||||||
|
# LV2: a plugin is defined as <uri> a lv2:Plugin
|
||||||
|
is_plugin_def = (
|
||||||
|
"lv2core#Plugin" in pred or "lv2core#Plugin" in obj
|
||||||
|
) and (
|
||||||
|
pred.endswith("#a") or pred.endswith("rdf-syntax-ns#type")
|
||||||
|
or "type" in pred.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_plugin_def and not current:
|
||||||
|
current = {"uri": subj, "rdf_type": obj, "name": "", "class_uri": ""}
|
||||||
|
plugins.append(current)
|
||||||
|
elif subj and current:
|
||||||
|
# Assign to current plugin
|
||||||
|
if pred.endswith("#name") or pred.endswith("doap#name"):
|
||||||
|
current["name"] = obj
|
||||||
|
elif pred.endswith("#binary") or pred.endswith("lv2#binary"):
|
||||||
|
current["binary"] = obj
|
||||||
|
elif pred.endswith("#class") or pred.endswith("rdf#type"):
|
||||||
|
current["class_uri"] = obj
|
||||||
|
elif "doap#license" in pred:
|
||||||
|
current["license"] = obj
|
||||||
|
elif "doap#homepage" in pred:
|
||||||
|
current["homepage"] = obj
|
||||||
|
elif "doap#shortdesc" in pred or "rdfs#comment" in pred:
|
||||||
|
current["description"] = obj
|
||||||
|
elif "doap#maintainer" in pred:
|
||||||
|
current["author"] = obj
|
||||||
|
elif "lv2#minorVersion" in pred:
|
||||||
|
current.setdefault("version_parts", [0, 0])
|
||||||
|
current["version_parts"][1] = int(obj) if obj.isdigit() else 0
|
||||||
|
elif "lv2#microVersion" in pred:
|
||||||
|
pass # Tracked in version parts
|
||||||
|
elif pred.endswith("#project") or "lv2#project" in pred:
|
||||||
|
current["project"] = obj
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lv2_ttl_port(port_path: Path) -> list[PluginPort]:
|
||||||
|
"""Parse an LV2 plugin's .ttl file for port definitions."""
|
||||||
|
ports: list[PluginPort] = []
|
||||||
|
if not port_path.exists():
|
||||||
|
return ports
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = port_path.read_text(errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return ports
|
||||||
|
|
||||||
|
port_index = 0
|
||||||
|
current_port: dict | None = None
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
stmt = _parse_turtle_statement(line)
|
||||||
|
if stmt is None:
|
||||||
|
continue
|
||||||
|
subj, pred, obj = stmt
|
||||||
|
|
||||||
|
# Detect port definition
|
||||||
|
if pred.endswith("#port") and "lv2:Port" not in subj:
|
||||||
|
# This is a port index assignment
|
||||||
|
# Usually: <plugin_uri> lv2:port [ ... ] — too complex for simple parser
|
||||||
|
# Instead, look for lv2:index
|
||||||
|
continue
|
||||||
|
elif pred.endswith("#index"):
|
||||||
|
current_port = {"index": int(obj) if obj.lstrip("-").isdigit() else port_index}
|
||||||
|
port_index += 1
|
||||||
|
elif pred.endswith("#symbol") and current_port:
|
||||||
|
current_port["symbol"] = obj
|
||||||
|
elif pred.endswith("#name") and current_port:
|
||||||
|
current_port["name"] = obj
|
||||||
|
elif "Port#Input" in obj and current_port:
|
||||||
|
current_port["direction"] = "input"
|
||||||
|
current_port.setdefault("port_type", "audio")
|
||||||
|
elif "Port#Output" in obj and current_port:
|
||||||
|
current_port["direction"] = "output"
|
||||||
|
current_port.setdefault("port_type", "audio")
|
||||||
|
elif "ControlPort" in obj or "lv2#ControlPort" in obj:
|
||||||
|
if current_port:
|
||||||
|
current_port["port_type"] = "control"
|
||||||
|
elif "AudioPort" in obj:
|
||||||
|
if current_port:
|
||||||
|
current_port["port_type"] = "audio"
|
||||||
|
elif pred.endswith("#minimum") and current_port:
|
||||||
|
try: current_port["minimum"] = float(obj)
|
||||||
|
except ValueError: pass
|
||||||
|
elif pred.endswith("#maximum") and current_port:
|
||||||
|
try: current_port["maximum"] = float(obj)
|
||||||
|
except ValueError: pass
|
||||||
|
elif pred.endswith("#default") and current_port:
|
||||||
|
try: current_port["default"] = float(obj)
|
||||||
|
except ValueError: pass
|
||||||
|
|
||||||
|
# When we encounter a new index after having a complete port, push it
|
||||||
|
if current_port and "symbol" in current_port and "name" in current_port:
|
||||||
|
ports.append(PluginPort(
|
||||||
|
index=current_port.get("index", len(ports)),
|
||||||
|
symbol=current_port.get("symbol", ""),
|
||||||
|
name=current_port.get("name", ""),
|
||||||
|
direction=current_port.get("direction", "input"),
|
||||||
|
port_type=current_port.get("port_type", "control"),
|
||||||
|
default=current_port.get("default", 0.0),
|
||||||
|
minimum=current_port.get("minimum", 0.0),
|
||||||
|
maximum=current_port.get("maximum", 1.0),
|
||||||
|
))
|
||||||
|
current_port = None
|
||||||
|
|
||||||
|
return ports
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_lv2_plugin(class_uri: str, uri: str, name: str) -> list[PluginCategory]:
|
||||||
|
"""Determine plugin categories from LV2 class URI and heuristics."""
|
||||||
|
categories: list[PluginCategory] = []
|
||||||
|
|
||||||
|
# Exact class URI match
|
||||||
|
for cls_uri, cat in _LV2_CLASS_MAP.items():
|
||||||
|
if cls_uri in class_uri or cls_uri in uri:
|
||||||
|
categories.append(cat)
|
||||||
|
break # Only one primary class
|
||||||
|
|
||||||
|
# Keyword heuristics on URI + name
|
||||||
|
search_text = f"{uri.lower()} {name.lower()}"
|
||||||
|
for keywords, cat in _CATEGORY_KEYWORDS:
|
||||||
|
if any(kw in search_text for kw in keywords):
|
||||||
|
if cat not in categories:
|
||||||
|
categories.append(cat)
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
categories.append(PluginCategory.UNKNOWN)
|
||||||
|
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
def scan_lv2(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||||
|
"""Scan for LV2 plugins across the given paths.
|
||||||
|
|
||||||
|
For each bundle directory, parses manifest.ttl to extract plugin URIs,
|
||||||
|
names, and metadata. Port information is extracted from the plugin's
|
||||||
|
.ttl file when available.
|
||||||
|
"""
|
||||||
|
if paths is None:
|
||||||
|
paths = DEFAULT_LV2_PATHS
|
||||||
|
|
||||||
|
results: list[PluginInfo] = []
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
for base_path in paths:
|
||||||
|
base = Path(base_path)
|
||||||
|
if not base.is_dir():
|
||||||
|
logger.debug("LV2 path not found, skipping: %s", base)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for bundle_dir in base.iterdir():
|
||||||
|
if not bundle_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
manifest = bundle_dir / "manifest.ttl"
|
||||||
|
if not manifest.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugins = _parse_lv2_manifest(manifest)
|
||||||
|
if not plugins:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for pdata in plugins:
|
||||||
|
uri = pdata.get("uri", "")
|
||||||
|
name = pdata.get("name", bundle_dir.name)
|
||||||
|
|
||||||
|
if not uri:
|
||||||
|
uri = f"urn:lv2:{bundle_dir.name}:{_hash_str(name)}"
|
||||||
|
|
||||||
|
# Look for the plugin's main .ttl file (usually <name>.ttl)
|
||||||
|
ttl_file = None
|
||||||
|
for candidate in bundle_dir.glob("*.ttl"):
|
||||||
|
if candidate.name != "manifest.ttl":
|
||||||
|
ttl_file = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
ports = _parse_lv2_ttl_port(ttl_file) if ttl_file else []
|
||||||
|
audio_in = sum(1 for p in ports if p.port_type == "audio" and p.direction == "input")
|
||||||
|
audio_out = sum(1 for p in ports if p.port_type == "audio" and p.direction == "output")
|
||||||
|
control_ports = [p for p in ports if p.port_type == "control"]
|
||||||
|
|
||||||
|
categories = _classify_lv2_plugin(
|
||||||
|
pdata.get("class_uri", ""), uri, name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look for the binary .so
|
||||||
|
bin_rel = pdata.get("binary", "")
|
||||||
|
library_path = str(bundle_dir / bin_rel) if bin_rel else ""
|
||||||
|
|
||||||
|
# Version parsing
|
||||||
|
version = pdata.get("version", "")
|
||||||
|
if not version and "version_parts" in pdata:
|
||||||
|
parts = pdata["version_parts"]
|
||||||
|
version = f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else ""
|
||||||
|
|
||||||
|
info = PluginInfo(
|
||||||
|
meta=PluginMeta(
|
||||||
|
name=name,
|
||||||
|
uri=uri,
|
||||||
|
format=PluginFormat.LV2,
|
||||||
|
version=version,
|
||||||
|
author=pdata.get("author", ""),
|
||||||
|
license=pdata.get("license", ""),
|
||||||
|
description=pdata.get("description", ""),
|
||||||
|
homepage=pdata.get("homepage", ""),
|
||||||
|
project=pdata.get("project", ""),
|
||||||
|
arch="aarch64",
|
||||||
|
arm_optimized=False,
|
||||||
|
),
|
||||||
|
categories=categories,
|
||||||
|
audio_inputs=audio_in,
|
||||||
|
audio_outputs=audio_out,
|
||||||
|
control_ports=control_ports,
|
||||||
|
bundle_path=str(bundle_dir),
|
||||||
|
library_path=library_path,
|
||||||
|
status=PluginStatus.ACTIVE,
|
||||||
|
scanned_at=now,
|
||||||
|
)
|
||||||
|
results.append(info)
|
||||||
|
|
||||||
|
logger.info("LV2 scan found %d plugins across %d paths", len(results), len(paths))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VST3 Scanner
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _parse_vst3_moduleinfo(json_path: Path) -> dict | None:
|
||||||
|
"""Parse a VST3 moduleinfo.json file."""
|
||||||
|
if not json_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(json_path.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning("Failed to parse VST3 moduleinfo %s: %s", json_path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vst3_snapshot(snapshot_path: Path) -> dict | None:
|
||||||
|
"""Parse a VST3 snapshot JSON file (module image)."""
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(snapshot_path.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning("Failed to parse VST3 snapshot %s: %s", snapshot_path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vst3_subcategories_to_categories(subcategories: list[str]) -> list[PluginCategory]:
|
||||||
|
"""Map VST3 subcategory strings to PluginCategory values."""
|
||||||
|
vst3_map: dict[str, PluginCategory] = {
|
||||||
|
"Fx": PluginCategory.OTHER,
|
||||||
|
"Fx|Delay": PluginCategory.DELAY,
|
||||||
|
"Fx|Reverb": PluginCategory.REVERB,
|
||||||
|
"Fx|Chorus": PluginCategory.CHORUS,
|
||||||
|
"Fx|Flanger": PluginCategory.FLANGER,
|
||||||
|
"Fx|Phaser": PluginCategory.PHASER,
|
||||||
|
"Fx|Tremolo": PluginCategory.TREMOLO,
|
||||||
|
"Fx|Compressor": PluginCategory.COMPRESSOR,
|
||||||
|
"Fx|Limiter": PluginCategory.LIMITER,
|
||||||
|
"Fx|Gate": PluginCategory.GATE,
|
||||||
|
"Fx|EQ": PluginCategory.EQ_PARAMETRIC,
|
||||||
|
"Fx|Filter": PluginCategory.FILTER,
|
||||||
|
"Fx|Distortion": PluginCategory.DISTORTION,
|
||||||
|
"Fx|PitchShift": PluginCategory.PITCH_SHIFTER,
|
||||||
|
"Fx|Wah": PluginCategory.WAH,
|
||||||
|
"Instrument": PluginCategory.SYNTH,
|
||||||
|
"Instrument|Synth": PluginCategory.SYNTH,
|
||||||
|
"Instrument|Sampler": PluginCategory.SAMPLER,
|
||||||
|
"Instrument|Drum": PluginCategory.DRUM_MACHINE,
|
||||||
|
"Analyzer": PluginCategory.ANALYZER,
|
||||||
|
"Spatial": PluginCategory.SPATIAL,
|
||||||
|
"Generator": PluginCategory.SYNTH,
|
||||||
|
}
|
||||||
|
|
||||||
|
categories: list[PluginCategory] = []
|
||||||
|
for sc in subcategories:
|
||||||
|
if sc in vst3_map:
|
||||||
|
categories.append(vst3_map[sc])
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
categories.append(PluginCategory.UNKNOWN)
|
||||||
|
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vst3_bundle(bundle_path: Path) -> list[PluginInfo]:
|
||||||
|
"""Parse a VST3 bundle directory (.vst3 folder)."""
|
||||||
|
results: list[PluginInfo] = []
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
bundle_name = bundle_path.name
|
||||||
|
if bundle_name.endswith(".vst3"):
|
||||||
|
bundle_name = bundle_name[:-5]
|
||||||
|
|
||||||
|
# Find moduleinfo.json (VST3 SDK 3.7.0+)
|
||||||
|
moduleinfo = None
|
||||||
|
# Try common locations within the bundle
|
||||||
|
for pattern in ["**/moduleinfo.json", "Contents/Resources/moduleinfo.json"]:
|
||||||
|
for mi_path in bundle_path.glob(pattern):
|
||||||
|
moduleinfo = _parse_vst3_moduleinfo(mi_path)
|
||||||
|
if moduleinfo:
|
||||||
|
break
|
||||||
|
if moduleinfo:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not moduleinfo:
|
||||||
|
logger.debug("No moduleinfo.json in %s, skipping", bundle_path)
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Find the shared library
|
||||||
|
lib_path = ""
|
||||||
|
arch_dir = "aarch64-linux" # RPi4B architecture
|
||||||
|
for arch_candidate in [arch_dir, "x86_64-linux", "arm64-linux", "i386-linux"]:
|
||||||
|
lib_dir = bundle_path / "Contents" / arch_candidate
|
||||||
|
if lib_dir.is_dir():
|
||||||
|
for so in lib_dir.glob("*.so"):
|
||||||
|
lib_path = str(so)
|
||||||
|
break
|
||||||
|
if lib_path:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not lib_path:
|
||||||
|
# Fallback: search for any .so in the bundle
|
||||||
|
for so in bundle_path.rglob("*.so"):
|
||||||
|
lib_path = str(so)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract factory info
|
||||||
|
factory_info = moduleinfo.get("Factory Info", {}) if isinstance(moduleinfo, dict) else {}
|
||||||
|
classes = moduleinfo.get("Classes", []) if isinstance(moduleinfo, dict) else []
|
||||||
|
|
||||||
|
for cls in classes:
|
||||||
|
cid = cls.get("CID", "") or cls.get("cid", "")
|
||||||
|
name = cls.get("Name", "") or cls.get("name", "") or bundle_name
|
||||||
|
category_str = cls.get("Category", "") or cls.get("category", "")
|
||||||
|
subcategories: list[str] = cls.get("Sub Categories", []) or cls.get("sub_categories", [])
|
||||||
|
vendor = cls.get("Vendor", "") or cls.get("vendor", "") or factory_info.get("Vendor", "")
|
||||||
|
version = cls.get("Version", "") or cls.get("version", "") or factory_info.get("Version", "")
|
||||||
|
class_flags = cls.get("Class Flags", 0) or cls.get("class_flags", 0)
|
||||||
|
is_instrument = bool(class_flags & 1) # kSimpleMode flag indicates instrument
|
||||||
|
|
||||||
|
# Parse ports from class info
|
||||||
|
audio_inputs = 0
|
||||||
|
audio_outputs = 0
|
||||||
|
midi_inputs = 0
|
||||||
|
midi_outputs = 0
|
||||||
|
buses = cls.get("Buses", []) or cls.get("buses", [])
|
||||||
|
for bus in buses:
|
||||||
|
btype = bus.get("Type", "") or bus.get("type", "")
|
||||||
|
direction = bus.get("Direction", "") or bus.get("direction", "")
|
||||||
|
count = bus.get("Bus Count", 1) or bus.get("bus_count", 1)
|
||||||
|
if btype == "Audio" or btype == "kAudio":
|
||||||
|
if direction in ("Input", "kInput"):
|
||||||
|
audio_inputs += count
|
||||||
|
elif direction in ("Output", "kOutput"):
|
||||||
|
audio_outputs += count
|
||||||
|
elif btype == "Event" or btype == "kEvent":
|
||||||
|
if direction in ("Input", "kInput"):
|
||||||
|
midi_inputs += count
|
||||||
|
elif direction in ("Output", "kOutput"):
|
||||||
|
midi_outputs += count
|
||||||
|
|
||||||
|
uri = cid or f"urn:vst3:{bundle_name}:{_hash_str(name)}"
|
||||||
|
categories = _vst3_subcategories_to_categories(subcategories)
|
||||||
|
if not categories and is_instrument:
|
||||||
|
categories = [PluginCategory.SYNTH]
|
||||||
|
|
||||||
|
# Determine ARM optimization
|
||||||
|
arm_optimized = arch_dir in lib_path or "aarch64" in lib_path
|
||||||
|
|
||||||
|
info = PluginInfo(
|
||||||
|
meta=PluginMeta(
|
||||||
|
name=name,
|
||||||
|
uri=uri,
|
||||||
|
format=PluginFormat.VST3,
|
||||||
|
version=version,
|
||||||
|
author=vendor,
|
||||||
|
license="",
|
||||||
|
description=cls.get("Description", "") or cls.get("description", ""),
|
||||||
|
homepage=cls.get("URL", "") or cls.get("url", ""),
|
||||||
|
project=bundle_name,
|
||||||
|
arch="aarch64",
|
||||||
|
arm_optimized=arm_optimized,
|
||||||
|
),
|
||||||
|
categories=categories,
|
||||||
|
audio_inputs=audio_inputs,
|
||||||
|
audio_outputs=audio_outputs,
|
||||||
|
midi_inputs=midi_inputs,
|
||||||
|
midi_outputs=midi_outputs,
|
||||||
|
bundle_path=str(bundle_path),
|
||||||
|
library_path=lib_path,
|
||||||
|
status=PluginStatus.ACTIVE,
|
||||||
|
scanned_at=now,
|
||||||
|
)
|
||||||
|
results.append(info)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def scan_vst3(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||||
|
"""Scan for VST3 plugins across the given paths."""
|
||||||
|
if paths is None:
|
||||||
|
paths = DEFAULT_VST3_PATHS
|
||||||
|
|
||||||
|
results: list[PluginInfo] = []
|
||||||
|
|
||||||
|
for base_path in paths:
|
||||||
|
base = Path(base_path)
|
||||||
|
if not base.is_dir():
|
||||||
|
logger.debug("VST3 path not found, skipping: %s", base)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for bundle_dir in base.iterdir():
|
||||||
|
if not bundle_dir.is_dir():
|
||||||
|
continue
|
||||||
|
if not bundle_dir.name.endswith(".vst3"):
|
||||||
|
continue
|
||||||
|
results.extend(_parse_vst3_bundle(bundle_dir))
|
||||||
|
|
||||||
|
logger.info("VST3 scan found %d plugins across %d paths", len(results), len(paths))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LADSPA Scanner
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _parse_ladspa_rdf(rdf_path: Path) -> list[dict]:
|
||||||
|
"""Parse a LADSPA RDF descriptor file."""
|
||||||
|
if not rdf_path.exists():
|
||||||
|
return []
|
||||||
|
results: list[dict] = []
|
||||||
|
current: dict | None = None
|
||||||
|
try:
|
||||||
|
text = rdf_path.read_text(errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return results
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
stmt = _parse_turtle_statement(line)
|
||||||
|
if stmt is None:
|
||||||
|
continue
|
||||||
|
subj, pred, obj = stmt
|
||||||
|
|
||||||
|
# LADSPA RDF uses ladspa:uniqueID patterns
|
||||||
|
if pred.endswith("#a") and "ladspa#" in obj:
|
||||||
|
current = {"uri": subj, "name": "", "id": 0}
|
||||||
|
results.append(current)
|
||||||
|
elif current:
|
||||||
|
if pred.endswith("#label") or "rdfs#label" in pred:
|
||||||
|
current["name"] = obj
|
||||||
|
elif pred.endswith("#uniqueID"):
|
||||||
|
try: current["id"] = int(obj)
|
||||||
|
except ValueError: pass
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def scan_ladspa(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||||
|
"""Scan for LADSPA plugins."""
|
||||||
|
if paths is None:
|
||||||
|
paths = DEFAULT_LADSPA_PATHS
|
||||||
|
|
||||||
|
results: list[PluginInfo] = []
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
for base_path in paths:
|
||||||
|
base = Path(base_path)
|
||||||
|
if not base.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for so_file in base.rglob("*.so"):
|
||||||
|
lib_path = str(so_file)
|
||||||
|
name = so_file.stem.lstrip("lib")
|
||||||
|
uri = f"urn:ladspa:{name}:{_hash_str(lib_path)}"
|
||||||
|
|
||||||
|
info = PluginInfo(
|
||||||
|
meta=PluginMeta(
|
||||||
|
name=name,
|
||||||
|
uri=uri,
|
||||||
|
format=PluginFormat.LADSPA,
|
||||||
|
arch="aarch64",
|
||||||
|
),
|
||||||
|
categories=[PluginCategory.OTHER],
|
||||||
|
bundle_path=str(base),
|
||||||
|
library_path=lib_path,
|
||||||
|
status=PluginStatus.ACTIVE,
|
||||||
|
scanned_at=now,
|
||||||
|
)
|
||||||
|
results.append(info)
|
||||||
|
|
||||||
|
logger.info("LADSPA scan found %d plugins", len(results))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# NAM Scanner
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _parse_nam_file(nam_path: Path) -> dict | None:
|
||||||
|
"""Parse a .nam file for embedded metadata.
|
||||||
|
|
||||||
|
NAM files store JSON config at the end of the binary blob.
|
||||||
|
"""
|
||||||
|
if not nam_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = nam_path.read_bytes()
|
||||||
|
# NAM files store metadata as JSON at the end
|
||||||
|
# Look for the JSON delimiter pattern
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
# Find JSON-like content at end
|
||||||
|
brace_start = text.rfind('{"')
|
||||||
|
if brace_start >= 0:
|
||||||
|
json_str = text[brace_start:]
|
||||||
|
# Find matching end brace
|
||||||
|
depth = 0
|
||||||
|
end = 0
|
||||||
|
for i, ch in enumerate(json_str):
|
||||||
|
if ch == '{': depth += 1
|
||||||
|
elif ch == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
end = i + 1
|
||||||
|
break
|
||||||
|
if end > 0:
|
||||||
|
return json.loads(json_str[:end])
|
||||||
|
return {}
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_nam(paths: list[str] | None = None) -> list[PluginInfo]:
|
||||||
|
"""Scan for .nam Neural Amp Modeler files."""
|
||||||
|
if paths is None:
|
||||||
|
paths = DEFAULT_NAM_PATHS
|
||||||
|
|
||||||
|
results: list[PluginInfo] = []
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
for base_path in paths:
|
||||||
|
base = Path(base_path)
|
||||||
|
if not base.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for nam_file in base.rglob("*.nam"):
|
||||||
|
nam_path = str(nam_file)
|
||||||
|
metadata = _parse_nam_file(nam_file)
|
||||||
|
name = metadata.get("name", nam_file.stem) if metadata else nam_file.stem
|
||||||
|
|
||||||
|
# Determine model size
|
||||||
|
file_size_mb = nam_file.stat().st_size / (1024 * 1024)
|
||||||
|
if file_size_mb < 1:
|
||||||
|
model_size = "nano"
|
||||||
|
elif file_size_mb < 10:
|
||||||
|
model_size = "feather"
|
||||||
|
elif file_size_mb < 50:
|
||||||
|
model_size = "standard"
|
||||||
|
else:
|
||||||
|
model_size = "custom"
|
||||||
|
|
||||||
|
uri = f"urn:nam:{nam_file.stem}:{_hash_str(nam_path)}"
|
||||||
|
|
||||||
|
# Estimate CPU based on model size
|
||||||
|
cpu_est = {"nano": 8.0, "feather": 18.0, "standard": 35.0, "custom": 50.0}.get(model_size, 30.0)
|
||||||
|
|
||||||
|
# Known good on RPi4B: nano and feather models
|
||||||
|
rpi4b_good = model_size in ("nano", "feather")
|
||||||
|
|
||||||
|
info = PluginInfo(
|
||||||
|
meta=PluginMeta(
|
||||||
|
name=name,
|
||||||
|
uri=uri,
|
||||||
|
format=PluginFormat.NAM,
|
||||||
|
version=metadata.get("version", "") if metadata else "",
|
||||||
|
author=metadata.get("author", "") if metadata else "",
|
||||||
|
description=metadata.get("description", "") if metadata else "",
|
||||||
|
arch="aarch64",
|
||||||
|
arm_optimized=True,
|
||||||
|
),
|
||||||
|
categories=[PluginCategory.NAM_PROFILE],
|
||||||
|
bundle_path=str(nam_file.parent),
|
||||||
|
library_path="", # NAM files don't have a .so
|
||||||
|
status=PluginStatus.ACTIVE,
|
||||||
|
scanned_at=now,
|
||||||
|
estimated_cpu_pct=cpu_est,
|
||||||
|
rpi4b_known_good=rpi4b_good,
|
||||||
|
rpi4b_known_broken=(model_size == "standard"),
|
||||||
|
nam_model_path=nam_path,
|
||||||
|
nam_model_size=model_size,
|
||||||
|
)
|
||||||
|
results.append(info)
|
||||||
|
|
||||||
|
logger.info("NAM scan found %d models across %d paths", len(results), len(paths))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Unified scanner
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def scan_all(
|
||||||
|
lv2_paths: list[str] | None = None,
|
||||||
|
vst3_paths: list[str] | None = None,
|
||||||
|
ladspa_paths: list[str] | None = None,
|
||||||
|
nam_paths: list[str] | None = None,
|
||||||
|
) -> list[PluginInfo]:
|
||||||
|
"""Run all scanners and return a unified list of PluginInfo objects."""
|
||||||
|
results: list[PluginInfo] = []
|
||||||
|
results.extend(scan_lv2(lv2_paths))
|
||||||
|
results.extend(scan_vst3(vst3_paths))
|
||||||
|
results.extend(scan_ladspa(ladspa_paths))
|
||||||
|
results.extend(scan_nam(nam_paths))
|
||||||
|
|
||||||
|
# Sort by format, then name
|
||||||
|
results.sort(key=lambda p: (p.meta.format.value, p.meta.name.lower()))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def scan_format(
|
||||||
|
format: PluginFormat,
|
||||||
|
paths: list[str] | None = None,
|
||||||
|
) -> list[PluginInfo]:
|
||||||
|
"""Scan for plugins of a specific format only."""
|
||||||
|
scanners = {
|
||||||
|
PluginFormat.LV2: scan_lv2,
|
||||||
|
PluginFormat.VST3: scan_vst3,
|
||||||
|
PluginFormat.LADSPA: scan_ladspa,
|
||||||
|
PluginFormat.NAM: scan_nam,
|
||||||
|
}
|
||||||
|
scanner = scanners.get(format)
|
||||||
|
if scanner is None:
|
||||||
|
logger.error("Unsupported format for scanning: %s", format)
|
||||||
|
return []
|
||||||
|
return scanner(paths)
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Plugin types, enums, and data classes for the RPi Mixer Plugin Manager.
|
||||||
|
|
||||||
|
Defines the canonical plugin representation: format, category, metadata,
|
||||||
|
and the plugin registry entry model used throughout the plugin subsystem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin format enum ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PluginFormat(enum.StrEnum):
|
||||||
|
"""Supported plugin formats."""
|
||||||
|
LV2 = "lv2"
|
||||||
|
VST3 = "vst3"
|
||||||
|
VST2 = "vst2"
|
||||||
|
LADSPA = "ladspa"
|
||||||
|
DSSI = "dssi"
|
||||||
|
NAM = "nam" # Neural Amp Modeler (.nam files loaded via NAM LV2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin category enum ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PluginCategory(enum.StrEnum):
|
||||||
|
"""Semantic categories for plugin classification.
|
||||||
|
|
||||||
|
Categories are derived from LV2 class URIs, VST3 subcategories,
|
||||||
|
and best-effort heuristics for formats that lack standard taxonomies.
|
||||||
|
"""
|
||||||
|
AMP = "amp"
|
||||||
|
AMP_BASS = "amp_bass"
|
||||||
|
AMP_GUITAR = "amp_guitar"
|
||||||
|
CABINET = "cabinet"
|
||||||
|
IR_LOADER = "ir_loader"
|
||||||
|
REVERB = "reverb"
|
||||||
|
DELAY = "delay"
|
||||||
|
CHORUS = "chorus"
|
||||||
|
FLANGER = "flanger"
|
||||||
|
PHASER = "phaser"
|
||||||
|
TREMOLO = "tremolo"
|
||||||
|
VIBRATO = "vibrato"
|
||||||
|
COMPRESSOR = "compressor"
|
||||||
|
LIMITER = "limiter"
|
||||||
|
GATE = "gate"
|
||||||
|
EXPANDER = "expander"
|
||||||
|
EQ_PARAMETRIC = "eq_parametric"
|
||||||
|
EQ_GRAPHIC = "eq_graphic"
|
||||||
|
EQ_SHELVING = "eq_shelving"
|
||||||
|
FILTER = "filter"
|
||||||
|
DISTORTION = "distortion"
|
||||||
|
OVERDRIVE = "overdrive"
|
||||||
|
FUZZ = "fuzz"
|
||||||
|
PITCH_SHIFTER = "pitch_shifter"
|
||||||
|
OCTAVER = "octaver"
|
||||||
|
WAH = "wah"
|
||||||
|
SYNTH = "synth"
|
||||||
|
SAMPLER = "sampler"
|
||||||
|
DRUM_MACHINE = "drum_machine"
|
||||||
|
UTILITY = "utility"
|
||||||
|
METER = "meter"
|
||||||
|
ANALYZER = "analyzer"
|
||||||
|
SPATIAL = "spatial"
|
||||||
|
NOISE_REDUCTION = "noise_reduction"
|
||||||
|
NAM_PROFILE = "nam_profile" # NAM capture/profile
|
||||||
|
OTHER = "other"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin status enum ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PluginStatus(enum.StrEnum):
|
||||||
|
"""Lifecycle status of a plugin in the registry."""
|
||||||
|
ACTIVE = "active" # Installed and loadable
|
||||||
|
DISABLED = "disabled" # Installed but manually disabled
|
||||||
|
BLACKLISTED = "blacklisted" # Known-broken, blocked from loading
|
||||||
|
STALE = "stale" # Files missing but registry entry exists
|
||||||
|
REMOVED = "removed" # Soft-deleted (retained for history)
|
||||||
|
ERROR = "error" # Scan/load failed with an error
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data classes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PluginMeta:
|
||||||
|
"""Core plugin identity and metadata."""
|
||||||
|
name: str
|
||||||
|
uri: str # Unique identifier (LV2 URI, VST3 UID, or file hash)
|
||||||
|
format: PluginFormat
|
||||||
|
version: str = ""
|
||||||
|
author: str = ""
|
||||||
|
license: str = ""
|
||||||
|
description: str = ""
|
||||||
|
homepage: str = ""
|
||||||
|
project: str = "" # LV2 project or VST3 bundle name
|
||||||
|
|
||||||
|
# Platform info
|
||||||
|
arch: str = "" # e.g. arm64, x86_64
|
||||||
|
os: str = "linux"
|
||||||
|
arm_optimized: bool = False # Has ARM/NEON flags
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PluginPort:
|
||||||
|
"""A single audio/CV/control port on a plugin."""
|
||||||
|
index: int
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
direction: str = "input" # input, output
|
||||||
|
port_type: str = "control" # audio, control, cv, midi
|
||||||
|
default: float = 0.0
|
||||||
|
minimum: float = 0.0
|
||||||
|
maximum: float = 1.0
|
||||||
|
enumeration: list[tuple[float, str]] = field(default_factory=list) # (value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PluginInfo:
|
||||||
|
"""Full plugin descriptor — the registry's canonical entry."""
|
||||||
|
meta: PluginMeta
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
categories: list[PluginCategory] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
audio_inputs: int = 0
|
||||||
|
audio_outputs: int = 0
|
||||||
|
midi_inputs: int = 0
|
||||||
|
midi_outputs: int = 0
|
||||||
|
control_ports: list[PluginPort] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Filesystem
|
||||||
|
bundle_path: str = "" # Absolute path to the plugin bundle
|
||||||
|
library_path: str = "" # Absolute path to the shared object (.so)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: PluginStatus = PluginStatus.ACTIVE
|
||||||
|
error_message: str = ""
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
scanned_at: float = 0.0 # Unix timestamp of last successful scan
|
||||||
|
installed_at: float = 0.0 # Unix timestamp of installation
|
||||||
|
updated_at: float = 0.0 # Unix timestamp of last update
|
||||||
|
|
||||||
|
# Resource estimation for RPi4B
|
||||||
|
estimated_cpu_pct: float = 0.0 # Rough CPU % on RPi4B (0 = unknown)
|
||||||
|
estimated_ram_mb: float = 0.0 # Rough RAM usage in MB (0 = unknown)
|
||||||
|
rpi4b_known_good: bool = False # Verified working on RPi4B
|
||||||
|
rpi4b_known_broken: bool = False # Known to break on RPi4B
|
||||||
|
|
||||||
|
# NAM-specific
|
||||||
|
nam_model_path: str = "" # Path to .nam model file (if NAM plugin)
|
||||||
|
nam_model_size: str = "" # nano, feather, standard, custom
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize to a flat dict for registry storage."""
|
||||||
|
return {
|
||||||
|
"uri": self.meta.uri,
|
||||||
|
"name": self.meta.name,
|
||||||
|
"format": self.meta.format.value,
|
||||||
|
"version": self.meta.version,
|
||||||
|
"author": self.meta.author,
|
||||||
|
"license": self.meta.license,
|
||||||
|
"description": self.meta.description,
|
||||||
|
"homepage": self.meta.homepage,
|
||||||
|
"project": self.meta.project,
|
||||||
|
"arch": self.meta.arch,
|
||||||
|
"os": self.meta.os,
|
||||||
|
"arm_optimized": int(self.meta.arm_optimized),
|
||||||
|
"categories": ",".join(c.value for c in self.categories),
|
||||||
|
"audio_inputs": self.audio_inputs,
|
||||||
|
"audio_outputs": self.audio_outputs,
|
||||||
|
"midi_inputs": self.midi_inputs,
|
||||||
|
"midi_outputs": self.midi_outputs,
|
||||||
|
"bundle_path": self.bundle_path,
|
||||||
|
"library_path": self.library_path,
|
||||||
|
"status": self.status.value,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
"scanned_at": self.scanned_at,
|
||||||
|
"installed_at": self.installed_at,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"estimated_cpu_pct": self.estimated_cpu_pct,
|
||||||
|
"estimated_ram_mb": self.estimated_ram_mb,
|
||||||
|
"rpi4b_known_good": int(self.rpi4b_known_good),
|
||||||
|
"rpi4b_known_broken": int(self.rpi4b_known_broken),
|
||||||
|
"nam_model_path": self.nam_model_path,
|
||||||
|
"nam_model_size": self.nam_model_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> PluginInfo:
|
||||||
|
"""Deserialize from a flat dict (registry row)."""
|
||||||
|
meta = PluginMeta(
|
||||||
|
name=d.get("name", ""),
|
||||||
|
uri=d.get("uri", ""),
|
||||||
|
format=PluginFormat(d.get("format", "lv2")),
|
||||||
|
version=d.get("version", ""),
|
||||||
|
author=d.get("author", ""),
|
||||||
|
license=d.get("license", ""),
|
||||||
|
description=d.get("description", ""),
|
||||||
|
homepage=d.get("homepage", ""),
|
||||||
|
project=d.get("project", ""),
|
||||||
|
arch=d.get("arch", ""),
|
||||||
|
os=d.get("os", "linux"),
|
||||||
|
arm_optimized=bool(d.get("arm_optimized", 0)),
|
||||||
|
)
|
||||||
|
cats_raw = d.get("categories", "")
|
||||||
|
categories = [PluginCategory(c) for c in cats_raw.split(",") if c] if cats_raw else []
|
||||||
|
return cls(
|
||||||
|
meta=meta,
|
||||||
|
categories=categories,
|
||||||
|
audio_inputs=d.get("audio_inputs", 0),
|
||||||
|
audio_outputs=d.get("audio_outputs", 0),
|
||||||
|
midi_inputs=d.get("midi_inputs", 0),
|
||||||
|
midi_outputs=d.get("midi_outputs", 0),
|
||||||
|
bundle_path=d.get("bundle_path", ""),
|
||||||
|
library_path=d.get("library_path", ""),
|
||||||
|
status=PluginStatus(d.get("status", "active")),
|
||||||
|
error_message=d.get("error_message", ""),
|
||||||
|
scanned_at=d.get("scanned_at", 0.0),
|
||||||
|
installed_at=d.get("installed_at", 0.0),
|
||||||
|
updated_at=d.get("updated_at", 0.0),
|
||||||
|
estimated_cpu_pct=d.get("estimated_cpu_pct", 0.0),
|
||||||
|
estimated_ram_mb=d.get("estimated_ram_mb", 0.0),
|
||||||
|
rpi4b_known_good=bool(d.get("rpi4b_known_good", 0)),
|
||||||
|
rpi4b_known_broken=bool(d.get("rpi4b_known_broken", 0)),
|
||||||
|
nam_model_path=d.get("nam_model_path", ""),
|
||||||
|
nam_model_size=d.get("nam_model_size", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_loadable(self) -> bool:
|
||||||
|
"""True if this plugin should be loadable at runtime."""
|
||||||
|
return self.status == PluginStatus.ACTIVE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_virtual_instrument(self) -> bool:
|
||||||
|
"""Heuristic: true if this looks like an instrument (not an effect)."""
|
||||||
|
inst_cats = {
|
||||||
|
PluginCategory.SYNTH, PluginCategory.SAMPLER,
|
||||||
|
PluginCategory.DRUM_MACHINE,
|
||||||
|
}
|
||||||
|
return bool(set(self.categories) & inst_cats) or self.audio_inputs == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_effect(self) -> bool:
|
||||||
|
"""Heuristic: true if this looks like an effect (has audio I/O)."""
|
||||||
|
return self.audio_inputs > 0 and self.audio_outputs > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nam_model_uri(self) -> str | None:
|
||||||
|
"""Return the .nam file URI if this is a NAM plugin with a model."""
|
||||||
|
if self.meta.format == PluginFormat.NAM and self.nam_model_path:
|
||||||
|
return self.nam_model_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bundle types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PluginBundle:
|
||||||
|
"""A downloadable/installable plugin bundle."""
|
||||||
|
name: str
|
||||||
|
uri: str
|
||||||
|
format: PluginFormat
|
||||||
|
version: str
|
||||||
|
source_url: str # Download URL
|
||||||
|
source_type: str = "tar.gz" # tar.gz, zip, deb, git
|
||||||
|
checksum_sha256: str = ""
|
||||||
|
size_bytes: int = 0
|
||||||
|
dependencies: list[str] = field(default_factory=list)
|
||||||
|
build_script: str = "" # Path to build script relative to extract root
|
||||||
|
install_paths: dict[str, str] = field(default_factory=dict) # src -> dest mapping
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Touchscreen UI for the Raspberry Pi RT Audio Mixer.
|
||||||
|
|
||||||
|
Provides a Kivy-based multi-touch mixer control surface for
|
||||||
|
Raspberry Pi official 7" and Waveshare 5" touch displays.
|
||||||
|
|
||||||
|
Modules:
|
||||||
|
app — Kivy App entry point + ScreenManager
|
||||||
|
ipc — REST + OSC client for mixer engine communication
|
||||||
|
theme — DPI-aware colors, fonts, sizes
|
||||||
|
screens — Mixer surface, routing matrix, plugin chain, settings
|
||||||
|
widgets — Faders, knobs, meters, buttons
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
"""Kivy application entry point for the touchscreen mixer UI.
|
||||||
|
|
||||||
|
Provides a ScreenManager with four screens:
|
||||||
|
- Mixer surface (faders, meters, mute/solo)
|
||||||
|
- Routing matrix
|
||||||
|
- Plugin chain
|
||||||
|
- Settings (brightness, timeout, DPI)
|
||||||
|
|
||||||
|
Designed for Raspberry Pi 5"/7" touch displays.
|
||||||
|
Runs fullscreen at native resolution with DPI-aware layout.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 main_touch.py # connect to localhost:8000
|
||||||
|
python3 main_touch.py --host 192.168.1.10 # remote mixer
|
||||||
|
KIVY_DPI=220 python3 main_touch.py # override DPI
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
MIXER_HOST — mixer server hostname (default 127.0.0.1)
|
||||||
|
MIXER_PORT — REST API port (default 8000)
|
||||||
|
MIXER_OSC_PORT — OSC server port (default 9001)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from kivy.app import App
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.config import Config
|
||||||
|
from kivy.core.window import Window
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.screenmanager import ScreenManager, Screen, FadeTransition, SlideTransition
|
||||||
|
from kivy.metrics import dp
|
||||||
|
|
||||||
|
from .ipc import MixerClient
|
||||||
|
from .theme import Colors, Fonts, Sizes
|
||||||
|
from .screens.mixer import MixerScreen
|
||||||
|
from .screens.routing import RoutingScreen
|
||||||
|
from .screens.plugins import PluginScreen
|
||||||
|
from .screens.settings import SettingsScreen
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Touch-optimized Kivy config ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _configure_kivy():
|
||||||
|
"""Apply touch-optimized Kivy settings before window creation."""
|
||||||
|
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||||
|
Config.set("kivy", "window_icon", "")
|
||||||
|
Config.set("kivy", "exit_on_escape", 0) # No accidental exit
|
||||||
|
Config.set("graphics", "fullscreen", "auto")
|
||||||
|
Config.set("graphics", "show_cursor", 0) # Hide cursor on touchscreen
|
||||||
|
Config.set("graphics", "resizable", 0) # Fixed size for embedded
|
||||||
|
|
||||||
|
# Allow environment overrides
|
||||||
|
if os.environ.get("KIVY_SHOW_CURSOR"):
|
||||||
|
Config.set("graphics", "show_cursor", 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TabBar(BoxLayout):
|
||||||
|
"""Bottom tab bar for screen switching.
|
||||||
|
|
||||||
|
Four tabs: Mixer, Routing, Plugins, Settings.
|
||||||
|
Touch-optimized: large hit targets, clear active indicator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
screen_manager = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.orientation = "horizontal"
|
||||||
|
self.size_hint = (1, None)
|
||||||
|
self.height = Sizes.TAB_BAR_HEIGHT
|
||||||
|
self.spacing = 0
|
||||||
|
|
||||||
|
self._tabs: dict[str, Button] = {}
|
||||||
|
self._active_tab = "mixer"
|
||||||
|
|
||||||
|
tabs = [
|
||||||
|
("mixer", "MIX"),
|
||||||
|
("routing", "RTG"),
|
||||||
|
("plugins", "PLG"),
|
||||||
|
("settings", "SET"),
|
||||||
|
]
|
||||||
|
for name, label in tabs:
|
||||||
|
btn = Button(
|
||||||
|
text=label,
|
||||||
|
background_normal="",
|
||||||
|
background_color=Colors.BG_HEADER,
|
||||||
|
color=Colors.ACCENT if name == "mixer" else Colors.TEXT_MUTED,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
bold=True,
|
||||||
|
)
|
||||||
|
btn.bind(on_press=self._make_switch(name))
|
||||||
|
self._tabs[name] = btn
|
||||||
|
self.add_widget(btn)
|
||||||
|
|
||||||
|
def _make_switch(self, screen_name: str):
|
||||||
|
def cb(instance):
|
||||||
|
if self.screen_manager:
|
||||||
|
self.screen_manager.current = screen_name
|
||||||
|
self._set_active(screen_name)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def _set_active(self, name: str):
|
||||||
|
self._active_tab = name
|
||||||
|
for tab_name, btn in self._tabs.items():
|
||||||
|
if tab_name == name:
|
||||||
|
btn.color = Colors.ACCENT
|
||||||
|
btn.font_size = Fonts.BODY
|
||||||
|
else:
|
||||||
|
btn.color = Colors.TEXT_MUTED
|
||||||
|
btn.font_size = Fonts.SMALL
|
||||||
|
|
||||||
|
def switch_to(self, name: str):
|
||||||
|
"""External switch (e.g., from gesture)."""
|
||||||
|
self._set_active(name)
|
||||||
|
if self.screen_manager:
|
||||||
|
self.screen_manager.current = name
|
||||||
|
|
||||||
|
|
||||||
|
# ── Root widget ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MixerRoot(BoxLayout):
|
||||||
|
"""Root layout: header + screen area + tab bar."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.orientation = "vertical"
|
||||||
|
|
||||||
|
# Status bar (top)
|
||||||
|
self._status_bar = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.HEADER_HEIGHT,
|
||||||
|
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||||
|
)
|
||||||
|
self._status_bar.bg_color = Colors.BG_HEADER
|
||||||
|
|
||||||
|
self._status_label = Label(
|
||||||
|
text="● Connected",
|
||||||
|
color=Colors.ACTIVE,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=dp(120),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
self._status_label.bind(size=self._status_label.setter("text_size"))
|
||||||
|
|
||||||
|
self._time_label = Label(
|
||||||
|
text="",
|
||||||
|
color=Colors.TEXT_MUTED,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
size_hint=(1, 1),
|
||||||
|
halign="right",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._status_bar.add_widget(self._status_label)
|
||||||
|
self._status_bar.add_widget(self._time_label)
|
||||||
|
|
||||||
|
# Screen manager (center)
|
||||||
|
self.screen_manager = ScreenManager(transition=FadeTransition(duration=0.2))
|
||||||
|
|
||||||
|
# Screens
|
||||||
|
self.mixer_screen = MixerScreen(name="mixer")
|
||||||
|
self.routing_screen = RoutingScreen(name="routing")
|
||||||
|
self.plugin_screen = PluginScreen(name="plugins")
|
||||||
|
self.settings_screen = SettingsScreen(name="settings")
|
||||||
|
|
||||||
|
self.screen_manager.add_widget(self.mixer_screen)
|
||||||
|
self.screen_manager.add_widget(self.routing_screen)
|
||||||
|
self.screen_manager.add_widget(self.plugin_screen)
|
||||||
|
self.screen_manager.add_widget(self.settings_screen)
|
||||||
|
|
||||||
|
# Tab bar (bottom)
|
||||||
|
self.tab_bar = TabBar(screen_manager=self.screen_manager)
|
||||||
|
|
||||||
|
self.add_widget(self._status_bar)
|
||||||
|
self.add_widget(self.screen_manager)
|
||||||
|
self.add_widget(self.tab_bar)
|
||||||
|
|
||||||
|
def set_client(self, client: MixerClient):
|
||||||
|
"""Inject the mixer client into all screens."""
|
||||||
|
self.mixer_screen.client = client
|
||||||
|
self.routing_screen.client = client
|
||||||
|
self.plugin_screen.client = client
|
||||||
|
self.settings_screen.client = client
|
||||||
|
|
||||||
|
def update_status(self, connected: bool, info: str = ""):
|
||||||
|
"""Update the status bar."""
|
||||||
|
if connected:
|
||||||
|
self._status_label.text = "● Connected" + (f" {info}" if info else "")
|
||||||
|
self._status_label.color = Colors.ACTIVE
|
||||||
|
else:
|
||||||
|
self._status_label.text = "○ Disconnected"
|
||||||
|
self._status_label.color = Colors.MUTE_ON
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kivy App ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MixerApp(App):
|
||||||
|
"""Touchscreen mixer application.
|
||||||
|
|
||||||
|
Args to build():
|
||||||
|
host: Mixer server hostname.
|
||||||
|
rest_port: REST API port.
|
||||||
|
osc_port: OSC server port.
|
||||||
|
api_key: API key for authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client: MixerClient = None # type: ignore
|
||||||
|
|
||||||
|
def __init__(self, host="127.0.0.1", rest_port=8000, osc_port=9001, api_key="", **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._host = host
|
||||||
|
self._rest_port = rest_port
|
||||||
|
self._osc_port = osc_port
|
||||||
|
self._api_key = api_key
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
"""Build the UI and return the root widget."""
|
||||||
|
_configure_kivy()
|
||||||
|
|
||||||
|
# Set window properties
|
||||||
|
Window.clearcolor = Colors.BG_DARK
|
||||||
|
Window.bind(on_key_down=self._on_key_down)
|
||||||
|
|
||||||
|
self.client = MixerClient(
|
||||||
|
host=self._host,
|
||||||
|
rest_port=self._rest_port,
|
||||||
|
osc_port=self._osc_port,
|
||||||
|
api_key=self._api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.root_widget = MixerRoot()
|
||||||
|
self.root_widget.set_client(self.client)
|
||||||
|
|
||||||
|
# Periodic health check
|
||||||
|
Clock.schedule_interval(self._health_check, 5.0)
|
||||||
|
|
||||||
|
# Update clock
|
||||||
|
Clock.schedule_interval(self._update_clock, 1.0)
|
||||||
|
|
||||||
|
return self.root_widget
|
||||||
|
|
||||||
|
def _health_check(self, dt):
|
||||||
|
"""Check mixer server connectivity."""
|
||||||
|
self.client.fetch_state(
|
||||||
|
on_success=lambda state: self.root_widget.update_status(True),
|
||||||
|
on_error=lambda e: self.root_widget.update_status(False, str(e)[:20]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_clock(self, dt):
|
||||||
|
"""Update the time display."""
|
||||||
|
import time
|
||||||
|
t = time.localtime()
|
||||||
|
self.root_widget._time_label.text = time.strftime("%H:%M:%S", t)
|
||||||
|
|
||||||
|
def _on_key_down(self, window, key, scancode, codepoint, modifier):
|
||||||
|
"""Handle keyboard shortcuts."""
|
||||||
|
# ESC to toggle between screens
|
||||||
|
if key == 27: # ESC
|
||||||
|
sm = self.root_widget.screen_manager
|
||||||
|
screens = sm.screen_names
|
||||||
|
idx = screens.index(sm.current)
|
||||||
|
next_idx = (idx + 1) % len(screens)
|
||||||
|
sm.current = screens[next_idx]
|
||||||
|
self.root_widget.tab_bar.switch_to(screens[next_idx])
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
"""Cleanup on app exit."""
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
+419
@@ -0,0 +1,419 @@
|
|||||||
|
"""IPC client for the touchscreen UI.
|
||||||
|
|
||||||
|
Communicates with the mixer engine via:
|
||||||
|
- REST API (GET /state, PUT /channel/{id}/parameter, etc.)
|
||||||
|
- OSC UDP (real-time parameter control, optional fallback)
|
||||||
|
|
||||||
|
Designed to run locally on the Raspberry Pi alongside the mixer server.
|
||||||
|
All calls are non-blocking via Kivy's UrlRequest; OSC runs on a
|
||||||
|
background thread with Kivy clock callbacks for thread safety.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.ui.ipc import MixerClient
|
||||||
|
|
||||||
|
client = MixerClient(host="127.0.0.1", rest_port=8000)
|
||||||
|
client.fetch_state(on_success=handle_state)
|
||||||
|
client.set_parameter("volume", 0.75, channel=3)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Import Kivy UrlRequest — works on any thread, callbacks fire on main
|
||||||
|
try:
|
||||||
|
from kivy.network.urlrequest import UrlRequest
|
||||||
|
KIVY_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
KIVY_AVAILABLE = False
|
||||||
|
UrlRequest = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# ── OSC helpers (pure Python, no deps beyond stdlib) ─────────────────────────
|
||||||
|
|
||||||
|
def _pad_4(s: bytes) -> bytes:
|
||||||
|
rem = len(s) % 4
|
||||||
|
return s + b"\x00" * (4 - rem) if rem else s
|
||||||
|
|
||||||
|
|
||||||
|
def _osc_string(s: str) -> bytes:
|
||||||
|
return _pad_4(s.encode("utf-8") + b"\x00")
|
||||||
|
|
||||||
|
|
||||||
|
def _osc_float(v: float) -> bytes:
|
||||||
|
import struct
|
||||||
|
return struct.pack(">f", v)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_osc(address: str, *args) -> bytes:
|
||||||
|
"""Encode an OSC 1.0 message. Handles float, int, string."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
type_tag = ","
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, float):
|
||||||
|
type_tag += "f"
|
||||||
|
elif isinstance(arg, int):
|
||||||
|
type_tag += "i"
|
||||||
|
elif isinstance(arg, str):
|
||||||
|
type_tag += "s"
|
||||||
|
else:
|
||||||
|
type_tag += "s"
|
||||||
|
|
||||||
|
packet = _osc_string(address) + _osc_string(type_tag)
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, float):
|
||||||
|
packet += struct.pack(">f", arg)
|
||||||
|
elif isinstance(arg, int):
|
||||||
|
packet += struct.pack(">i", arg)
|
||||||
|
elif isinstance(arg, str):
|
||||||
|
packet += _osc_string(str(arg))
|
||||||
|
return packet
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mixer IPC client ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MixerClient:
|
||||||
|
"""Non-blocking IPC client for the mixer engine.
|
||||||
|
|
||||||
|
Uses Kivy's UrlRequest for REST calls (non-blocking, main-thread
|
||||||
|
callbacks) and raw UDP for OSC (background thread, Kivy clock
|
||||||
|
for thread-safe callbacks).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
host: Mixer server hostname (default 127.0.0.1).
|
||||||
|
rest_port: REST API port (default 8000).
|
||||||
|
osc_port: OSC server port (default 9001).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
rest_port: int = 8000,
|
||||||
|
osc_port: int = 9001,
|
||||||
|
api_key: str = "",
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.rest_port = rest_port
|
||||||
|
self.osc_port = osc_port
|
||||||
|
self.api_key = api_key
|
||||||
|
self._base_url = f"http://{host}:{rest_port}"
|
||||||
|
self._osc_sock: Optional[socket.socket] = None
|
||||||
|
self._osc_lock = threading.Lock()
|
||||||
|
self._pending_requests: set[UrlRequest] = set() if KIVY_AVAILABLE else set() # type: ignore
|
||||||
|
|
||||||
|
# ── REST API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _url(self, path: str) -> str:
|
||||||
|
return f"{self._base_url}{path}"
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
h["X-API-Key"] = self.api_key
|
||||||
|
return h
|
||||||
|
|
||||||
|
def fetch_state(
|
||||||
|
self,
|
||||||
|
on_success: Callable[[dict], None],
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
on_failure: Optional[Callable[[UrlRequest], None]] = None, # type: ignore
|
||||||
|
) -> None:
|
||||||
|
"""GET /state — fetch full mixer state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_success: Called with the parsed JSON dict on 2xx.
|
||||||
|
on_error: Called with an error string on non-2xx.
|
||||||
|
on_failure: Called with the UrlRequest on connection failure.
|
||||||
|
"""
|
||||||
|
if not KIVY_AVAILABLE:
|
||||||
|
_sync_fetch(self._url("/state"), on_success, on_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ok(req, result):
|
||||||
|
on_success(result)
|
||||||
|
|
||||||
|
def _err(req, error):
|
||||||
|
msg = f"REST error: {error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
def _fail(req):
|
||||||
|
msg = f"REST failure: {req.error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_failure:
|
||||||
|
on_failure(req)
|
||||||
|
elif on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
UrlRequest(
|
||||||
|
self._url("/state"),
|
||||||
|
on_success=_ok,
|
||||||
|
on_error=_err,
|
||||||
|
on_failure=_fail,
|
||||||
|
req_headers=self._headers(),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_channels(
|
||||||
|
self,
|
||||||
|
on_success: Callable[[list], None],
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""GET /channels — list all channel states."""
|
||||||
|
self._simple_get("/channels", on_success, on_error, expect_list=True)
|
||||||
|
|
||||||
|
def fetch_routing(
|
||||||
|
self,
|
||||||
|
on_success: Callable[[dict], None],
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""GET /routing — fetch routing matrix state."""
|
||||||
|
self._simple_get("/routing", on_success, on_error)
|
||||||
|
|
||||||
|
def fetch_plugins(
|
||||||
|
self,
|
||||||
|
on_success: Callable[[list], None],
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""GET /plugins — list all plugins."""
|
||||||
|
self._simple_get("/plugins", on_success, on_error, expect_list=True)
|
||||||
|
|
||||||
|
def set_parameter(
|
||||||
|
self,
|
||||||
|
param_type: str,
|
||||||
|
value: float,
|
||||||
|
channel: int = -1,
|
||||||
|
on_success: Optional[Callable[[dict], None]] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""PUT /channels/{id}/parameter — set a mixer parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param_type: e.g., 'volume', 'mute', 'pan', 'solo'.
|
||||||
|
value: New value.
|
||||||
|
channel: Channel index (0-based), -1 for master.
|
||||||
|
"""
|
||||||
|
body = json.dumps({"param_type": param_type, "value": value})
|
||||||
|
url = (
|
||||||
|
self._url(f"/channels/{channel}/parameter")
|
||||||
|
if channel >= 0
|
||||||
|
else self._url("/mixes/parameter")
|
||||||
|
)
|
||||||
|
self._send_put(url, body, on_success, on_error)
|
||||||
|
|
||||||
|
def set_master_parameter(
|
||||||
|
self,
|
||||||
|
param_type: str,
|
||||||
|
value: float,
|
||||||
|
on_success: Optional[Callable[[dict], None]] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""PUT /mixes/parameter — set master parameter."""
|
||||||
|
body = json.dumps({"param_type": param_type, "value": value})
|
||||||
|
self._send_put(self._url("/mixes/parameter"), body, on_success, on_error)
|
||||||
|
|
||||||
|
def transport_command(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
on_success: Optional[Callable[[dict], None]] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""PUT /transport/command — play/stop/record/loop."""
|
||||||
|
body = json.dumps({"command": command})
|
||||||
|
self._send_put(self._url("/transport/command"), body, on_success, on_error)
|
||||||
|
|
||||||
|
def _simple_get(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
on_success: Callable,
|
||||||
|
on_error: Optional[Callable[[str], None]],
|
||||||
|
expect_list: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not KIVY_AVAILABLE:
|
||||||
|
result = _sync_fetch(self._url(path), on_success, on_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ok(req, result):
|
||||||
|
data = result if not expect_list else result
|
||||||
|
on_success(data)
|
||||||
|
|
||||||
|
def _err(req, error):
|
||||||
|
msg = f"REST GET {path}: {error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
def _fail(req):
|
||||||
|
msg = f"REST GET {path} failure: {req.error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
UrlRequest(
|
||||||
|
self._url(path),
|
||||||
|
on_success=_ok,
|
||||||
|
on_error=_err,
|
||||||
|
on_failure=_fail,
|
||||||
|
req_headers=self._headers(),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_put(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
body: str,
|
||||||
|
on_success: Optional[Callable[[dict], None]],
|
||||||
|
on_error: Optional[Callable[[str], None]],
|
||||||
|
) -> None:
|
||||||
|
if not KIVY_AVAILABLE:
|
||||||
|
_sync_put(url, body, on_success, on_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ok(req, result):
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
|
||||||
|
def _err(req, error):
|
||||||
|
msg = f"REST PUT error: {error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
def _fail(req):
|
||||||
|
msg = f"REST PUT failure: {req.error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
UrlRequest(
|
||||||
|
url,
|
||||||
|
method="PUT",
|
||||||
|
req_body=body,
|
||||||
|
on_success=_ok,
|
||||||
|
on_error=_err,
|
||||||
|
on_failure=_fail,
|
||||||
|
req_headers=self._headers(),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── OSC (optional raw UDP channel) ────────────────────────────────────
|
||||||
|
|
||||||
|
def osc_send(self, address: str, *args) -> bool:
|
||||||
|
"""Send a raw OSC message to the mixer OSC server (port 9001).
|
||||||
|
|
||||||
|
Use for real-time parameter changes where REST latency
|
||||||
|
is too high. Fire-and-forget — no response expected.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the packet was queued to the socket.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
packet = encode_osc(address, *args)
|
||||||
|
with self._osc_lock:
|
||||||
|
if self._osc_sock is None:
|
||||||
|
self._osc_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
self._osc_sock.settimeout(0.1)
|
||||||
|
self._osc_sock.sendto(packet, (self.host, self.osc_port))
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("OSC send failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def osc_set_channel_param(self, channel: int, param: str, value: float) -> bool:
|
||||||
|
"""Send a channel parameter change via OSC.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
client.osc_set_channel_param(3, "volume", -6.0)
|
||||||
|
# Sends: /mixer/channel/3/volume -6.0
|
||||||
|
"""
|
||||||
|
return self.osc_send(f"/mixer/channel/{channel}/{param}", float(value))
|
||||||
|
|
||||||
|
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def cancel_all(self) -> None:
|
||||||
|
"""Cancel all pending REST requests."""
|
||||||
|
for req in list(self._pending_requests):
|
||||||
|
try:
|
||||||
|
req.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._pending_requests.clear()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close OSC socket and cancel requests."""
|
||||||
|
self.cancel_all()
|
||||||
|
with self._osc_lock:
|
||||||
|
if self._osc_sock:
|
||||||
|
try:
|
||||||
|
self._osc_sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._osc_sock = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Synchronous fallback (for testing without Kivy) ─────────────────────────
|
||||||
|
|
||||||
|
def _sync_fetch(
|
||||||
|
url: str,
|
||||||
|
on_success: Optional[Callable] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Fallback synchronous GET using urllib."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
if on_success:
|
||||||
|
on_success(data)
|
||||||
|
return data
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
msg = f"HTTP {e.code}: {e.reason}"
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_put(
|
||||||
|
url: str,
|
||||||
|
body: str,
|
||||||
|
on_success: Optional[Callable] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fallback synchronous PUT using urllib."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data, method="PUT",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
msg = f"HTTP {e.code}: {e.reason}"
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Touchscreen mixer screens."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""Main mixer surface screen — faders, mute/solo, pan per channel.
|
||||||
|
|
||||||
|
This is the primary screen showing all channel strips in a
|
||||||
|
horizontally scrollable layout with faders, meters, mute/solo
|
||||||
|
buttons, and pan knobs at the top.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.scrollview import ScrollView
|
||||||
|
from kivy.uix.screenmanager import Screen
|
||||||
|
from kivy.metrics import dp
|
||||||
|
|
||||||
|
from ..theme import Colors, Fonts, Sizes
|
||||||
|
from ..widgets.fader import FaderWidget
|
||||||
|
from ..widgets.knob import KnobWidget
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelStrip(BoxLayout):
|
||||||
|
"""One channel strip: fader + mute/solo + pan knob.
|
||||||
|
|
||||||
|
Layout (top to bottom):
|
||||||
|
pan knob
|
||||||
|
mute button
|
||||||
|
solo button
|
||||||
|
fader (with meter)
|
||||||
|
channel label
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channel_index: int, label: str = "", **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.orientation = "vertical"
|
||||||
|
self.size_hint = (None, 1)
|
||||||
|
self.width = Sizes.FADER_WIDTH + dp(16)
|
||||||
|
self.spacing = dp(4)
|
||||||
|
self.padding = [dp(6), dp(4), dp(6), dp(4)]
|
||||||
|
|
||||||
|
self.channel_index = channel_index
|
||||||
|
color = Colors.FADER_COLORS[channel_index % len(Colors.FADER_COLORS)]
|
||||||
|
|
||||||
|
# Pan knob
|
||||||
|
self.pan_knob = KnobWidget(
|
||||||
|
label="PAN",
|
||||||
|
min_val=-1.0,
|
||||||
|
max_val=1.0,
|
||||||
|
default_val=0.0,
|
||||||
|
color=list(color),
|
||||||
|
parameter_name="pan",
|
||||||
|
size_hint=(None, None),
|
||||||
|
size=(Sizes.KNOB_SIZE + dp(16), Sizes.KNOB_SIZE + dp(24)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mute/Solo buttons row
|
||||||
|
btn_row = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.MUTE_SOLO_BUTTON,
|
||||||
|
spacing=dp(4),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.mute_btn = Button(
|
||||||
|
text="M",
|
||||||
|
size_hint=(1, 1),
|
||||||
|
background_normal="",
|
||||||
|
background_color=(0.15, 0.15, 0.22, 1),
|
||||||
|
color=Colors.TEXT_SECONDARY,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
bold=True,
|
||||||
|
)
|
||||||
|
self.mute_btn.bind(on_press=self._toggle_mute)
|
||||||
|
|
||||||
|
self.solo_btn = Button(
|
||||||
|
text="S",
|
||||||
|
size_hint=(1, 1),
|
||||||
|
background_normal="",
|
||||||
|
background_color=(0.15, 0.15, 0.22, 1),
|
||||||
|
color=Colors.TEXT_SECONDARY,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
bold=True,
|
||||||
|
)
|
||||||
|
self.solo_btn.bind(on_press=self._toggle_solo)
|
||||||
|
|
||||||
|
btn_row.add_widget(self.mute_btn)
|
||||||
|
btn_row.add_widget(self.solo_btn)
|
||||||
|
|
||||||
|
# Fader
|
||||||
|
self.fader = FaderWidget(
|
||||||
|
label=label or f"CH {channel_index + 1}",
|
||||||
|
color=list(color),
|
||||||
|
channel_index=channel_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Channel label
|
||||||
|
self.ch_label = Label(
|
||||||
|
text=label or f"CH{channel_index + 1}",
|
||||||
|
color=Colors.TEXT_SECONDARY,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(20),
|
||||||
|
halign="center",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.add_widget(self.pan_knob)
|
||||||
|
self.add_widget(btn_row)
|
||||||
|
self.add_widget(self.fader)
|
||||||
|
self.add_widget(self.ch_label)
|
||||||
|
|
||||||
|
# State
|
||||||
|
self.muted = False
|
||||||
|
self.soloed = False
|
||||||
|
|
||||||
|
def _toggle_mute(self, instance):
|
||||||
|
self.muted = not self.muted
|
||||||
|
self.fader.mute = self.muted
|
||||||
|
self.mute_btn.background_color = Colors.MUTE_ON if self.muted else (0.15, 0.15, 0.22, 1)
|
||||||
|
self.mute_btn.color = (1, 1, 1, 1) if self.muted else Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
|
def _toggle_solo(self, instance):
|
||||||
|
self.soloed = not self.soloed
|
||||||
|
self.fader.solo = self.soloed
|
||||||
|
self.solo_btn.background_color = Colors.SOLO_ON if self.soloed else (0.15, 0.15, 0.22, 1)
|
||||||
|
self.solo_btn.color = (0, 0, 0, 1) if self.soloed else Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
|
def update_state(self, channel_data: dict) -> None:
|
||||||
|
"""Update widget state from mixer state dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_data: Dict from ChannelSchema or similar:
|
||||||
|
{volume_db, pan, mute, solo, ...}
|
||||||
|
"""
|
||||||
|
self.fader.value = channel_data.get("volume_db", 0.0)
|
||||||
|
self.muted = channel_data.get("mute", False)
|
||||||
|
self.soloed = channel_data.get("solo", False)
|
||||||
|
self.fader.mute = self.muted
|
||||||
|
self.fader.solo = self.soloed
|
||||||
|
self.pan_knob.value = channel_data.get("pan", 0.0)
|
||||||
|
|
||||||
|
self.mute_btn.background_color = Colors.MUTE_ON if self.muted else (0.15, 0.15, 0.22, 1)
|
||||||
|
self.mute_btn.color = (1, 1, 1, 1) if self.muted else Colors.TEXT_SECONDARY
|
||||||
|
self.solo_btn.background_color = Colors.SOLO_ON if self.soloed else (0.15, 0.15, 0.22, 1)
|
||||||
|
self.solo_btn.color = (0, 0, 0, 1) if self.soloed else Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
|
|
||||||
|
class MixerScreen(Screen):
|
||||||
|
"""Main mixer surface with scrollable channel strips.
|
||||||
|
|
||||||
|
Shows the master fader on the right side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client = ObjectProperty(None)
|
||||||
|
strips: list[ChannelStrip] = []
|
||||||
|
_refresh_event = None
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.name = "mixer"
|
||||||
|
|
||||||
|
root = BoxLayout(orientation="vertical")
|
||||||
|
self._root = root
|
||||||
|
|
||||||
|
# ── Header ──
|
||||||
|
header = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.HEADER_HEIGHT,
|
||||||
|
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||||
|
)
|
||||||
|
header.bg_color = Colors.BG_HEADER
|
||||||
|
title = Label(
|
||||||
|
text="MIXER",
|
||||||
|
color=Colors.ACCENT,
|
||||||
|
font_size=Fonts.HEADER,
|
||||||
|
bold=True,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=dp(120),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
title.bind(size=title.setter("text_size"))
|
||||||
|
self._clock_label = Label(
|
||||||
|
text="",
|
||||||
|
color=Colors.TEXT_MUTED,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
size_hint=(1, 1),
|
||||||
|
halign="right",
|
||||||
|
)
|
||||||
|
header.add_widget(title)
|
||||||
|
header.add_widget(self._clock_label)
|
||||||
|
|
||||||
|
# ── Master fader (right side, fixed) ──
|
||||||
|
body = BoxLayout(orientation="horizontal")
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
# Scrollable channel strips
|
||||||
|
self._scroll = ScrollView(
|
||||||
|
size_hint=(1, 1),
|
||||||
|
do_scroll_x=True,
|
||||||
|
do_scroll_y=False,
|
||||||
|
bar_width=dp(4),
|
||||||
|
scroll_type=["bars", "content"],
|
||||||
|
)
|
||||||
|
self._strip_container = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(None, 1),
|
||||||
|
spacing=Sizes.PAD_SM,
|
||||||
|
padding=[Sizes.PAD_SM, Sizes.PAD_SM],
|
||||||
|
)
|
||||||
|
self._strip_container.bind(minimum_width=self._strip_container.setter("width"))
|
||||||
|
self._scroll.add_widget(self._strip_container)
|
||||||
|
|
||||||
|
# Master fader (right side)
|
||||||
|
self._master_fader = FaderWidget(
|
||||||
|
label="MASTER",
|
||||||
|
color=[1.0, 0.84, 0.0, 1],
|
||||||
|
channel_index=-1,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=Sizes.FADER_WIDTH + dp(20),
|
||||||
|
size=(Sizes.FADER_WIDTH + dp(20), Sizes.FADER_HEIGHT),
|
||||||
|
min_val=-60.0,
|
||||||
|
max_val=12.0,
|
||||||
|
value=0.0,
|
||||||
|
)
|
||||||
|
self._master_fader.on_value_changed = self._on_master_fader_change
|
||||||
|
|
||||||
|
body.add_widget(self._scroll)
|
||||||
|
body.add_widget(self._master_fader)
|
||||||
|
|
||||||
|
root.add_widget(header)
|
||||||
|
root.add_widget(body)
|
||||||
|
self.add_widget(root)
|
||||||
|
|
||||||
|
# Build 16 channels
|
||||||
|
self._build_channels(16)
|
||||||
|
|
||||||
|
def _build_channels(self, count: int) -> None:
|
||||||
|
for i in range(count):
|
||||||
|
strip = ChannelStrip(i)
|
||||||
|
strip.fader.on_value_changed = self._on_channel_fader_change
|
||||||
|
strip.pan_knob.on_value_changed = self._on_channel_pan_change
|
||||||
|
strip.mute_btn.bind(on_press=self._make_mute_callback(i))
|
||||||
|
strip.solo_btn.bind(on_press=self._make_solo_callback(i))
|
||||||
|
self._strip_container.add_widget(strip)
|
||||||
|
self.strips.append(strip)
|
||||||
|
|
||||||
|
def on_enter(self, *args):
|
||||||
|
"""Start polling when screen becomes active."""
|
||||||
|
self._refresh_state()
|
||||||
|
self._refresh_event = Clock.schedule_interval(
|
||||||
|
lambda dt: self._refresh_state(), 0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_leave(self, *args):
|
||||||
|
"""Stop polling when leaving screen."""
|
||||||
|
if self._refresh_event:
|
||||||
|
self._refresh_event.cancel()
|
||||||
|
self._refresh_event = None
|
||||||
|
|
||||||
|
def _refresh_state(self) -> None:
|
||||||
|
"""Fetch full mixer state and update all widgets."""
|
||||||
|
if not self.client:
|
||||||
|
return
|
||||||
|
self.client.fetch_state(
|
||||||
|
on_success=self._on_state_received,
|
||||||
|
on_error=lambda e: None, # silently ignore temporary errors
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_state_received(self, state: dict) -> None:
|
||||||
|
"""Update all strips from full state."""
|
||||||
|
channels = state.get("channels", [])
|
||||||
|
for i, ch_data in enumerate(channels):
|
||||||
|
if i < len(self.strips):
|
||||||
|
self.strips[i].update_state(ch_data)
|
||||||
|
|
||||||
|
# Master
|
||||||
|
master = state.get("master", {})
|
||||||
|
self._master_fader.value = master.get("volume_db", 0.0)
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_channel_fader_change(self, channel: int, value: float):
|
||||||
|
if self.client:
|
||||||
|
self.client.set_parameter("volume", value, channel=channel)
|
||||||
|
|
||||||
|
def _on_channel_pan_change(self, param_name: str, value: float):
|
||||||
|
# param_name is 'pan' — need channel context
|
||||||
|
# Handled via the strip's pan_knob callback
|
||||||
|
if self.client:
|
||||||
|
# Find which strip this knob belongs to
|
||||||
|
for i, strip in enumerate(self.strips):
|
||||||
|
if strip.pan_knob.parameter_name == param_name:
|
||||||
|
self.client.set_parameter("pan", value, channel=i)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _make_mute_callback(self, ch: int):
|
||||||
|
def cb(instance):
|
||||||
|
strip = self.strips[ch]
|
||||||
|
if self.client:
|
||||||
|
self.client.set_parameter("mute", 1.0 if strip.muted else 0.0, channel=ch)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def _make_solo_callback(self, ch: int):
|
||||||
|
def cb(instance):
|
||||||
|
strip = self.strips[ch]
|
||||||
|
if self.client:
|
||||||
|
self.client.set_parameter("solo", 1.0 if strip.soloed else 0.0, channel=ch)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def _on_master_fader_change(self, channel: int, value: float):
|
||||||
|
if self.client:
|
||||||
|
self.client.set_master_parameter("master_volume", value)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Plugin chain screen — per-channel plugin list with bypass.
|
||||||
|
|
||||||
|
Shows each channel's plugin chain: Gate → EQ → Compressor (or
|
||||||
|
custom chains like NAM → IR for guitar channels). Each plugin
|
||||||
|
slot shows name, bypass toggle, and tap-for-detail.
|
||||||
|
|
||||||
|
Touch-optimized: large slots, swipe between channels, clear
|
||||||
|
bypass indicators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.scrollview import ScrollView
|
||||||
|
from kivy.uix.screenmanager import Screen
|
||||||
|
from kivy.metrics import dp
|
||||||
|
|
||||||
|
from ..theme import Colors, Fonts, Sizes
|
||||||
|
|
||||||
|
|
||||||
|
class PluginSlot(BoxLayout):
|
||||||
|
"""One plugin in the chain — name, bypass toggle, tap to edit."""
|
||||||
|
|
||||||
|
def __init__(self, plugin_id: int, name: str, role: str, active: bool = True, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.orientation = "horizontal"
|
||||||
|
self.size_hint = (1, None)
|
||||||
|
self.height = Sizes.PLUGIN_SLOT_HEIGHT
|
||||||
|
self.spacing = Sizes.PAD_SM
|
||||||
|
self.padding = [Sizes.PAD_SM, Sizes.PAD_XS]
|
||||||
|
self.plugin_id = plugin_id
|
||||||
|
self.role = role
|
||||||
|
self._active = active
|
||||||
|
|
||||||
|
# Role indicator (colored strip)
|
||||||
|
role_colors = {
|
||||||
|
"gate": (0.4, 0.5, 1.0, 1),
|
||||||
|
"eq": (0.3, 0.9, 0.6, 1),
|
||||||
|
"comp": (1.0, 0.5, 0.3, 1),
|
||||||
|
"gain": (0.4, 0.8, 0.8, 1),
|
||||||
|
"nam": (1.0, 0.3, 0.8, 1),
|
||||||
|
"ir": (0.8, 0.5, 1.0, 1),
|
||||||
|
"reverb": (0.5, 0.7, 1.0, 1),
|
||||||
|
"delay": (0.5, 0.5, 1.0, 1),
|
||||||
|
"limiter": (1.0, 0.3, 0.3, 1),
|
||||||
|
}
|
||||||
|
self._role_color = role_colors.get(role, (0.5, 0.5, 0.5, 1))
|
||||||
|
|
||||||
|
# Role indicator
|
||||||
|
self._indicator = Label(
|
||||||
|
text=role.upper()[:3],
|
||||||
|
color=(0, 0, 0, 1),
|
||||||
|
font_size=Fonts.TINY,
|
||||||
|
bold=True,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=dp(32),
|
||||||
|
halign="center",
|
||||||
|
)
|
||||||
|
self._indicator.bind(size=self._indicator.setter("text_size"))
|
||||||
|
self._update_indicator()
|
||||||
|
|
||||||
|
# Name
|
||||||
|
self._name_label = Label(
|
||||||
|
text=name,
|
||||||
|
color=Colors.TEXT_PRIMARY,
|
||||||
|
font_size=Fonts.BODY,
|
||||||
|
size_hint=(1, 1),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
self._name_label.bind(size=self._name_label.setter("text_size"))
|
||||||
|
|
||||||
|
# Bypass button
|
||||||
|
self._bypass_btn = Button(
|
||||||
|
text="ON",
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=dp(48),
|
||||||
|
background_normal="",
|
||||||
|
background_color=Colors.ACTIVE,
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
bold=True,
|
||||||
|
)
|
||||||
|
self._bypass_btn.bind(on_press=self._toggle_bypass)
|
||||||
|
|
||||||
|
self.add_widget(self._indicator)
|
||||||
|
self.add_widget(self._name_label)
|
||||||
|
self.add_widget(self._bypass_btn)
|
||||||
|
|
||||||
|
def _update_indicator(self):
|
||||||
|
if self._active:
|
||||||
|
self._indicator.color = (1, 1, 1, 1)
|
||||||
|
self._indicator.canvas.before.clear()
|
||||||
|
else:
|
||||||
|
self._indicator.color = Colors.TEXT_MUTED
|
||||||
|
|
||||||
|
def _toggle_bypass(self, instance):
|
||||||
|
self._active = not self._active
|
||||||
|
if self._active:
|
||||||
|
self._bypass_btn.text = "ON"
|
||||||
|
self._bypass_btn.background_color = Colors.ACTIVE
|
||||||
|
else:
|
||||||
|
self._bypass_btn.text = "BYP"
|
||||||
|
self._bypass_btn.background_color = Colors.BYPASS_ON
|
||||||
|
self._update_indicator()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self._active
|
||||||
|
|
||||||
|
@active.setter
|
||||||
|
def active(self, value: bool):
|
||||||
|
self._active = value
|
||||||
|
if value:
|
||||||
|
self._bypass_btn.text = "ON"
|
||||||
|
self._bypass_btn.background_color = Colors.ACTIVE
|
||||||
|
else:
|
||||||
|
self._bypass_btn.text = "BYP"
|
||||||
|
self._bypass_btn.background_color = Colors.BYPASS_ON
|
||||||
|
self._update_indicator()
|
||||||
|
|
||||||
|
|
||||||
|
class PluginScreen(Screen):
|
||||||
|
"""Plugin chain view — scrollable list of channels with plugins."""
|
||||||
|
|
||||||
|
client = ObjectProperty(None)
|
||||||
|
_refresh_event = None
|
||||||
|
_channel_strips: dict[int, list[PluginSlot]] = {}
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.name = "plugins"
|
||||||
|
|
||||||
|
root = BoxLayout(orientation="vertical")
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.HEADER_HEIGHT,
|
||||||
|
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||||
|
)
|
||||||
|
header.bg_color = Colors.BG_HEADER
|
||||||
|
title = Label(
|
||||||
|
text="PLUGINS",
|
||||||
|
color=Colors.ACCENT,
|
||||||
|
font_size=Fonts.HEADER,
|
||||||
|
bold=True,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=dp(150),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
title.bind(size=title.setter("text_size"))
|
||||||
|
header.add_widget(title)
|
||||||
|
|
||||||
|
# Scrollable plugin list
|
||||||
|
self._scroll = ScrollView(
|
||||||
|
size_hint=(1, 1),
|
||||||
|
do_scroll_x=False,
|
||||||
|
do_scroll_y=True,
|
||||||
|
bar_width=dp(4),
|
||||||
|
)
|
||||||
|
self._container = BoxLayout(
|
||||||
|
orientation="vertical",
|
||||||
|
size_hint=(1, None),
|
||||||
|
spacing=Sizes.PLUGIN_SLOT_GAP,
|
||||||
|
padding=[Sizes.PAD_SM, Sizes.PAD_SM],
|
||||||
|
)
|
||||||
|
self._container.bind(minimum_height=self._container.setter("height"))
|
||||||
|
self._scroll.add_widget(self._container)
|
||||||
|
|
||||||
|
root.add_widget(header)
|
||||||
|
root.add_widget(self._scroll)
|
||||||
|
self.add_widget(root)
|
||||||
|
|
||||||
|
def on_enter(self, *args):
|
||||||
|
self._refresh_event = Clock.schedule_interval(
|
||||||
|
lambda dt: self._fetch_plugins(), 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_leave(self, *args):
|
||||||
|
if self._refresh_event:
|
||||||
|
self._refresh_event.cancel()
|
||||||
|
self._refresh_event = None
|
||||||
|
|
||||||
|
def _fetch_plugins(self) -> None:
|
||||||
|
if not self.client:
|
||||||
|
return
|
||||||
|
self.client.fetch_plugins(
|
||||||
|
on_success=self._on_plugins_received,
|
||||||
|
on_error=lambda e: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_plugins_received(self, plugins: list) -> None:
|
||||||
|
"""Build plugin slots from plugin list."""
|
||||||
|
self._container.clear_widgets()
|
||||||
|
self._channel_strips.clear()
|
||||||
|
|
||||||
|
# Group plugins by channel
|
||||||
|
by_channel: dict[int, list[dict]] = {}
|
||||||
|
for p in plugins:
|
||||||
|
ch = p.get("channel", -1)
|
||||||
|
by_channel.setdefault(ch, []).append(p)
|
||||||
|
|
||||||
|
for ch_index in sorted(by_channel.keys()):
|
||||||
|
ch_name = f"Channel {ch_index + 1}" if ch_index >= 0 else "Master/Global"
|
||||||
|
ch_label = Label(
|
||||||
|
text=f"── {ch_name} ──",
|
||||||
|
color=Colors.TEXT_MUTED,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(24),
|
||||||
|
halign="center",
|
||||||
|
)
|
||||||
|
ch_label.bind(size=ch_label.setter("text_size"))
|
||||||
|
self._container.add_widget(ch_label)
|
||||||
|
|
||||||
|
slots = []
|
||||||
|
for p in by_channel[ch_index]:
|
||||||
|
slot = PluginSlot(
|
||||||
|
plugin_id=p.get("plugin_id", 0),
|
||||||
|
name=p.get("name", f"Plugin {p.get('plugin_id', '?')}"),
|
||||||
|
role=p.get("role", "unknown"),
|
||||||
|
active=p.get("active", True),
|
||||||
|
)
|
||||||
|
slots.append(slot)
|
||||||
|
self._container.add_widget(slot)
|
||||||
|
|
||||||
|
self._channel_strips[ch_index] = slots
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Routing matrix screen — visual patchbay for audio routing.
|
||||||
|
|
||||||
|
Shows a grid of sources (rows) vs destinations (columns) with
|
||||||
|
toggle buttons for each connection. Supports scrolling for
|
||||||
|
large matrices.
|
||||||
|
|
||||||
|
Touch-optimized: large grid cells, pinch-zoom for dense matrices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.gridlayout import GridLayout
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.scrollview import ScrollView
|
||||||
|
from kivy.uix.screenmanager import Screen
|
||||||
|
from kivy.metrics import dp
|
||||||
|
|
||||||
|
from ..theme import Colors, Fonts, Sizes
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingCell(Button):
|
||||||
|
"""A single routing grid cell — tap to toggle connection."""
|
||||||
|
|
||||||
|
def __init__(self, source: str, dest: str, active: bool = False, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.source = source
|
||||||
|
self.dest = dest
|
||||||
|
self.is_active = active
|
||||||
|
self.background_normal = ""
|
||||||
|
self._update_color()
|
||||||
|
|
||||||
|
def _update_color(self):
|
||||||
|
self.background_color = Colors.ROUTE_ACTIVE if self.is_active else Colors.ROUTE_INACTIVE
|
||||||
|
|
||||||
|
def on_press(self):
|
||||||
|
self.is_active = not self.is_active
|
||||||
|
self._update_color()
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingScreen(Screen):
|
||||||
|
"""Routing matrix with scrollable grid.
|
||||||
|
|
||||||
|
Sources on left (rows), destinations on top (columns).
|
||||||
|
Each cell toggles a connection between source→dest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client = ObjectProperty(None)
|
||||||
|
_cells: dict[tuple[str, str], RoutingCell] = {}
|
||||||
|
_refresh_event = None
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.name = "routing"
|
||||||
|
|
||||||
|
root = BoxLayout(orientation="vertical")
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.HEADER_HEIGHT,
|
||||||
|
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||||
|
)
|
||||||
|
header.bg_color = Colors.BG_HEADER
|
||||||
|
title = Label(
|
||||||
|
text="ROUTING",
|
||||||
|
color=Colors.ACCENT,
|
||||||
|
font_size=Fonts.HEADER,
|
||||||
|
bold=True,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=dp(140),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
title.bind(size=title.setter("text_size"))
|
||||||
|
header.add_widget(title)
|
||||||
|
|
||||||
|
# Grid container
|
||||||
|
self._grid_container = BoxLayout(orientation="vertical", padding=[Sizes.PAD_SM])
|
||||||
|
self._grid_header = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.ROUTE_LABEL_HEIGHT,
|
||||||
|
)
|
||||||
|
self._grid_body_scroll = ScrollView(
|
||||||
|
size_hint=(1, 1),
|
||||||
|
do_scroll_x=True,
|
||||||
|
do_scroll_y=True,
|
||||||
|
bar_width=dp(4),
|
||||||
|
)
|
||||||
|
self._grid_body = GridLayout(
|
||||||
|
cols=1,
|
||||||
|
size_hint=(None, None),
|
||||||
|
spacing=Sizes.CELL_GAP,
|
||||||
|
)
|
||||||
|
self._grid_body.bind(minimum_height=self._grid_body.setter("height"))
|
||||||
|
self._grid_body.bind(minimum_width=self._grid_body.setter("width"))
|
||||||
|
self._grid_body_scroll.add_widget(self._grid_body)
|
||||||
|
|
||||||
|
self._grid_container.add_widget(self._grid_header)
|
||||||
|
self._grid_container.add_widget(self._grid_body_scroll)
|
||||||
|
|
||||||
|
root.add_widget(header)
|
||||||
|
root.add_widget(self._grid_container)
|
||||||
|
self.add_widget(root)
|
||||||
|
|
||||||
|
self._default_sources = [
|
||||||
|
"Ch1 In", "Ch2 In", "Ch3 In", "Ch4 In",
|
||||||
|
"Ch5 In", "Ch6 In", "Ch7 In", "Ch8 In",
|
||||||
|
"USB 1/2", "USB 3/4", "USB 5/6", "USB 7/8",
|
||||||
|
]
|
||||||
|
self._default_dests = [
|
||||||
|
"Master L/R", "Aux A", "Aux B", "Sub 1", "Sub 2",
|
||||||
|
"Monitor L/R",
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_enter(self, *args):
|
||||||
|
self._build_grid(self._default_sources, self._default_dests)
|
||||||
|
self._refresh_event = Clock.schedule_interval(
|
||||||
|
lambda dt: self._fetch_routing(), 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_leave(self, *args):
|
||||||
|
if self._refresh_event:
|
||||||
|
self._refresh_event.cancel()
|
||||||
|
self._refresh_event = None
|
||||||
|
|
||||||
|
def _build_grid(self, sources: list[str], dests: list[str]) -> None:
|
||||||
|
"""Build the routing grid from source/destination lists."""
|
||||||
|
self._grid_header.clear_widgets()
|
||||||
|
self._grid_body.clear_widgets()
|
||||||
|
self._cells.clear()
|
||||||
|
|
||||||
|
# Header row: empty corner cell + destination labels
|
||||||
|
corner = Label(
|
||||||
|
text="SRC \\ DST",
|
||||||
|
color=Colors.TEXT_MUTED,
|
||||||
|
font_size=Fonts.TINY,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=Sizes.ROUTE_LABEL_WIDTH,
|
||||||
|
)
|
||||||
|
self._grid_header.add_widget(corner)
|
||||||
|
for dest in dests:
|
||||||
|
lbl = Label(
|
||||||
|
text=dest,
|
||||||
|
color=Colors.TEXT_SECONDARY,
|
||||||
|
font_size=Fonts.TINY,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=Sizes.CELL_SIZE,
|
||||||
|
halign="center",
|
||||||
|
)
|
||||||
|
lbl.bind(size=lbl.setter("text_size"))
|
||||||
|
self._grid_header.add_widget(lbl)
|
||||||
|
|
||||||
|
# Body: one row per source
|
||||||
|
self._grid_body.cols = len(dests) + 1 # +1 for label column
|
||||||
|
for src in sources:
|
||||||
|
# Source label
|
||||||
|
row_label = Label(
|
||||||
|
text=src,
|
||||||
|
color=Colors.TEXT_SECONDARY,
|
||||||
|
font_size=Fonts.TINY,
|
||||||
|
size_hint=(None, None),
|
||||||
|
size=(Sizes.ROUTE_LABEL_WIDTH, Sizes.ROUTE_LABEL_HEIGHT),
|
||||||
|
halign="right",
|
||||||
|
)
|
||||||
|
row_label.bind(size=row_label.setter("text_size"))
|
||||||
|
self._grid_body.add_widget(row_label)
|
||||||
|
|
||||||
|
for dest in dests:
|
||||||
|
cell = RoutingCell(
|
||||||
|
source=src,
|
||||||
|
dest=dest,
|
||||||
|
active=False,
|
||||||
|
size_hint=(None, None),
|
||||||
|
size=(Sizes.CELL_SIZE, Sizes.CELL_SIZE),
|
||||||
|
)
|
||||||
|
cell.bind(on_press=self._make_cell_callback(src, dest))
|
||||||
|
self._cells[(src, dest)] = cell
|
||||||
|
self._grid_body.add_widget(cell)
|
||||||
|
|
||||||
|
def _make_cell_callback(self, src: str, dest: str):
|
||||||
|
def cb(instance):
|
||||||
|
if self.client:
|
||||||
|
# Send routing change via REST
|
||||||
|
self.client.set_parameter(
|
||||||
|
"routing",
|
||||||
|
1.0 if instance.is_active else 0.0,
|
||||||
|
channel=-1,
|
||||||
|
)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def _fetch_routing(self) -> None:
|
||||||
|
if not self.client:
|
||||||
|
return
|
||||||
|
self.client.fetch_routing(
|
||||||
|
on_success=self._on_routing_received,
|
||||||
|
on_error=lambda e: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_routing_received(self, routing: dict) -> None:
|
||||||
|
"""Update cell states from routing data."""
|
||||||
|
edges = routing.get("edges", [])
|
||||||
|
# Reset all cells
|
||||||
|
for cell in self._cells.values():
|
||||||
|
cell.is_active = False
|
||||||
|
cell._update_color()
|
||||||
|
|
||||||
|
# Activate connected edges
|
||||||
|
for edge in edges:
|
||||||
|
src = edge.get("source", "")
|
||||||
|
dest = edge.get("dest", "")
|
||||||
|
is_active = edge.get("is_active", True)
|
||||||
|
key = (src, dest)
|
||||||
|
if key in self._cells:
|
||||||
|
self._cells[key].is_active = is_active
|
||||||
|
self._cells[key]._update_color()
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Settings screen — brightness, screen timeout, DPI config.
|
||||||
|
|
||||||
|
Controls for the RPi display backlight, screen dim timeout,
|
||||||
|
and DPI scaling override.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.screenmanager import Screen
|
||||||
|
from kivy.uix.slider import Slider
|
||||||
|
from kivy.metrics import dp
|
||||||
|
|
||||||
|
from ..theme import Colors, Fonts, Sizes
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsScreen(Screen):
|
||||||
|
"""Display and system settings."""
|
||||||
|
|
||||||
|
client = ObjectProperty(None)
|
||||||
|
brightness = 100
|
||||||
|
timeout = 0 # seconds, 0 = never
|
||||||
|
dpi_scale = 1.0
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.name = "settings"
|
||||||
|
|
||||||
|
root = BoxLayout(orientation="vertical")
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.HEADER_HEIGHT,
|
||||||
|
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||||
|
)
|
||||||
|
header.bg_color = Colors.BG_HEADER
|
||||||
|
title = Label(
|
||||||
|
text="SETTINGS",
|
||||||
|
color=Colors.ACCENT,
|
||||||
|
font_size=Fonts.HEADER,
|
||||||
|
bold=True,
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=dp(150),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
title.bind(size=title.setter("text_size"))
|
||||||
|
header.add_widget(title)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
content = BoxLayout(
|
||||||
|
orientation="vertical",
|
||||||
|
spacing=Sizes.PAD_LG,
|
||||||
|
padding=[Sizes.PAD_LG, Sizes.PAD_LG],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Brightness ──
|
||||||
|
brightness_section = BoxLayout(
|
||||||
|
orientation="vertical",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(80),
|
||||||
|
spacing=Sizes.PAD_XS,
|
||||||
|
)
|
||||||
|
bright_label = Label(
|
||||||
|
text="Brightness: 100%",
|
||||||
|
color=Colors.TEXT_PRIMARY,
|
||||||
|
font_size=Fonts.BODY,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(24),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
bright_label.bind(size=bright_label.setter("text_size"))
|
||||||
|
|
||||||
|
self._bright_slider = Slider(
|
||||||
|
min=10,
|
||||||
|
max=100,
|
||||||
|
value=100,
|
||||||
|
step=5,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(40),
|
||||||
|
cursor_size=(dp(28), dp(28)),
|
||||||
|
)
|
||||||
|
self._bright_slider.bind(value=self._on_brightness_change)
|
||||||
|
self._bright_label = bright_label
|
||||||
|
|
||||||
|
brightness_section.add_widget(bright_label)
|
||||||
|
brightness_section.add_widget(self._bright_slider)
|
||||||
|
|
||||||
|
# ── Screen timeout ──
|
||||||
|
timeout_section = BoxLayout(
|
||||||
|
orientation="vertical",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(80),
|
||||||
|
spacing=Sizes.PAD_XS,
|
||||||
|
)
|
||||||
|
self._timeout_label = Label(
|
||||||
|
text="Screen Timeout: Never",
|
||||||
|
color=Colors.TEXT_PRIMARY,
|
||||||
|
font_size=Fonts.BODY,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(24),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
self._timeout_label.bind(size=self._timeout_label.setter("text_size"))
|
||||||
|
|
||||||
|
timeout_buttons = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.BUTTON_HEIGHT,
|
||||||
|
spacing=Sizes.PAD_SM,
|
||||||
|
)
|
||||||
|
timeouts = [
|
||||||
|
("30s", 30),
|
||||||
|
("1m", 60),
|
||||||
|
("5m", 300),
|
||||||
|
("15m", 900),
|
||||||
|
("Never", 0),
|
||||||
|
]
|
||||||
|
for label, secs in timeouts:
|
||||||
|
btn = Button(
|
||||||
|
text=label,
|
||||||
|
background_normal="",
|
||||||
|
background_color=(0.15, 0.15, 0.22, 1),
|
||||||
|
color=Colors.ACCENT if secs == 0 else Colors.TEXT_SECONDARY,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
)
|
||||||
|
btn.bind(on_press=self._make_timeout_callback(secs))
|
||||||
|
timeout_buttons.add_widget(btn)
|
||||||
|
|
||||||
|
timeout_section.add_widget(self._timeout_label)
|
||||||
|
timeout_section.add_widget(timeout_buttons)
|
||||||
|
|
||||||
|
# ── DPI scale override ──
|
||||||
|
dpi_section = BoxLayout(
|
||||||
|
orientation="vertical",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(80),
|
||||||
|
spacing=Sizes.PAD_XS,
|
||||||
|
)
|
||||||
|
self._dpi_label = Label(
|
||||||
|
text="UI Scale: 1.0x (auto)",
|
||||||
|
color=Colors.TEXT_PRIMARY,
|
||||||
|
font_size=Fonts.BODY,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(24),
|
||||||
|
halign="left",
|
||||||
|
)
|
||||||
|
self._dpi_label.bind(size=self._dpi_label.setter("text_size"))
|
||||||
|
|
||||||
|
dpi_buttons = BoxLayout(
|
||||||
|
orientation="horizontal",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=Sizes.BUTTON_HEIGHT,
|
||||||
|
spacing=Sizes.PAD_SM,
|
||||||
|
)
|
||||||
|
scales = [("0.75x", 0.75), ("1.0x", 1.0), ("1.25x", 1.25), ("1.5x", 1.5)]
|
||||||
|
for label, scale in scales:
|
||||||
|
btn = Button(
|
||||||
|
text=label,
|
||||||
|
background_normal="",
|
||||||
|
background_color=(0.15, 0.15, 0.22, 1),
|
||||||
|
color=Colors.ACCENT if scale == 1.0 else Colors.TEXT_SECONDARY,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
)
|
||||||
|
btn.bind(on_press=self._make_dpi_callback(scale))
|
||||||
|
dpi_buttons.add_widget(btn)
|
||||||
|
|
||||||
|
dpi_section.add_widget(self._dpi_label)
|
||||||
|
dpi_section.add_widget(dpi_buttons)
|
||||||
|
|
||||||
|
# ── System info ──
|
||||||
|
info_section = BoxLayout(
|
||||||
|
orientation="vertical",
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(60),
|
||||||
|
spacing=Sizes.PAD_XS,
|
||||||
|
)
|
||||||
|
info_label = Label(
|
||||||
|
text="Raspberry Pi 4B | Kivy 2.3\nMixer Engine v1.0",
|
||||||
|
color=Colors.TEXT_MUTED,
|
||||||
|
font_size=Fonts.SMALL,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=dp(36),
|
||||||
|
halign="center",
|
||||||
|
)
|
||||||
|
info_label.bind(size=info_label.setter("text_size"))
|
||||||
|
info_section.add_widget(info_label)
|
||||||
|
|
||||||
|
content.add_widget(brightness_section)
|
||||||
|
content.add_widget(timeout_section)
|
||||||
|
content.add_widget(dpi_section)
|
||||||
|
content.add_widget(info_section)
|
||||||
|
|
||||||
|
root.add_widget(header)
|
||||||
|
root.add_widget(content)
|
||||||
|
self.add_widget(root)
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_brightness_change(self, instance, value):
|
||||||
|
self.brightness = int(value)
|
||||||
|
self._bright_label.text = f"Brightness: {self.brightness}%"
|
||||||
|
# Set actual backlight
|
||||||
|
self._set_backlight(self.brightness)
|
||||||
|
|
||||||
|
def _make_timeout_callback(self, secs: int):
|
||||||
|
def cb(instance):
|
||||||
|
self.timeout = secs
|
||||||
|
if secs == 0:
|
||||||
|
self._timeout_label.text = "Screen Timeout: Never"
|
||||||
|
elif secs < 60:
|
||||||
|
self._timeout_label.text = f"Screen Timeout: {secs}s"
|
||||||
|
else:
|
||||||
|
self._timeout_label.text = f"Screen Timeout: {secs // 60}m"
|
||||||
|
self._set_screen_timeout(secs)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def _make_dpi_callback(self, scale: float):
|
||||||
|
def cb(instance):
|
||||||
|
self.dpi_scale = scale
|
||||||
|
self._dpi_label.text = f"UI Scale: {scale}x"
|
||||||
|
# In production, this would reconfigure Kivy's DPI
|
||||||
|
# For now, informational
|
||||||
|
return cb
|
||||||
|
|
||||||
|
# ── Backlight control ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_backlight(self, percent: int) -> None:
|
||||||
|
"""Set the display backlight brightness.
|
||||||
|
|
||||||
|
On Raspberry Pi, writes to /sys/class/backlight/*/brightness.
|
||||||
|
This is a no-op on non-RPi systems.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
backlight_dirs = glob.glob("/sys/class/backlight/*/brightness")
|
||||||
|
if backlight_dirs:
|
||||||
|
max_file = backlight_dirs[0].replace("brightness", "max_brightness")
|
||||||
|
with open(max_file) as f:
|
||||||
|
max_bright = int(f.read().strip())
|
||||||
|
value = int(percent / 100.0 * max_bright)
|
||||||
|
with open(backlight_dirs[0], "w") as f:
|
||||||
|
f.write(str(value))
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass # Non-root or non-RPi — silently skip
|
||||||
|
|
||||||
|
def _set_screen_timeout(self, seconds: int) -> None:
|
||||||
|
"""Set screen blanking timeout.
|
||||||
|
|
||||||
|
Uses xset on X11, or writes to console blank sysfs on framebuffer.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try X11
|
||||||
|
subprocess.run(
|
||||||
|
["xset", "dpms", str(seconds), str(seconds), str(seconds)],
|
||||||
|
capture_output=True, timeout=2,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also try console blanking
|
||||||
|
try:
|
||||||
|
with open("/sys/module/kernel/parameters/consoleblank", "w") as f:
|
||||||
|
f.write(str(seconds))
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
"""DPI-aware theme for the touchscreen mixer UI.
|
||||||
|
|
||||||
|
Dark stage/studio palette designed for small touch displays.
|
||||||
|
All sizes in dp (density-independent pixels) so the UI
|
||||||
|
auto-scales across 5" (720×720) and 7" (800×480) displays.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.ui.theme import Colors, Fonts, Sizes, dp, sp
|
||||||
|
"""
|
||||||
|
|
||||||
|
from kivy.metrics import dp, sp # noqa: F401 — re-exported
|
||||||
|
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
"""Dark stage/studio palette."""
|
||||||
|
|
||||||
|
# Backgrounds
|
||||||
|
BG_DARK = (0.10, 0.10, 0.18, 1) # #1a1a2e — deepest bg
|
||||||
|
BG_SURFACE = (0.13, 0.13, 0.24, 1) # #21213d — cards/panels
|
||||||
|
BG_HEADER = (0.08, 0.08, 0.14, 1) # #141424 — top bar
|
||||||
|
|
||||||
|
# Accent
|
||||||
|
ACCENT = (1.0, 0.42, 0.21, 1) # #ff6b35 — orange
|
||||||
|
ACCENT_DIM = (0.7, 0.29, 0.15, 1) # dimmed accent
|
||||||
|
|
||||||
|
# Faders (per channel group — 8 groups of 2)
|
||||||
|
FADER_COLORS = [
|
||||||
|
(0.31, 0.82, 0.78, 1), # teal
|
||||||
|
(0.98, 0.73, 0.27, 1), # amber
|
||||||
|
(0.55, 0.67, 1.0, 1), # blue
|
||||||
|
(0.56, 0.93, 0.56, 1), # green
|
||||||
|
(0.93, 0.51, 0.93, 1), # orchid
|
||||||
|
(1.0, 0.65, 0.0, 1), # orange
|
||||||
|
(0.40, 0.80, 1.0, 1), # sky blue
|
||||||
|
(1.0, 0.41, 0.71, 1), # hot pink
|
||||||
|
]
|
||||||
|
|
||||||
|
# Meter
|
||||||
|
METER_GREEN = (0.0, 0.9, 0.3, 1)
|
||||||
|
METER_YELLOW = (1.0, 0.84, 0.0, 1)
|
||||||
|
METER_RED = (1.0, 0.2, 0.2, 1)
|
||||||
|
|
||||||
|
# Text
|
||||||
|
TEXT_PRIMARY = (0.93, 0.93, 0.93, 1) # #eee — main text
|
||||||
|
TEXT_SECONDARY = (0.6, 0.6, 0.7, 1) # dim text
|
||||||
|
TEXT_MUTED = (0.35, 0.35, 0.45, 1) # very dim
|
||||||
|
|
||||||
|
# States
|
||||||
|
MUTE_ON = (1.0, 0.2, 0.2, 1) # red when muted
|
||||||
|
SOLO_ON = (1.0, 0.84, 0.0, 1) # yellow when soloed
|
||||||
|
BYPASS_ON = (0.5, 0.5, 0.5, 1) # dim when bypassed
|
||||||
|
ACTIVE = (0.0, 0.8, 0.4, 1) # green active indicator
|
||||||
|
|
||||||
|
# Routing
|
||||||
|
ROUTE_ACTIVE = (0.0, 0.7, 0.9, 1) # cyan for active routes
|
||||||
|
ROUTE_INACTIVE = (0.15, 0.15, 0.25, 1) # dim for inactive
|
||||||
|
|
||||||
|
|
||||||
|
class Fonts:
|
||||||
|
"""Font sizes in sp (scale-independent pixels)."""
|
||||||
|
|
||||||
|
HEADER = sp(16)
|
||||||
|
TITLE = sp(14)
|
||||||
|
BODY = sp(12)
|
||||||
|
SMALL = sp(10)
|
||||||
|
TINY = sp(8)
|
||||||
|
METER = sp(7)
|
||||||
|
|
||||||
|
|
||||||
|
class Sizes:
|
||||||
|
"""UI element sizes in dp."""
|
||||||
|
|
||||||
|
# Screen
|
||||||
|
HEADER_HEIGHT = dp(44)
|
||||||
|
TAB_BAR_HEIGHT = dp(48)
|
||||||
|
|
||||||
|
# Fader
|
||||||
|
FADER_WIDTH = dp(52)
|
||||||
|
FADER_HEIGHT = dp(220)
|
||||||
|
FADER_THUMB_SIZE = dp(40)
|
||||||
|
FADER_TRACK_WIDTH = dp(8)
|
||||||
|
|
||||||
|
# Meter
|
||||||
|
METER_WIDTH = dp(10)
|
||||||
|
METER_HEIGHT = dp(160)
|
||||||
|
|
||||||
|
# Knob
|
||||||
|
KNOB_SIZE = dp(48)
|
||||||
|
KNOB_TRACK_WIDTH = dp(3)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
BUTTON_HEIGHT = dp(44)
|
||||||
|
BUTTON_MIN_WIDTH = dp(64)
|
||||||
|
SMALL_BUTTON = dp(36)
|
||||||
|
MUTE_SOLO_BUTTON = dp(36)
|
||||||
|
|
||||||
|
# Touch targets (minimum 44dp for usability)
|
||||||
|
TOUCH_MIN = dp(44)
|
||||||
|
|
||||||
|
# Padding
|
||||||
|
PAD_XS = dp(4)
|
||||||
|
PAD_SM = dp(8)
|
||||||
|
PAD_MD = dp(12)
|
||||||
|
PAD_LG = dp(16)
|
||||||
|
PAD_XL = dp(24)
|
||||||
|
|
||||||
|
# Routing matrix
|
||||||
|
CELL_SIZE = dp(36)
|
||||||
|
CELL_GAP = dp(2)
|
||||||
|
ROUTE_LABEL_WIDTH = dp(80)
|
||||||
|
ROUTE_LABEL_HEIGHT = dp(28)
|
||||||
|
|
||||||
|
# Plugin chain
|
||||||
|
PLUGIN_SLOT_HEIGHT = dp(52)
|
||||||
|
PLUGIN_SLOT_GAP = dp(4)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Touchscreen mixer widgets — fader, knob, meter, header."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Vertical fader widget with integrated level meter.
|
||||||
|
|
||||||
|
Touch-optimized: large hit target, precise drag control, visual
|
||||||
|
feedback on touch. Displays dB scale and channel label.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.properties import (
|
||||||
|
NumericProperty, StringProperty, ListProperty, BooleanProperty,
|
||||||
|
ObjectProperty,
|
||||||
|
)
|
||||||
|
from kivy.uix.widget import Widget
|
||||||
|
from kivy.graphics import Color, Rectangle, RoundedRectangle, Line
|
||||||
|
from kivy.metrics import dp
|
||||||
|
|
||||||
|
from ..theme import Colors, Fonts, Sizes
|
||||||
|
|
||||||
|
|
||||||
|
class FaderWidget(Widget):
|
||||||
|
"""Vertical fader with integrated stereo meter.
|
||||||
|
|
||||||
|
Usage in KV:
|
||||||
|
FaderWidget:
|
||||||
|
value: 0.0 # -60 to +12 dB
|
||||||
|
min_val: -60.0
|
||||||
|
max_val: 12.0
|
||||||
|
meter_left: 0.0 # 0.0-1.0
|
||||||
|
meter_right: 0.0
|
||||||
|
label: "CH 1"
|
||||||
|
color: (0.31, 0.82, 0.78, 1)
|
||||||
|
on_value_changed: app.on_fader_change(*args)
|
||||||
|
"""
|
||||||
|
|
||||||
|
value = NumericProperty(0.0) # current value in native units (dB)
|
||||||
|
min_val = NumericProperty(-60.0)
|
||||||
|
max_val = NumericProperty(12.0)
|
||||||
|
meter_left = NumericProperty(0.0) # 0.0-1.0
|
||||||
|
meter_right = NumericProperty(0.0)
|
||||||
|
label = StringProperty("")
|
||||||
|
color = ListProperty([0.31, 0.82, 0.78, 1])
|
||||||
|
mute = BooleanProperty(False)
|
||||||
|
solo = BooleanProperty(False)
|
||||||
|
meter_peak_left = NumericProperty(0.0)
|
||||||
|
meter_peak_right = NumericProperty(0.0)
|
||||||
|
|
||||||
|
# Callback: f(channel_index, value)
|
||||||
|
on_value_changed = ObjectProperty(None)
|
||||||
|
channel_index = NumericProperty(0)
|
||||||
|
|
||||||
|
_dragging = False
|
||||||
|
_meter_decay = 0.95 # peak hold decay per frame
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.size_hint = (None, None)
|
||||||
|
self.size = (Sizes.FADER_WIDTH, Sizes.FADER_HEIGHT)
|
||||||
|
Clock.schedule_interval(self._decay_meters, 0.05)
|
||||||
|
self.bind(
|
||||||
|
value=self._on_value_change,
|
||||||
|
meter_left=self._update_canvas,
|
||||||
|
meter_right=self._update_canvas,
|
||||||
|
mute=self._update_canvas,
|
||||||
|
solo=self._update_canvas,
|
||||||
|
size=self._update_canvas,
|
||||||
|
pos=self._update_canvas,
|
||||||
|
)
|
||||||
|
self._update_canvas()
|
||||||
|
|
||||||
|
# ── Normalized value (0.0–1.0) ─────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalized(self) -> float:
|
||||||
|
if self.max_val <= self.min_val:
|
||||||
|
return 0.0
|
||||||
|
return (self.value - self.min_val) / (self.max_val - self.min_val)
|
||||||
|
|
||||||
|
def value_to_y(self) -> float:
|
||||||
|
"""Map normalized value to y position within the widget."""
|
||||||
|
track_top = self.height - dp(12)
|
||||||
|
track_bottom = dp(40)
|
||||||
|
track_height = track_top - track_bottom
|
||||||
|
return track_bottom + self.normalized * track_height
|
||||||
|
|
||||||
|
def y_to_value(self, y: float) -> float:
|
||||||
|
"""Map y position to native value."""
|
||||||
|
track_top = self.height - dp(12)
|
||||||
|
track_bottom = dp(40)
|
||||||
|
track_height = track_top - track_bottom
|
||||||
|
norm = max(0.0, min(1.0, (y - track_bottom) / track_height))
|
||||||
|
return self.min_val + norm * (self.max_val - self.min_val)
|
||||||
|
|
||||||
|
# ── Touch handling ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_touch_down(self, touch):
|
||||||
|
if not self.collide_point(*touch.pos):
|
||||||
|
return False
|
||||||
|
# Accept touch anywhere within the widget bounds
|
||||||
|
if touch.is_mouse_scrolling:
|
||||||
|
return False
|
||||||
|
touch.grab(self)
|
||||||
|
self._dragging = True
|
||||||
|
self.value = self.y_to_value(touch.y)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_touch_move(self, touch):
|
||||||
|
if touch.grab_current is not self:
|
||||||
|
return False
|
||||||
|
self.value = self.y_to_value(touch.y)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_touch_up(self, touch):
|
||||||
|
if touch.grab_current is not self:
|
||||||
|
return False
|
||||||
|
touch.ungrab(self)
|
||||||
|
self._dragging = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Drawing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _update_canvas(self, *args):
|
||||||
|
self.canvas.clear()
|
||||||
|
w, h = self.size
|
||||||
|
x, y = self.pos
|
||||||
|
|
||||||
|
# Track area
|
||||||
|
track_x = x + (w - Sizes.FADER_TRACK_WIDTH) / 2
|
||||||
|
track_top = y + h - dp(12)
|
||||||
|
track_bottom = y + dp(40)
|
||||||
|
track_height = track_top - track_bottom
|
||||||
|
|
||||||
|
thumb_y = y + self.value_to_y()
|
||||||
|
|
||||||
|
with self.canvas:
|
||||||
|
# ── Track background ──
|
||||||
|
Color(*Colors.BG_SURFACE)
|
||||||
|
RoundedRectangle(
|
||||||
|
pos=(track_x, track_bottom),
|
||||||
|
size=(Sizes.FADER_TRACK_WIDTH, track_height),
|
||||||
|
radius=[dp(4)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Track fill (from bottom to thumb) ──
|
||||||
|
fill_height = thumb_y - track_bottom
|
||||||
|
if fill_height > 0:
|
||||||
|
if self.mute:
|
||||||
|
Color(*Colors.MUTE_ON)
|
||||||
|
elif self.solo:
|
||||||
|
Color(*Colors.SOLO_ON)
|
||||||
|
else:
|
||||||
|
Color(*self.color)
|
||||||
|
RoundedRectangle(
|
||||||
|
pos=(track_x, track_bottom),
|
||||||
|
size=(Sizes.FADER_TRACK_WIDTH, fill_height),
|
||||||
|
radius=[dp(4)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Thumb ──
|
||||||
|
if self.solo:
|
||||||
|
Color(*Colors.SOLO_ON)
|
||||||
|
elif self.mute:
|
||||||
|
Color(*Colors.MUTE_ON)
|
||||||
|
else:
|
||||||
|
Color(*self.color)
|
||||||
|
thumb_hw = Sizes.FADER_THUMB_SIZE / 2
|
||||||
|
RoundedRectangle(
|
||||||
|
pos=(x + w / 2 - thumb_hw, thumb_y - thumb_hw),
|
||||||
|
size=(Sizes.FADER_THUMB_SIZE, Sizes.FADER_THUMB_SIZE),
|
||||||
|
radius=[dp(4)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── dB value label ──
|
||||||
|
Color(*Colors.TEXT_SECONDARY)
|
||||||
|
|
||||||
|
# ── Meter bars (left + right of fader track) ──
|
||||||
|
meter_x_l = track_x - dp(14)
|
||||||
|
meter_x_r = track_x + Sizes.FADER_TRACK_WIDTH + dp(4)
|
||||||
|
meter_w = Sizes.METER_WIDTH
|
||||||
|
self._draw_meter_bar(meter_x_l, track_bottom, meter_w, track_height, self.meter_left)
|
||||||
|
self._draw_meter_bar(meter_x_r, track_bottom, meter_w, track_height, self.meter_right)
|
||||||
|
|
||||||
|
def _draw_meter_bar(self, mx: float, my: float, mw: float, mh: float, level: float):
|
||||||
|
"""Draw one vertical meter bar."""
|
||||||
|
level = max(0.0, min(1.0, level))
|
||||||
|
# Green → yellow at 0.7, → red at 0.9
|
||||||
|
if level < 0.7:
|
||||||
|
Color(*Colors.METER_GREEN)
|
||||||
|
elif level < 0.9:
|
||||||
|
Color(*Colors.METER_YELLOW)
|
||||||
|
else:
|
||||||
|
Color(*Colors.METER_RED)
|
||||||
|
|
||||||
|
fill_h = level * mh
|
||||||
|
# Background
|
||||||
|
Color(0.1, 0.1, 0.15, 1)
|
||||||
|
RoundedRectangle(pos=(mx, my), size=(mw, mh), radius=[dp(2)])
|
||||||
|
# Fill
|
||||||
|
if level < 0.7:
|
||||||
|
Color(*Colors.METER_GREEN)
|
||||||
|
elif level < 0.9:
|
||||||
|
Color(*Colors.METER_YELLOW)
|
||||||
|
else:
|
||||||
|
Color(*Colors.METER_RED)
|
||||||
|
RoundedRectangle(pos=(mx, my), size=(mw, fill_h), radius=[dp(2)])
|
||||||
|
|
||||||
|
# ── Meter decay ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _decay_meters(self, dt):
|
||||||
|
"""Apply peak-hold decay to meters."""
|
||||||
|
self.meter_peak_left = max(self.meter_left, self.meter_peak_left * self._meter_decay)
|
||||||
|
self.meter_peak_right = max(self.meter_right, self.meter_peak_right * self._meter_decay)
|
||||||
|
|
||||||
|
def _on_value_change(self, instance, value):
|
||||||
|
if self.on_value_changed:
|
||||||
|
self.on_value_changed(self.channel_index, value)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Rotary knob widget — for pan, EQ parameters, etc.
|
||||||
|
|
||||||
|
Touch-optimized: large hit target, rotary drag gesture.
|
||||||
|
Displays current value as an arc and numeric label.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from kivy.properties import NumericProperty, StringProperty, ListProperty, ObjectProperty
|
||||||
|
from kivy.uix.widget import Widget
|
||||||
|
from kivy.graphics import Color, Ellipse, Line, PushMatrix, PopMatrix, Rotate
|
||||||
|
from kivy.metrics import dp
|
||||||
|
|
||||||
|
from ..theme import Colors, Fonts, Sizes
|
||||||
|
|
||||||
|
|
||||||
|
class KnobWidget(Widget):
|
||||||
|
"""Rotary knob with arc indicator.
|
||||||
|
|
||||||
|
Usage in KV:
|
||||||
|
KnobWidget:
|
||||||
|
value: 0.0 # current value
|
||||||
|
min_val: -1.0 # L for pan
|
||||||
|
max_val: 1.0 # R for pan
|
||||||
|
default_val: 0.0
|
||||||
|
label: "PAN"
|
||||||
|
on_value_changed: app.on_knob_change(*args)
|
||||||
|
"""
|
||||||
|
|
||||||
|
value = NumericProperty(0.0)
|
||||||
|
min_val = NumericProperty(0.0)
|
||||||
|
max_val = NumericProperty(1.0)
|
||||||
|
default_val = NumericProperty(0.0)
|
||||||
|
label = StringProperty("")
|
||||||
|
color = ListProperty([0.31, 0.82, 0.78, 1])
|
||||||
|
on_value_changed = ObjectProperty(None)
|
||||||
|
parameter_name = StringProperty("") # e.g., "pan", "gain"
|
||||||
|
|
||||||
|
_dragging = False
|
||||||
|
_angle = 0.0 # current angle in degrees
|
||||||
|
|
||||||
|
ARC_START = 225 # degrees — bottom-left
|
||||||
|
ARC_SWEEP = 270 # degrees — bottom-right
|
||||||
|
ARC_MIN = ARC_START
|
||||||
|
ARC_MAX = ARC_START + ARC_SWEEP
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.size_hint = (None, None)
|
||||||
|
self.size = (Sizes.KNOB_SIZE + dp(16), Sizes.KNOB_SIZE + dp(24))
|
||||||
|
self.bind(
|
||||||
|
value=self._update_angle,
|
||||||
|
size=self._draw,
|
||||||
|
pos=self._draw,
|
||||||
|
)
|
||||||
|
self._update_angle()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalized(self) -> float:
|
||||||
|
if self.max_val <= self.min_val:
|
||||||
|
return 0.0
|
||||||
|
return (self.value - self.min_val) / (self.max_val - self.min_val)
|
||||||
|
|
||||||
|
def _update_angle(self, *args):
|
||||||
|
self._angle = self.ARC_MIN + self.normalized * self.ARC_SWEEP
|
||||||
|
self._draw()
|
||||||
|
|
||||||
|
# ── Touch handling ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_touch_down(self, touch):
|
||||||
|
if not self.collide_point(*touch.pos):
|
||||||
|
return False
|
||||||
|
touch.grab(self)
|
||||||
|
self._dragging = True
|
||||||
|
self._drag_start = touch.y
|
||||||
|
self._drag_value = self.value
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_touch_move(self, touch):
|
||||||
|
if touch.grab_current is not self or not self._dragging:
|
||||||
|
return False
|
||||||
|
dy = touch.y - self._drag_start
|
||||||
|
# Sensitivity: 200dp of vertical drag = full range
|
||||||
|
sensitivity = dp(200)
|
||||||
|
delta = (dy / sensitivity) * (self.max_val - self.min_val)
|
||||||
|
self.value = max(self.min_val, min(self.max_val, self._drag_value + delta))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_touch_up(self, touch):
|
||||||
|
if touch.grab_current is not self:
|
||||||
|
return False
|
||||||
|
touch.ungrab(self)
|
||||||
|
self._dragging = False
|
||||||
|
if self.on_value_changed:
|
||||||
|
self.on_value_changed(self.parameter_name, self.value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Drawing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _draw(self, *args):
|
||||||
|
self.canvas.clear()
|
||||||
|
w, h = self.size
|
||||||
|
cx = self.x + w / 2
|
||||||
|
cy = self.y + h / 2
|
||||||
|
knob_r = Sizes.KNOB_SIZE / 2
|
||||||
|
arc_r = knob_r - dp(4)
|
||||||
|
|
||||||
|
with self.canvas:
|
||||||
|
# ── Arc background ──
|
||||||
|
Color(*Colors.BG_SURFACE)
|
||||||
|
Line(
|
||||||
|
circle=(cx, cy, arc_r, self.ARC_MIN, self.ARC_MAX),
|
||||||
|
width=Sizes.KNOB_TRACK_WIDTH * 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Arc fill ──
|
||||||
|
Color(*self.color)
|
||||||
|
if self.normalized > 0.001:
|
||||||
|
Line(
|
||||||
|
circle=(cx, cy, arc_r, self.ARC_MIN, self._angle),
|
||||||
|
width=Sizes.KNOB_TRACK_WIDTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Knob body ──
|
||||||
|
Color(0.2, 0.2, 0.3, 1)
|
||||||
|
Ellipse(
|
||||||
|
pos=(cx - knob_r + dp(2), cy - knob_r + dp(2)),
|
||||||
|
size=(knob_r * 2 - dp(4), knob_r * 2 - dp(4)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Indicator line ──
|
||||||
|
Color(*self.color)
|
||||||
|
angle_rad = math.radians(self._angle)
|
||||||
|
indicator_len = knob_r * 0.7
|
||||||
|
ix = cx + math.cos(angle_rad) * indicator_len
|
||||||
|
iy = cy + math.sin(angle_rad) * indicator_len
|
||||||
|
Line(points=[cx, cy, ix, iy], width=dp(2))
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Test configuration for the RPi Audio Mixer test suite."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure the project root is in sys.path for imports
|
||||||
|
_project_root = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_project_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_project_root))
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests for API key authentication."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Depends
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.network.auth import (
|
||||||
|
APIKeyAuth,
|
||||||
|
APIKeyHeader,
|
||||||
|
require_api_key,
|
||||||
|
API_KEY_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPIKeyAuth:
|
||||||
|
"""Tests for APIKeyAuth."""
|
||||||
|
|
||||||
|
def test_validate_correct_key(self):
|
||||||
|
auth = APIKeyAuth(keys=["my-secret-key"])
|
||||||
|
assert auth.validate("my-secret-key")
|
||||||
|
|
||||||
|
def test_validate_wrong_key(self):
|
||||||
|
auth = APIKeyAuth(keys=["my-secret-key"])
|
||||||
|
assert not auth.validate("wrong-key")
|
||||||
|
|
||||||
|
def test_validate_empty_key(self):
|
||||||
|
auth = APIKeyAuth(keys=["my-secret-key"])
|
||||||
|
assert not auth.validate("")
|
||||||
|
assert not auth.validate(None)
|
||||||
|
|
||||||
|
def test_multiple_keys(self):
|
||||||
|
auth = APIKeyAuth(keys=["key-a", "key-b", "key-c"])
|
||||||
|
assert auth.validate("key-a")
|
||||||
|
assert auth.validate("key-b")
|
||||||
|
assert auth.validate("key-c")
|
||||||
|
assert not auth.validate("key-d")
|
||||||
|
assert auth.get_keys_count() == 3
|
||||||
|
|
||||||
|
def test_empty_keys_list(self):
|
||||||
|
auth = APIKeyAuth(keys=[])
|
||||||
|
assert not auth.validate("anything")
|
||||||
|
assert auth.get_keys_count() == 0
|
||||||
|
|
||||||
|
def test_key_with_whitespace(self):
|
||||||
|
auth = APIKeyAuth(keys=[" key-with-spaces "])
|
||||||
|
assert auth.validate("key-with-spaces")
|
||||||
|
|
||||||
|
def test_constant_time_comparison(self):
|
||||||
|
"""Ensure timing doesn't leak key info (basic check)."""
|
||||||
|
auth = APIKeyAuth(keys=["a" * 32])
|
||||||
|
# Should not raise
|
||||||
|
for _ in range(10):
|
||||||
|
auth.validate("b" * 32)
|
||||||
|
auth.validate("a" * 32)
|
||||||
|
|
||||||
|
def test_key_from_env(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("MIXER_API_KEY", "env-secret")
|
||||||
|
auth = APIKeyAuth()
|
||||||
|
assert auth.validate("env-secret")
|
||||||
|
assert not auth.validate("wrong")
|
||||||
|
|
||||||
|
def test_multikey_from_env(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("MIXER_API_KEY", "key1, key2,key3")
|
||||||
|
auth = APIKeyAuth()
|
||||||
|
assert auth.validate("key1")
|
||||||
|
assert auth.validate("key2")
|
||||||
|
assert auth.validate("key3")
|
||||||
|
assert auth.get_keys_count() == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequireAPIKey:
|
||||||
|
"""Tests for require_api_key FastAPI dependency."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self):
|
||||||
|
auth = APIKeyAuth(keys=["test-key"])
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected(_: None = Depends(require_api_key(auth))):
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/public")
|
||||||
|
async def public():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_public_endpoint_no_key(self, client):
|
||||||
|
resp = client.get("/public")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
def test_protected_no_key(self, client):
|
||||||
|
resp = client.get("/protected")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Missing" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_protected_wrong_key(self, client):
|
||||||
|
resp = client.get("/protected", headers={API_KEY_HEADER: "wrong"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_protected_correct_key(self, client):
|
||||||
|
resp = client.get("/protected", headers={API_KEY_HEADER: "test-key"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,977 @@
|
|||||||
|
"""Tests for the mixer DSP engine components.
|
||||||
|
|
||||||
|
Tests are designed to run without JACK or Carla — they validate
|
||||||
|
the logic, state management, and parameter dispatch without
|
||||||
|
requiring actual audio hardware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
|
||||||
|
from src.mixer.osc_client import (
|
||||||
|
encode_osc,
|
||||||
|
decode_osc,
|
||||||
|
CarlaOSCClient,
|
||||||
|
CarlaPluginInfo,
|
||||||
|
DEFAULT_PLUGIN_LAYOUT,
|
||||||
|
linear_to_db,
|
||||||
|
db_to_linear,
|
||||||
|
freq_to_normalized,
|
||||||
|
normalized_to_freq,
|
||||||
|
time_ms_to_normalized,
|
||||||
|
mix_to_pan,
|
||||||
|
)
|
||||||
|
from src.mixer.channel_strip import (
|
||||||
|
ChannelStrip,
|
||||||
|
ChannelState,
|
||||||
|
_apply_state,
|
||||||
|
_transform_value,
|
||||||
|
_get_osc_commands,
|
||||||
|
_normalize_param,
|
||||||
|
)
|
||||||
|
from src.mixer.routing_matrix import (
|
||||||
|
RoutingMatrix,
|
||||||
|
RouteNode,
|
||||||
|
RoutingEdge,
|
||||||
|
NodeType,
|
||||||
|
)
|
||||||
|
from src.mixer.bus_manager import (
|
||||||
|
BusManager,
|
||||||
|
AuxBus,
|
||||||
|
SubgroupBus,
|
||||||
|
VCAGroup,
|
||||||
|
MasterBus,
|
||||||
|
BusType,
|
||||||
|
)
|
||||||
|
from src.mixer.fader_automation import (
|
||||||
|
FaderAutomation,
|
||||||
|
AutomationLane,
|
||||||
|
AutomationPoint,
|
||||||
|
Scene,
|
||||||
|
InterpolationMode,
|
||||||
|
_interpolate,
|
||||||
|
)
|
||||||
|
from src.mixer.dsp_engine import (
|
||||||
|
DSPEngine,
|
||||||
|
DSPEngineConfig,
|
||||||
|
create_default_engine,
|
||||||
|
_make_param_key,
|
||||||
|
_parse_param_key,
|
||||||
|
)
|
||||||
|
from src.midi.types import (
|
||||||
|
ParameterType,
|
||||||
|
ParameterCategory,
|
||||||
|
MixerParameter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# OSC Protocol Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestOSCEncoding:
|
||||||
|
"""Test OSC message encoding/decoding."""
|
||||||
|
|
||||||
|
def test_encode_float(self):
|
||||||
|
packet = encode_osc("/test", 1.0)
|
||||||
|
assert b"/test" in packet
|
||||||
|
assert b",f" in packet
|
||||||
|
|
||||||
|
def test_encode_int(self):
|
||||||
|
packet = encode_osc("/test", 42)
|
||||||
|
assert b"/test" in packet
|
||||||
|
assert b",i" in packet
|
||||||
|
|
||||||
|
def test_encode_string(self):
|
||||||
|
packet = encode_osc("/test", "hello")
|
||||||
|
assert b"/test" in packet
|
||||||
|
assert b",s" in packet
|
||||||
|
assert b"hello" in packet
|
||||||
|
|
||||||
|
def test_encode_multiple_args(self):
|
||||||
|
packet = encode_osc("/Carla/1/set_parameter_value", 3, 0, 0.5)
|
||||||
|
assert b"/Carla/1/set_parameter_value" in packet
|
||||||
|
assert b",iif" in packet
|
||||||
|
|
||||||
|
def test_decode_float(self):
|
||||||
|
packet = encode_osc("/test", 3.14)
|
||||||
|
addr, args = decode_osc(packet)
|
||||||
|
assert addr == "/test"
|
||||||
|
assert len(args) == 1
|
||||||
|
assert abs(args[0] - 3.14) < 0.001
|
||||||
|
|
||||||
|
def test_decode_int(self):
|
||||||
|
packet = encode_osc("/test", 99)
|
||||||
|
addr, args = decode_osc(packet)
|
||||||
|
assert addr == "/test"
|
||||||
|
assert args[0] == 99
|
||||||
|
|
||||||
|
def test_decode_string(self):
|
||||||
|
packet = encode_osc("/test", "world")
|
||||||
|
addr, args = decode_osc(packet)
|
||||||
|
assert addr == "/test"
|
||||||
|
assert args[0] == "world"
|
||||||
|
|
||||||
|
def test_decode_multiple(self):
|
||||||
|
packet = encode_osc("/path", 1.0, 42, "abc")
|
||||||
|
addr, args = decode_osc(packet)
|
||||||
|
assert addr == "/path"
|
||||||
|
assert len(args) == 3
|
||||||
|
assert abs(args[0] - 1.0) < 0.001
|
||||||
|
assert args[1] == 42
|
||||||
|
assert args[2] == "abc"
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
"""Encode then decode should return the same values."""
|
||||||
|
original_addr = "/some/path"
|
||||||
|
original_args = [0.75, 128, "test_value"]
|
||||||
|
packet = encode_osc(original_addr, *original_args)
|
||||||
|
addr, args = decode_osc(packet)
|
||||||
|
assert addr == original_addr
|
||||||
|
assert len(args) == len(original_args)
|
||||||
|
for a, b in zip(args, original_args):
|
||||||
|
if isinstance(a, float) and isinstance(b, float):
|
||||||
|
assert abs(a - b) < 0.001
|
||||||
|
else:
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
class TestCarlaOSCClient:
|
||||||
|
"""Test the OSC client (without actual network)."""
|
||||||
|
|
||||||
|
def test_client_creation(self):
|
||||||
|
client = CarlaOSCClient()
|
||||||
|
assert client.host == "127.0.0.1"
|
||||||
|
assert client.port == 22752
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
def test_client_context_manager(self):
|
||||||
|
with CarlaOSCClient() as client:
|
||||||
|
assert client.host == "127.0.0.1"
|
||||||
|
|
||||||
|
def test_stats(self):
|
||||||
|
client = CarlaOSCClient()
|
||||||
|
stats = client.stats
|
||||||
|
assert stats["host"] == "127.0.0.1"
|
||||||
|
assert stats["sends"] == 0
|
||||||
|
assert stats["errors"] == 0
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Value Conversion Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestValueConversions:
|
||||||
|
|
||||||
|
def test_linear_to_db_silence(self):
|
||||||
|
# With default min_db=-60.0, linear=0 returns -inf (true digital silence)
|
||||||
|
assert linear_to_db(0.0) == float("-inf")
|
||||||
|
|
||||||
|
def test_linear_to_db_unity(self):
|
||||||
|
assert abs(linear_to_db(0.5) - (-24.0)) < 1.0
|
||||||
|
|
||||||
|
def test_linear_to_db_max(self):
|
||||||
|
assert abs(linear_to_db(1.0) - 12.0) < 0.1
|
||||||
|
|
||||||
|
def test_db_to_linear_roundtrip(self):
|
||||||
|
# Skip -60 (floor): mathematically db_to_linear(-60)=0.0, linear_to_db(0.0)=-inf
|
||||||
|
for db in [-40, -20, 0, 6, 12]:
|
||||||
|
lin = db_to_linear(db)
|
||||||
|
db2 = linear_to_db(lin)
|
||||||
|
assert abs(db - db2) < 0.5, f"Roundtrip failed at {db} dB"
|
||||||
|
|
||||||
|
def test_freq_to_normalized(self):
|
||||||
|
assert freq_to_normalized(20) < 0.01
|
||||||
|
assert freq_to_normalized(20000) > 0.99
|
||||||
|
assert 0.4 < freq_to_normalized(1000) < 0.6
|
||||||
|
|
||||||
|
def test_normalized_to_freq(self):
|
||||||
|
for hz in [50, 200, 1000, 5000, 15000]:
|
||||||
|
norm = freq_to_normalized(hz)
|
||||||
|
hz2 = normalized_to_freq(norm)
|
||||||
|
ratio = hz2 / hz
|
||||||
|
assert 0.8 < ratio < 1.25, f"Failed at {hz} Hz: got {hz2:.0f}"
|
||||||
|
|
||||||
|
def test_time_ms_to_normalized(self):
|
||||||
|
assert time_ms_to_normalized(0.1) < 0.01
|
||||||
|
assert time_ms_to_normalized(2000) > 0.99
|
||||||
|
assert 0.0 < time_ms_to_normalized(100) < 0.5
|
||||||
|
|
||||||
|
def test_mix_to_pan_center(self):
|
||||||
|
l, r = mix_to_pan(1.0, 0.0) # mix full, pan center
|
||||||
|
assert abs(l - r) < 0.001
|
||||||
|
assert abs(l - 0.707) < 0.01 # cos(pi/4)
|
||||||
|
|
||||||
|
def test_mix_to_pan_left(self):
|
||||||
|
l, r = mix_to_pan(1.0, -1.0)
|
||||||
|
assert l > 0.99
|
||||||
|
assert r < 0.01
|
||||||
|
|
||||||
|
def test_mix_to_pan_right(self):
|
||||||
|
l, r = mix_to_pan(1.0, 1.0)
|
||||||
|
assert l < 0.01
|
||||||
|
assert r > 0.99
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Channel Strip Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelStrip:
|
||||||
|
|
||||||
|
def test_default_state(self):
|
||||||
|
strip = ChannelStrip(index=0)
|
||||||
|
assert strip.index == 0
|
||||||
|
assert strip.state.volume == 0.0
|
||||||
|
assert strip.state.mute is False
|
||||||
|
assert strip.state.pan == 0.0
|
||||||
|
|
||||||
|
def test_set_volume(self):
|
||||||
|
strip = ChannelStrip(index=0)
|
||||||
|
strip.set_parameter(ParameterType.VOLUME, -6.0)
|
||||||
|
assert strip.state.volume == -6.0
|
||||||
|
|
||||||
|
def test_set_mute(self):
|
||||||
|
strip = ChannelStrip(index=0)
|
||||||
|
strip.set_parameter(ParameterType.MUTE, 1.0)
|
||||||
|
assert strip.state.mute is True
|
||||||
|
strip.set_parameter(ParameterType.MUTE, 0.0)
|
||||||
|
assert strip.state.mute is False
|
||||||
|
|
||||||
|
def test_set_eq(self):
|
||||||
|
strip = ChannelStrip(index=3)
|
||||||
|
strip.set_parameter(ParameterType.EQ_LOW_FREQ, 120.0)
|
||||||
|
strip.set_parameter(ParameterType.EQ_LOW_GAIN, 3.0)
|
||||||
|
strip.set_parameter(ParameterType.EQ_LOW_Q, 1.5)
|
||||||
|
assert strip.state.eq_low_freq == 120.0
|
||||||
|
assert strip.state.eq_low_gain == 3.0
|
||||||
|
assert strip.state.eq_low_q == 1.5
|
||||||
|
|
||||||
|
def test_set_compressor(self):
|
||||||
|
strip = ChannelStrip(index=1)
|
||||||
|
strip.set_parameter(ParameterType.COMP_THRESHOLD, -25.0)
|
||||||
|
strip.set_parameter(ParameterType.COMP_RATIO, 4.0)
|
||||||
|
strip.set_parameter(ParameterType.COMP_ATTACK, 5.0)
|
||||||
|
assert strip.state.comp_threshold == -25.0
|
||||||
|
assert strip.state.comp_ratio == 4.0
|
||||||
|
assert strip.state.comp_attack == 5.0
|
||||||
|
|
||||||
|
def test_set_gate(self):
|
||||||
|
strip = ChannelStrip(index=2)
|
||||||
|
strip.set_parameter(ParameterType.GATE_THRESHOLD, -50.0)
|
||||||
|
strip.set_parameter(ParameterType.GATE_RANGE, -70.0)
|
||||||
|
assert strip.state.gate_threshold == -50.0
|
||||||
|
assert strip.state.gate_range == -70.0
|
||||||
|
|
||||||
|
def test_set_fx_sends(self):
|
||||||
|
strip = ChannelStrip(index=0)
|
||||||
|
strip.set_parameter(ParameterType.FX_SEND_A, -10.0)
|
||||||
|
strip.set_parameter(ParameterType.FX_SEND_B, -20.0)
|
||||||
|
assert strip.state.fx_send_a == -10.0
|
||||||
|
assert strip.state.fx_send_b == -20.0
|
||||||
|
|
||||||
|
def test_snapshot_restore(self):
|
||||||
|
strip = ChannelStrip(index=5)
|
||||||
|
strip.set_parameter(ParameterType.VOLUME, -12.0)
|
||||||
|
strip.set_parameter(ParameterType.PAN, 0.5)
|
||||||
|
|
||||||
|
snap = strip.snapshot()
|
||||||
|
assert snap.volume == -12.0
|
||||||
|
assert snap.pan == 0.5
|
||||||
|
|
||||||
|
# Modify
|
||||||
|
strip.set_parameter(ParameterType.VOLUME, 0.0)
|
||||||
|
assert strip.state.volume == 0.0
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
strip.restore(snap, send_osc=False)
|
||||||
|
assert strip.state.volume == -12.0
|
||||||
|
assert strip.state.pan == 0.5
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
strip = ChannelStrip(index=0)
|
||||||
|
strip.set_parameter(ParameterType.VOLUME, 6.0)
|
||||||
|
strip.set_parameter(ParameterType.MUTE, 1.0)
|
||||||
|
strip.reset()
|
||||||
|
assert strip.state.volume == 0.0
|
||||||
|
assert strip.state.mute is False
|
||||||
|
|
||||||
|
def test_has_dsp(self):
|
||||||
|
strip = ChannelStrip(index=0)
|
||||||
|
assert strip.has_dsp is False
|
||||||
|
|
||||||
|
plugin = CarlaPluginInfo(1, "Test Gate", "gate", 0, {"threshold": 0})
|
||||||
|
strip.register_plugin(plugin)
|
||||||
|
assert strip.has_dsp is True
|
||||||
|
|
||||||
|
def test_set_many(self):
|
||||||
|
strip = ChannelStrip(index=0)
|
||||||
|
strip.set_many({
|
||||||
|
ParameterType.VOLUME: -3.0,
|
||||||
|
ParameterType.PAN: 0.5,
|
||||||
|
ParameterType.MUTE: 1.0,
|
||||||
|
})
|
||||||
|
assert strip.state.volume == -3.0
|
||||||
|
assert strip.state.pan == 0.5
|
||||||
|
assert strip.state.mute is True
|
||||||
|
|
||||||
|
def test_plugin_registration(self):
|
||||||
|
strip = ChannelStrip(index=3)
|
||||||
|
plugin = CarlaPluginInfo(10, "Ch4 EQ", "eq", 3, {"low_freq": 0, "low_gain": 1})
|
||||||
|
strip.register_plugin(plugin)
|
||||||
|
assert "eq" in strip._plugins
|
||||||
|
assert strip._plugins["eq"].plugin_id == 10
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Routing Matrix Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutingMatrix:
|
||||||
|
|
||||||
|
def test_add_node(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
node = rm.add_node(RouteNode("test", NodeType.CHANNEL_INPUT, "Test"))
|
||||||
|
assert rm.get_node("test") is node
|
||||||
|
|
||||||
|
def test_connect(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
src = rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||||
|
dst = rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||||
|
edge = rm.connect("src", "dst", gain_db=-6.0, apply_jack=False)
|
||||||
|
assert edge is not None
|
||||||
|
assert edge.gain_db == -6.0
|
||||||
|
|
||||||
|
def test_connect_nonexistent(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
edge = rm.connect("a", "b")
|
||||||
|
assert edge is None
|
||||||
|
|
||||||
|
def test_disconnect(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||||
|
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||||
|
rm.connect("src", "dst", apply_jack=False)
|
||||||
|
assert len(rm._edges) == 1
|
||||||
|
assert rm.disconnect("src", "dst", apply_jack=False)
|
||||||
|
assert len(rm._edges) == 0
|
||||||
|
|
||||||
|
def test_set_gain(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||||
|
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||||
|
rm.connect("src", "dst", apply_jack=False)
|
||||||
|
assert rm.set_gain("src", "dst", -12.0)
|
||||||
|
edge = rm.get_edges("src")[0]
|
||||||
|
assert edge.gain_db == -12.0
|
||||||
|
|
||||||
|
def test_set_mute(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||||
|
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||||
|
rm.connect("src", "dst", apply_jack=False)
|
||||||
|
rm.set_mute("src", "dst", True)
|
||||||
|
edge = rm.get_edges("src")[0]
|
||||||
|
assert edge.muted is True
|
||||||
|
|
||||||
|
def test_solo_logic(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
src1 = rm.add_node(RouteNode("src1", NodeType.CHANNEL_INPUT))
|
||||||
|
src2 = rm.add_node(RouteNode("src2", NodeType.CHANNEL_INPUT))
|
||||||
|
dst = rm.add_node(RouteNode("dst", NodeType.MASTER_INPUT))
|
||||||
|
rm.connect("src1", "dst", apply_jack=False)
|
||||||
|
rm.connect("src2", "dst", apply_jack=False)
|
||||||
|
|
||||||
|
rm.set_solo("src1", True)
|
||||||
|
edges = rm.get_edges()
|
||||||
|
edge1 = [e for e in edges if e.source.node_id == "src1"][0]
|
||||||
|
edge2 = [e for e in edges if e.source.node_id == "src2"][0]
|
||||||
|
assert edge1.soloed is True
|
||||||
|
assert edge2.soloed is False
|
||||||
|
|
||||||
|
rm.clear_solo()
|
||||||
|
edges = rm.get_edges()
|
||||||
|
assert all(not e.soloed for e in edges)
|
||||||
|
|
||||||
|
def test_remove_node_cleans_edges(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||||
|
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||||
|
rm.connect("src", "dst", apply_jack=False)
|
||||||
|
assert len(rm._edges) == 1
|
||||||
|
rm.remove_node("src")
|
||||||
|
assert len(rm._edges) == 0
|
||||||
|
assert rm.get_node("src") is None
|
||||||
|
|
||||||
|
def test_build_default_8ch(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
rm.build_default_8ch_matrix()
|
||||||
|
# Should have system inputs (8), system outputs (2), channel nodes (16),
|
||||||
|
# aux nodes (8: 4 buses × 2), subgroups (2), master (3)
|
||||||
|
assert len(rm._nodes) >= 8 + 2
|
||||||
|
# Should have connections: 8 sys_in → channels, 8 channels → master, 2 master → sys_out
|
||||||
|
assert len(rm._edges) >= 8 + 8 + 2
|
||||||
|
|
||||||
|
def test_serialization_roundtrip(self):
|
||||||
|
rm = RoutingMatrix()
|
||||||
|
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT, "Input"))
|
||||||
|
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT, "Channel"))
|
||||||
|
rm.connect("src", "dst", gain_db=-3.0, apply_jack=False)
|
||||||
|
|
||||||
|
data = rm.to_dict()
|
||||||
|
rm2 = RoutingMatrix()
|
||||||
|
rm2.from_dict(data)
|
||||||
|
|
||||||
|
assert len(rm2._nodes) == 2
|
||||||
|
assert len(rm2._edges) == 1
|
||||||
|
assert rm2.get_node("src") is not None
|
||||||
|
assert rm2._edges[0].gain_db == -3.0
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Bus Manager Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestBusManager:
|
||||||
|
|
||||||
|
def test_default_initialization(self):
|
||||||
|
bm = BusManager(num_channels=16, num_aux=4, num_subgroups=2, num_vca=2)
|
||||||
|
assert len(bm.aux_buses) == 4
|
||||||
|
assert len(bm.subgroups) == 2
|
||||||
|
assert len(bm.vca_groups) == 2
|
||||||
|
assert bm.master.volume_db == 0.0
|
||||||
|
|
||||||
|
def test_aux_send(self):
|
||||||
|
bm = BusManager(num_channels=8, num_aux=4)
|
||||||
|
bm.set_aux_send(0, 3, -10.0)
|
||||||
|
aux = bm.get_aux(0)
|
||||||
|
assert aux.get_send(3) == -10.0
|
||||||
|
|
||||||
|
def test_aux_return(self):
|
||||||
|
bm = BusManager(num_channels=8, num_aux=4)
|
||||||
|
bm.set_aux_return(0, -5.0)
|
||||||
|
assert bm.get_aux(0).return_gain_db == -5.0
|
||||||
|
|
||||||
|
def test_aux_mute(self):
|
||||||
|
bm = BusManager(num_channels=8, num_aux=4)
|
||||||
|
bm.set_aux_mute(1, True)
|
||||||
|
assert bm.get_aux(1).muted is True
|
||||||
|
|
||||||
|
def test_aux_out_of_range(self):
|
||||||
|
bm = BusManager(num_channels=8, num_aux=2)
|
||||||
|
assert bm.get_aux(5) is None
|
||||||
|
|
||||||
|
def test_subgroup_membership(self):
|
||||||
|
bm = BusManager(num_channels=8, num_aux=2, num_subgroups=2)
|
||||||
|
assert bm.subgroup_add_channel(0, 3)
|
||||||
|
assert 3 in bm.subgroups[0].members
|
||||||
|
assert bm.subgroup_remove_channel(0, 3)
|
||||||
|
assert 3 not in bm.subgroups[0].members
|
||||||
|
|
||||||
|
def test_vca_membership(self):
|
||||||
|
bm = BusManager(num_channels=8, num_aux=2, num_vca=2)
|
||||||
|
bm.vca_add_channel(0, 5)
|
||||||
|
bm.vca_add_channel(1, 5)
|
||||||
|
vcas = bm.get_channel_vcas(5)
|
||||||
|
assert len(vcas) == 2
|
||||||
|
|
||||||
|
def test_vca_offset(self):
|
||||||
|
bm = BusManager(num_channels=8, num_vca=2)
|
||||||
|
bm.vca_add_channel(0, 2)
|
||||||
|
vca = bm.get_vca(0)
|
||||||
|
vca.master_db = -5.0
|
||||||
|
assert bm.get_channel_vca_offset(2) == -5.0
|
||||||
|
|
||||||
|
def test_vca_multiple_offset(self):
|
||||||
|
bm = BusManager(num_channels=8, num_vca=2)
|
||||||
|
bm.vca_add_channel(0, 2)
|
||||||
|
bm.vca_add_channel(1, 2)
|
||||||
|
bm.get_vca(0).master_db = -3.0
|
||||||
|
bm.get_vca(1).master_db = -2.0
|
||||||
|
assert bm.get_channel_vca_offset(2) == -5.0
|
||||||
|
|
||||||
|
def test_vca_muted_offset(self):
|
||||||
|
bm = BusManager(num_channels=8, num_vca=2)
|
||||||
|
bm.vca_add_channel(0, 2)
|
||||||
|
bm.get_vca(0).master_db = -5.0
|
||||||
|
bm.get_vca(0).muted = True
|
||||||
|
assert bm.get_channel_vca_offset(2) == 0.0 # muted VCA doesn't contribute
|
||||||
|
|
||||||
|
def test_master_operations(self):
|
||||||
|
bm = BusManager()
|
||||||
|
bm.set_master_volume(-10.0)
|
||||||
|
bm.set_master_mute(True)
|
||||||
|
bm.set_master_dim(True)
|
||||||
|
assert bm.master.volume_db == -10.0
|
||||||
|
assert bm.master.muted is True
|
||||||
|
assert bm.master.dim_active is True
|
||||||
|
# Effective gain with mute = 0
|
||||||
|
assert bm.master.effective_gain == 0.0
|
||||||
|
|
||||||
|
def test_master_dim_gain(self):
|
||||||
|
bm = BusManager()
|
||||||
|
bm.master.dim_db = -20.0
|
||||||
|
bm.master.dim_active = True
|
||||||
|
# -20 dB dim
|
||||||
|
assert abs(bm.master.effective_gain - 0.1) < 0.001
|
||||||
|
|
||||||
|
def test_serialization_roundtrip(self):
|
||||||
|
bm = BusManager(num_channels=4, num_aux=2, num_subgroups=1, num_vca=1)
|
||||||
|
bm.set_aux_send(0, 2, -6.0)
|
||||||
|
bm.subgroup_add_channel(0, 1)
|
||||||
|
bm.vca_add_channel(0, 0)
|
||||||
|
bm.get_vca(0).master_db = -3.0
|
||||||
|
bm.set_master_volume(-12.0)
|
||||||
|
|
||||||
|
data = bm.to_dict()
|
||||||
|
bm2 = BusManager(num_channels=4, num_aux=2, num_subgroups=1, num_vca=1)
|
||||||
|
bm2.from_dict(data)
|
||||||
|
|
||||||
|
assert bm2.get_aux(0).get_send(2) == -6.0
|
||||||
|
assert 1 in bm2.subgroups[0].members
|
||||||
|
assert 0 in bm2.vca_groups[0].members
|
||||||
|
assert bm2.master.volume_db == -12.0
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Fader Automation Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestInterpolation:
|
||||||
|
|
||||||
|
def test_linear(self):
|
||||||
|
assert _interpolate(0.0, 0, 10, InterpolationMode.LINEAR) == 0
|
||||||
|
assert _interpolate(0.5, 0, 10, InterpolationMode.LINEAR) == 5
|
||||||
|
assert _interpolate(1.0, 0, 10, InterpolationMode.LINEAR) == 10
|
||||||
|
|
||||||
|
def test_s_curve(self):
|
||||||
|
assert _interpolate(0.0, 0, 10, InterpolationMode.S_CURVE) == 0
|
||||||
|
assert _interpolate(0.5, 0, 10, InterpolationMode.S_CURVE) == 5 # smoothstep at 0.5 = 0.5
|
||||||
|
assert _interpolate(1.0, 0, 10, InterpolationMode.S_CURVE) == 10
|
||||||
|
|
||||||
|
def test_logarithmic(self):
|
||||||
|
v = _interpolate(0.5, 1, 100, InterpolationMode.LOGARITHMIC)
|
||||||
|
assert 5 < v < 15 # roughly sqrt(100) = 10
|
||||||
|
|
||||||
|
def test_clamp(self):
|
||||||
|
assert _interpolate(-0.5, 0, 10, InterpolationMode.LINEAR) == 0
|
||||||
|
assert _interpolate(1.5, 0, 10, InterpolationMode.LINEAR) == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutomationLane:
|
||||||
|
|
||||||
|
def test_add_point_sorted(self):
|
||||||
|
lane = AutomationLane("test")
|
||||||
|
lane.add_point(2.0, 0.5)
|
||||||
|
lane.add_point(1.0, 0.2)
|
||||||
|
lane.add_point(3.0, 0.8)
|
||||||
|
assert [p.time_sec for p in lane.points] == [1.0, 2.0, 3.0]
|
||||||
|
|
||||||
|
def test_get_value_at_exact(self):
|
||||||
|
lane = AutomationLane("test")
|
||||||
|
lane.add_point(1.0, 0.2)
|
||||||
|
lane.add_point(2.0, 0.8)
|
||||||
|
assert abs(lane.get_value_at(1.5) - 0.5) < 0.01
|
||||||
|
|
||||||
|
def test_get_value_at_before(self):
|
||||||
|
lane = AutomationLane("test")
|
||||||
|
lane.add_point(1.0, 0.2)
|
||||||
|
assert abs(lane.get_value_at(0.0) - 0.2) < 0.01
|
||||||
|
|
||||||
|
def test_get_value_at_after(self):
|
||||||
|
lane = AutomationLane("test")
|
||||||
|
lane.add_point(1.0, 0.2)
|
||||||
|
lane.add_point(2.0, 0.8)
|
||||||
|
assert abs(lane.get_value_at(3.0) - 0.8) < 0.01
|
||||||
|
|
||||||
|
def test_get_value_empty(self):
|
||||||
|
lane = AutomationLane("test")
|
||||||
|
assert lane.get_value_at(1.0, default=-1.0) == -1.0
|
||||||
|
|
||||||
|
def test_clone(self):
|
||||||
|
lane = AutomationLane("test")
|
||||||
|
lane.add_point(1.0, 0.5)
|
||||||
|
clone = lane.clone()
|
||||||
|
clone.points[0].value = 0.9
|
||||||
|
assert lane.points[0].value == 0.5 # original unchanged
|
||||||
|
|
||||||
|
|
||||||
|
class TestFaderAutomation:
|
||||||
|
|
||||||
|
def test_recording(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
assert fa.is_recording
|
||||||
|
|
||||||
|
time.sleep(0.01)
|
||||||
|
fa.record("test_param", 0.5)
|
||||||
|
fa.stop_recording()
|
||||||
|
assert not fa.is_recording
|
||||||
|
|
||||||
|
assert "test_param" in fa._lanes
|
||||||
|
assert len(fa._lanes["test_param"].points) > 0
|
||||||
|
|
||||||
|
def test_not_recording_when_stopped(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.record("test", 0.5)
|
||||||
|
assert "test" not in fa._lanes
|
||||||
|
|
||||||
|
def test_playback(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
fa.record("vol", 0.0)
|
||||||
|
time.sleep(0.02)
|
||||||
|
fa.record("vol", 1.0)
|
||||||
|
fa.stop_recording()
|
||||||
|
|
||||||
|
lane = fa._lanes["vol"]
|
||||||
|
assert len(lane.points) == 2
|
||||||
|
|
||||||
|
def test_scene_save_load(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
scene = fa.save_scene("TestScene", {0: {"volume": -6.0}}, {}, {})
|
||||||
|
assert "TestScene" in fa.list_scenes()
|
||||||
|
|
||||||
|
loaded = fa.load_scene("TestScene")
|
||||||
|
assert loaded.name == "TestScene"
|
||||||
|
|
||||||
|
def test_scene_delete(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.save_scene("DeleteMe", {}, {}, {})
|
||||||
|
assert fa.delete_scene("DeleteMe")
|
||||||
|
assert "DeleteMe" not in fa.list_scenes()
|
||||||
|
|
||||||
|
def test_scene_recall(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
fa.record("param", 0.25)
|
||||||
|
fa.stop_recording()
|
||||||
|
|
||||||
|
fa.save_scene("S1", {0: {"volume": -6.0}}, {}, {})
|
||||||
|
fa.recall_scene("S1")
|
||||||
|
assert fa._current_scene == "S1"
|
||||||
|
|
||||||
|
def test_crossfade(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
fa.record("param", 0.0)
|
||||||
|
fa.stop_recording()
|
||||||
|
|
||||||
|
fa.save_scene("Target", {}, {}, {})
|
||||||
|
|
||||||
|
# Start very short crossfade
|
||||||
|
result = fa.crossfade_to("Target", 0.05)
|
||||||
|
assert result is True
|
||||||
|
assert fa._crossfade_active
|
||||||
|
|
||||||
|
# Let it complete
|
||||||
|
time.sleep(0.1)
|
||||||
|
fa.update_crossfade()
|
||||||
|
assert not fa._crossfade_active
|
||||||
|
|
||||||
|
def test_crossfade_missing_scene(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
assert fa.crossfade_to("Nonexistent", 1.0) is False
|
||||||
|
|
||||||
|
def test_cancel_crossfade(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
fa.record("param", 0.0)
|
||||||
|
fa.stop_recording()
|
||||||
|
fa.save_scene("Target", {}, {}, {})
|
||||||
|
|
||||||
|
fa.crossfade_to("Target", 10.0)
|
||||||
|
assert fa._crossfade_active
|
||||||
|
fa.cancel_crossfade()
|
||||||
|
assert not fa._crossfade_active
|
||||||
|
|
||||||
|
def test_seek(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
fa.record("param", 0.0)
|
||||||
|
time.sleep(0.02)
|
||||||
|
fa.record("param", 1.0)
|
||||||
|
fa.stop_recording()
|
||||||
|
|
||||||
|
fa.start_playback()
|
||||||
|
fa.seek(1000.0) # seek way past
|
||||||
|
val = fa.get_value("param")
|
||||||
|
assert abs(val - 1.0) < 0.01
|
||||||
|
|
||||||
|
def test_get_duration(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
fa.record("a", 0.0)
|
||||||
|
time.sleep(0.05)
|
||||||
|
fa.record("a", 0.5)
|
||||||
|
time.sleep(0.05)
|
||||||
|
fa.record("b", 1.0)
|
||||||
|
fa.stop_recording()
|
||||||
|
|
||||||
|
duration = fa.get_duration()
|
||||||
|
assert duration > 0.08 # at least 100ms total
|
||||||
|
|
||||||
|
def test_serialization_roundtrip(self):
|
||||||
|
fa = FaderAutomation()
|
||||||
|
fa.start_recording()
|
||||||
|
fa.record("param1", 0.3)
|
||||||
|
fa.stop_recording()
|
||||||
|
fa.save_scene("Test", {}, {}, {})
|
||||||
|
|
||||||
|
data = fa.to_dict()
|
||||||
|
fa2 = FaderAutomation()
|
||||||
|
fa2.from_dict(data)
|
||||||
|
|
||||||
|
assert len(fa2._lanes) == 1
|
||||||
|
assert "Test" in fa2.list_scenes()
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# DSP Engine Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSPEngine:
|
||||||
|
|
||||||
|
def test_create_default_engine(self):
|
||||||
|
engine = create_default_engine(num_channels=8, osc_enabled=False)
|
||||||
|
assert len(engine.channels) == 8
|
||||||
|
assert engine.config.num_aux == 4
|
||||||
|
assert not engine.running
|
||||||
|
|
||||||
|
def test_start_stop(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
assert engine.running
|
||||||
|
engine.stop()
|
||||||
|
assert not engine.running
|
||||||
|
|
||||||
|
def test_handle_channel_volume(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
category=ParameterCategory.CHANNEL,
|
||||||
|
channel=2,
|
||||||
|
)
|
||||||
|
engine.handle_parameter(param, -12.0)
|
||||||
|
assert engine.channels[2].state.volume == -12.0
|
||||||
|
|
||||||
|
def test_handle_mute(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=ParameterType.MUTE,
|
||||||
|
category=ParameterCategory.CHANNEL,
|
||||||
|
channel=0,
|
||||||
|
)
|
||||||
|
engine.handle_parameter(param, 1.0)
|
||||||
|
assert engine.channels[0].state.mute is True
|
||||||
|
|
||||||
|
def test_handle_master_volume(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=ParameterType.MASTER_VOLUME,
|
||||||
|
category=ParameterCategory.MASTER,
|
||||||
|
channel=-1,
|
||||||
|
)
|
||||||
|
engine.handle_parameter(param, -20.0)
|
||||||
|
assert engine.buses.master.volume_db == -20.0
|
||||||
|
|
||||||
|
def test_handle_fx_return(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=ParameterType.FX_RETURN_A,
|
||||||
|
category=ParameterCategory.FX,
|
||||||
|
channel=-1,
|
||||||
|
)
|
||||||
|
engine.handle_parameter(param, -6.0)
|
||||||
|
assert engine.buses.get_aux(0).return_gain_db == -6.0
|
||||||
|
|
||||||
|
def test_handle_eq_params(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=ParameterType.EQ_MID_GAIN,
|
||||||
|
category=ParameterCategory.CHANNEL,
|
||||||
|
channel=1,
|
||||||
|
)
|
||||||
|
engine.handle_parameter(param, 4.0)
|
||||||
|
assert engine.channels[1].state.eq_mid_gain == 4.0
|
||||||
|
|
||||||
|
def test_snapshot_save_load(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
# Set some state
|
||||||
|
engine.channels[0].set_parameter(ParameterType.VOLUME, -6.0)
|
||||||
|
engine.buses.set_master_volume(-3.0)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
engine.save_snapshot("test_snap")
|
||||||
|
|
||||||
|
# Modify
|
||||||
|
engine.channels[0].set_parameter(ParameterType.VOLUME, 0.0)
|
||||||
|
engine.buses.set_master_volume(0.0)
|
||||||
|
|
||||||
|
# Load
|
||||||
|
assert engine.load_snapshot("test_snap")
|
||||||
|
assert engine.channels[0].state.volume == -6.0
|
||||||
|
assert engine.buses.master.volume_db == -3.0
|
||||||
|
|
||||||
|
def test_param_key_helpers(self):
|
||||||
|
key = _make_param_key(ParameterCategory.CHANNEL, 3, ParameterType.VOLUME)
|
||||||
|
cat, ch, pt = _parse_param_key(key)
|
||||||
|
assert cat == ParameterCategory.CHANNEL
|
||||||
|
assert ch == 3
|
||||||
|
assert pt == ParameterType.VOLUME
|
||||||
|
|
||||||
|
def test_param_key_master(self):
|
||||||
|
key = _make_param_key(ParameterCategory.MASTER, -1, ParameterType.MASTER_VOLUME)
|
||||||
|
cat, ch, pt = _parse_param_key(key)
|
||||||
|
assert cat == ParameterCategory.MASTER
|
||||||
|
assert ch == -1
|
||||||
|
assert pt == ParameterType.MASTER_VOLUME
|
||||||
|
|
||||||
|
def test_stats(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
stats = engine.stats
|
||||||
|
assert stats["running"]
|
||||||
|
assert stats["num_channels"] == 4
|
||||||
|
assert not stats["osc_connected"]
|
||||||
|
|
||||||
|
def test_to_dict_from_dict(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
engine.channels[0].set_parameter(ParameterType.VOLUME, -10.0)
|
||||||
|
|
||||||
|
data = engine.to_dict()
|
||||||
|
engine2 = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine2.from_dict(data)
|
||||||
|
|
||||||
|
assert engine2.channels[0].state.volume == -10.0
|
||||||
|
|
||||||
|
def test_next_prev_scene(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
engine.save_snapshot("A")
|
||||||
|
engine.save_snapshot("B")
|
||||||
|
engine.save_snapshot("C")
|
||||||
|
|
||||||
|
assert engine.load_snapshot("B")
|
||||||
|
assert engine.automation._current_scene == "B"
|
||||||
|
|
||||||
|
# Bound to the scenes
|
||||||
|
assert engine.next_scene() # goes to C
|
||||||
|
assert engine.next_scene() # wraps to A
|
||||||
|
assert engine.prev_scene() # goes to C
|
||||||
|
|
||||||
|
def test_automation_recording_handling(self):
|
||||||
|
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
engine.automation.start_recording()
|
||||||
|
|
||||||
|
param = MixerParameter(
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
category=ParameterCategory.CHANNEL,
|
||||||
|
channel=0,
|
||||||
|
)
|
||||||
|
engine.handle_parameter(param, -5.0)
|
||||||
|
|
||||||
|
key = _make_param_key(ParameterCategory.CHANNEL, 0, ParameterType.VOLUME)
|
||||||
|
assert key in engine.automation._lanes
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Integration Tests
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegration:
|
||||||
|
|
||||||
|
def test_full_signal_chain_state(self):
|
||||||
|
"""Test that the full signal chain can be configured through the engine."""
|
||||||
|
engine = create_default_engine(num_channels=16, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
# Configure channel 0
|
||||||
|
params_to_set = [
|
||||||
|
(ParameterType.GAIN, 20.0),
|
||||||
|
(ParameterType.EQ_LOW_FREQ, 80.0),
|
||||||
|
(ParameterType.EQ_LOW_GAIN, 3.0),
|
||||||
|
(ParameterType.EQ_MID_FREQ, 2500.0),
|
||||||
|
(ParameterType.EQ_MID_GAIN, -2.0),
|
||||||
|
(ParameterType.EQ_HIGH_FREQ, 8000.0),
|
||||||
|
(ParameterType.EQ_HIGH_GAIN, 1.5),
|
||||||
|
(ParameterType.COMP_THRESHOLD, -18.0),
|
||||||
|
(ParameterType.COMP_RATIO, 3.0),
|
||||||
|
(ParameterType.GATE_THRESHOLD, -45.0),
|
||||||
|
(ParameterType.FX_SEND_A, -8.0),
|
||||||
|
(ParameterType.VOLUME, -3.0),
|
||||||
|
(ParameterType.PAN, 0.2),
|
||||||
|
]
|
||||||
|
for pt, val in params_to_set:
|
||||||
|
param = MixerParameter(pt, ParameterCategory.CHANNEL, 0)
|
||||||
|
engine.handle_parameter(param, val)
|
||||||
|
|
||||||
|
ch0 = engine.channels[0]
|
||||||
|
assert ch0.state.gain == 20.0
|
||||||
|
assert ch0.state.eq_low_freq == 80.0
|
||||||
|
assert ch0.state.comp_threshold == -18.0
|
||||||
|
assert ch0.state.volume == -3.0
|
||||||
|
|
||||||
|
def test_multiple_channels_independent(self):
|
||||||
|
"""Test that channels maintain independent state."""
|
||||||
|
engine = create_default_engine(num_channels=8, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
# Set different volumes on different channels
|
||||||
|
for ch, vol in [(0, -6.0), (3, 0.0), (7, 6.0)]:
|
||||||
|
param = MixerParameter(ParameterType.VOLUME, ParameterCategory.CHANNEL, ch)
|
||||||
|
engine.handle_parameter(param, vol)
|
||||||
|
|
||||||
|
assert engine.channels[0].state.volume == -6.0
|
||||||
|
assert engine.channels[3].state.volume == 0.0
|
||||||
|
assert engine.channels[7].state.volume == 6.0
|
||||||
|
assert engine.channels[1].state.volume == 0.0 # unchanged
|
||||||
|
|
||||||
|
def test_bus_routing_persistence(self):
|
||||||
|
"""Test that bus state persists across snapshot save/load."""
|
||||||
|
engine = create_default_engine(num_channels=8, osc_enabled=False)
|
||||||
|
engine.start(connect_osc=False)
|
||||||
|
|
||||||
|
# Set up complex bus state
|
||||||
|
engine.buses.set_aux_send(0, 1, -12.0)
|
||||||
|
engine.buses.set_aux_send(2, 3, -4.0)
|
||||||
|
engine.buses.subgroup_add_channel(0, 0)
|
||||||
|
engine.buses.subgroup_add_channel(0, 4)
|
||||||
|
engine.buses.vca_add_channel(0, 2)
|
||||||
|
engine.buses.get_vca(0).master_db = -5.0
|
||||||
|
|
||||||
|
# Save and verify restore
|
||||||
|
snap = engine.save_snapshot("complex")
|
||||||
|
engine.load_snapshot("complex")
|
||||||
|
|
||||||
|
assert engine.buses.get_aux(0).get_send(1) == -12.0
|
||||||
|
assert engine.buses.get_aux(2).get_send(3) == -4.0
|
||||||
|
assert 0 in engine.buses.subgroups[0].members
|
||||||
|
assert 2 in engine.buses.vca_groups[0].members
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for mapping persistence."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.midi.types import MIDIMapping, MIDIMessageType, ParameterType
|
||||||
|
from src.midi.mapping_store import (
|
||||||
|
mapping_to_dict,
|
||||||
|
mapping_from_dict,
|
||||||
|
save_mappings,
|
||||||
|
load_mappings,
|
||||||
|
list_sessions,
|
||||||
|
delete_session,
|
||||||
|
DEFAULT_MAPPINGS_DIR,
|
||||||
|
DEFAULT_SESSION_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerialisation:
|
||||||
|
def test_roundtrip_cc_mapping(self):
|
||||||
|
original = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
param_channel=0,
|
||||||
|
invert=True,
|
||||||
|
curve="logarithmic",
|
||||||
|
label="CH0 Vol",
|
||||||
|
)
|
||||||
|
d = mapping_to_dict(original)
|
||||||
|
restored = mapping_from_dict(d)
|
||||||
|
|
||||||
|
assert restored.msg_type == original.msg_type
|
||||||
|
assert restored.channel == original.channel
|
||||||
|
assert restored.controller == original.controller
|
||||||
|
assert restored.param_type == original.param_type
|
||||||
|
assert restored.param_channel == original.param_channel
|
||||||
|
assert restored.invert == original.invert
|
||||||
|
assert restored.curve == original.curve
|
||||||
|
assert restored.label == original.label
|
||||||
|
|
||||||
|
def test_roundtrip_nrpn_mapping(self):
|
||||||
|
original = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=1,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_number=258,
|
||||||
|
param_type=ParameterType.COMP_THRESHOLD,
|
||||||
|
param_channel=3,
|
||||||
|
)
|
||||||
|
d = mapping_to_dict(original)
|
||||||
|
restored = mapping_from_dict(d)
|
||||||
|
|
||||||
|
assert restored.is_nrpn
|
||||||
|
assert restored.nrpn_number == 258
|
||||||
|
|
||||||
|
def test_roundtrip_with_device(self):
|
||||||
|
original = MIDIMapping(
|
||||||
|
controller=10,
|
||||||
|
source_device="X-Touch",
|
||||||
|
)
|
||||||
|
d = mapping_to_dict(original)
|
||||||
|
restored = mapping_from_dict(d)
|
||||||
|
|
||||||
|
assert restored.source_device == "X-Touch"
|
||||||
|
|
||||||
|
def test_roundtrip_disabled(self):
|
||||||
|
original = MIDIMapping(enabled=False)
|
||||||
|
d = mapping_to_dict(original)
|
||||||
|
restored = mapping_from_dict(d)
|
||||||
|
assert not restored.enabled
|
||||||
|
|
||||||
|
def test_dict_format_has_expected_keys(self):
|
||||||
|
m = MIDIMapping()
|
||||||
|
d = mapping_to_dict(m)
|
||||||
|
|
||||||
|
assert "midi" in d
|
||||||
|
assert "target" in d
|
||||||
|
assert "value" in d
|
||||||
|
assert "meta" in d
|
||||||
|
assert d["midi"]["type"] == "CONTROL_CHANGE"
|
||||||
|
assert d["target"]["parameter"] == "volume"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileIO:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def temp_dir(self, monkeypatch):
|
||||||
|
"""Redirect mappings to a temp directory."""
|
||||||
|
td = tempfile.mkdtemp(prefix="rpi-mixer-test-")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.midi.mapping_store.DEFAULT_MAPPINGS_DIR",
|
||||||
|
Path(td),
|
||||||
|
)
|
||||||
|
yield td
|
||||||
|
# Cleanup
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(td, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_save_and_load(self):
|
||||||
|
mappings = [
|
||||||
|
MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
param_channel=0,
|
||||||
|
label="CH0 Vol",
|
||||||
|
),
|
||||||
|
MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=10,
|
||||||
|
param_type=ParameterType.PAN,
|
||||||
|
param_channel=0,
|
||||||
|
label="CH0 Pan",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
path = save_mappings(mappings, DEFAULT_SESSION_NAME)
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
loaded = load_mappings(DEFAULT_SESSION_NAME)
|
||||||
|
assert len(loaded) == 2
|
||||||
|
assert loaded[0].controller == 7
|
||||||
|
assert loaded[1].controller == 10
|
||||||
|
assert loaded[0].label == "CH0 Vol"
|
||||||
|
assert loaded[1].label == "CH0 Pan"
|
||||||
|
|
||||||
|
def test_missing_session_returns_empty(self):
|
||||||
|
loaded = load_mappings("nonexistent-session")
|
||||||
|
assert loaded == []
|
||||||
|
|
||||||
|
def test_list_sessions(self, temp_dir):
|
||||||
|
save_mappings([MIDIMapping()], "session-a")
|
||||||
|
save_mappings([MIDIMapping()], "session-b")
|
||||||
|
|
||||||
|
sessions = list_sessions()
|
||||||
|
assert "session-a" in sessions
|
||||||
|
assert "session-b" in sessions
|
||||||
|
|
||||||
|
def test_delete_session(self):
|
||||||
|
save_mappings([MIDIMapping()], "to-delete")
|
||||||
|
assert delete_session("to-delete")
|
||||||
|
assert load_mappings("to-delete") == []
|
||||||
|
|
||||||
|
def test_delete_nonexistent_session(self):
|
||||||
|
assert not delete_session("never-existed")
|
||||||
|
|
||||||
|
def test_save_empty_mappings(self):
|
||||||
|
path = save_mappings([], DEFAULT_SESSION_NAME)
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
loaded = load_mappings(DEFAULT_SESSION_NAME)
|
||||||
|
assert loaded == []
|
||||||
|
|
||||||
|
def test_json_is_valid(self):
|
||||||
|
"""Saved JSON should be parseable by standard json module."""
|
||||||
|
mappings = [
|
||||||
|
MIDIMapping(controller=7, param_type=ParameterType.VOLUME, param_channel=0),
|
||||||
|
MIDIMapping(controller=10, param_type=ParameterType.PAN, param_channel=1, invert=True),
|
||||||
|
]
|
||||||
|
path = save_mappings(mappings, DEFAULT_SESSION_NAME)
|
||||||
|
|
||||||
|
with open(path) as fh:
|
||||||
|
doc = json.load(fh)
|
||||||
|
|
||||||
|
assert doc["version"] == 1
|
||||||
|
assert len(doc["mappings"]) == 2
|
||||||
|
assert doc["mappings"][0]["target"]["parameter"] == "volume"
|
||||||
|
assert doc["mappings"][1]["value"]["invert"] is True
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Tests for MIDI clock sync."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from src.midi.midi_clock import MIDIClock, PPQN
|
||||||
|
|
||||||
|
|
||||||
|
class TestMIDIClock:
|
||||||
|
@pytest.fixture
|
||||||
|
def clock(self):
|
||||||
|
return MIDIClock(window_size=24) # Small window for fast tests
|
||||||
|
|
||||||
|
def test_initial_state(self, clock):
|
||||||
|
assert not clock.is_running
|
||||||
|
assert clock.tempo == 120.0
|
||||||
|
|
||||||
|
def test_start_sets_running(self, clock):
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
assert clock.is_running
|
||||||
|
|
||||||
|
def test_stop_sets_not_running(self, clock):
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
clock.process_message(0xFC) # STOP
|
||||||
|
assert not clock.is_running
|
||||||
|
|
||||||
|
def test_continue_sets_running(self, clock):
|
||||||
|
clock.process_message(0xFB) # CONTINUE
|
||||||
|
assert clock.is_running
|
||||||
|
|
||||||
|
def test_clock_pulses_detect_tempo(self, clock):
|
||||||
|
"""Simulate MIDI clock at 120 BPM.
|
||||||
|
|
||||||
|
At 120 BPM:
|
||||||
|
- Seconds per quarter note = 60/120 = 0.5s
|
||||||
|
- Seconds per pulse = 0.5/24 = ~0.02083s
|
||||||
|
"""
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
|
||||||
|
pulse_interval = 60.0 / 120.0 / PPQN # ~0.02083s
|
||||||
|
|
||||||
|
# Send 48 pulses (2 beats at 120 BPM)
|
||||||
|
now = time.monotonic()
|
||||||
|
for i in range(48):
|
||||||
|
# Simulate time advance (we inject the timestamp indirectly via the process)
|
||||||
|
clock.process_message(0xF8)
|
||||||
|
|
||||||
|
# After enough pulses, BPM should be roughly 120
|
||||||
|
# (Since we can't control the real clock, we just verify it runs)
|
||||||
|
assert clock.state.pulse_count == 48
|
||||||
|
|
||||||
|
def test_transport_callbacks(self, clock):
|
||||||
|
events = []
|
||||||
|
clock.on_transport(lambda ev: events.append(ev))
|
||||||
|
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
clock.process_message(0xFC) # STOP
|
||||||
|
clock.process_message(0xFB) # CONTINUE
|
||||||
|
|
||||||
|
assert events == ["start", "stop", "continue"]
|
||||||
|
|
||||||
|
def test_tempo_callback_fires(self, clock):
|
||||||
|
"""Send enough pulses to fill the window and trigger tempo callback."""
|
||||||
|
tempos = []
|
||||||
|
clock.on_tempo_change(lambda bpm, raw, stable: tempos.append(bpm))
|
||||||
|
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
|
||||||
|
# Send pulses quickly
|
||||||
|
for _ in range(24): # 1 beat
|
||||||
|
clock.process_message(0xF8)
|
||||||
|
|
||||||
|
# Tempo callback should have fired at least once on beat boundary
|
||||||
|
# (but only if bpm_stable, which requires half window)
|
||||||
|
# With window=24 and 24 pulses, we get exactly 1 beat and half the window
|
||||||
|
# Actually we need 12 pulses for half window. 24 pulses = 1 beat, callback fires
|
||||||
|
# Wait — the tempo callback fires on beat boundaries IF bpm_stable
|
||||||
|
# bpm_stable requires >= 12 intervals (half of window=24). 24 pulses = 23 intervals.
|
||||||
|
# So yes, bpm_stable should be True after 24 pulses.
|
||||||
|
|
||||||
|
def test_song_position(self, clock):
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
clock.process_song_position(96) # 96 beats
|
||||||
|
assert clock.state.song_position == 96
|
||||||
|
|
||||||
|
def test_reset(self, clock):
|
||||||
|
clock.process_message(0xFA)
|
||||||
|
for _ in range(24):
|
||||||
|
clock.process_message(0xF8)
|
||||||
|
|
||||||
|
clock.reset()
|
||||||
|
assert not clock.is_running
|
||||||
|
assert clock.tempo == 120.0
|
||||||
|
assert clock.state.pulse_count == 0
|
||||||
|
assert len(clock.state._pulse_intervals) == 0
|
||||||
|
|
||||||
|
def test_generate_clock_pulse(self, clock):
|
||||||
|
"""When not running, generate_clock_pulse returns None."""
|
||||||
|
assert clock.generate_clock_pulse() is None
|
||||||
|
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
interval = clock.generate_clock_pulse()
|
||||||
|
assert interval is not None
|
||||||
|
# At 120 BPM: interval = (60/120)/24 = 1/48 ≈ 0.0208s
|
||||||
|
assert interval == pytest.approx(1.0 / 48.0, abs=0.001)
|
||||||
|
|
||||||
|
def test_beat_callback(self, clock):
|
||||||
|
beats = []
|
||||||
|
clock.on_beat(lambda beat: beats.append(beat))
|
||||||
|
|
||||||
|
clock.process_message(0xFA) # START
|
||||||
|
for _ in range(48): # 2 beats
|
||||||
|
clock.process_message(0xF8)
|
||||||
|
|
||||||
|
assert beats == [1, 2]
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Tests for the core MIDI engine."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.midi.types import (
|
||||||
|
MIDIMessage,
|
||||||
|
MIDIMessageType,
|
||||||
|
MIDIMapping,
|
||||||
|
ParameterType,
|
||||||
|
)
|
||||||
|
from src.midi.midi_engine import MIDIEngine
|
||||||
|
|
||||||
|
|
||||||
|
class TestMIDIEngine:
|
||||||
|
@pytest.fixture
|
||||||
|
def engine(self):
|
||||||
|
return MIDIEngine()
|
||||||
|
|
||||||
|
def test_process_cc_event_matched(self, engine):
|
||||||
|
mapping = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
param_channel=0,
|
||||||
|
)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=64,
|
||||||
|
)
|
||||||
|
results = engine.process_event(msg)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
result_mapping, result_value = results[0]
|
||||||
|
assert result_mapping.param_type == ParameterType.VOLUME
|
||||||
|
assert result_mapping.param_channel == 0
|
||||||
|
assert 0.4 < result_value < 0.6 # 64/127 ≈ 0.504
|
||||||
|
|
||||||
|
def test_process_cc_event_no_match(self, engine):
|
||||||
|
mapping = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=10,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
results = engine.process_event(msg)
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
def test_multiple_mappings_same_cc(self, engine):
|
||||||
|
"""Two mappings for the same CC should both fire."""
|
||||||
|
m1 = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
param_channel=0,
|
||||||
|
)
|
||||||
|
m2 = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
param_type=ParameterType.PAN,
|
||||||
|
param_channel=0,
|
||||||
|
)
|
||||||
|
engine.set_mappings([m1, m2])
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
results = engine.process_event(msg)
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
def test_disabled_mapping_skipped(self, engine):
|
||||||
|
mapping = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
results = engine.process_event(msg)
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
def test_callback_fires(self, engine):
|
||||||
|
mapping = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
|
||||||
|
fired = []
|
||||||
|
engine.on_mapped(lambda m, v, r: fired.append((m.param_type, v)))
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=127,
|
||||||
|
)
|
||||||
|
engine.process_event(msg)
|
||||||
|
|
||||||
|
assert len(fired) == 1
|
||||||
|
|
||||||
|
def test_set_mappings_replaces(self, engine):
|
||||||
|
m1 = MIDIMapping(controller=7)
|
||||||
|
engine.set_mappings([m1])
|
||||||
|
assert len(engine.get_mappings()) == 1
|
||||||
|
|
||||||
|
m2 = MIDIMapping(controller=10)
|
||||||
|
engine.set_mappings([m2])
|
||||||
|
assert len(engine.get_mappings()) == 1
|
||||||
|
assert engine.get_mappings()[0].controller == 10
|
||||||
|
|
||||||
|
def test_remove_mapping(self, engine):
|
||||||
|
m = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
param_channel=0,
|
||||||
|
)
|
||||||
|
engine.add_mapping(m)
|
||||||
|
assert len(engine.get_mappings()) == 1
|
||||||
|
engine.remove_mapping(m)
|
||||||
|
assert len(engine.get_mappings()) == 0
|
||||||
|
|
||||||
|
def test_find_mappings_for(self, engine):
|
||||||
|
m1 = MIDIMapping(controller=7, param_type=ParameterType.VOLUME, param_channel=0)
|
||||||
|
m2 = MIDIMapping(controller=10, param_type=ParameterType.VOLUME, param_channel=0)
|
||||||
|
m3 = MIDIMapping(controller=11, param_type=ParameterType.PAN, param_channel=0)
|
||||||
|
engine.set_mappings([m1, m2, m3])
|
||||||
|
|
||||||
|
vol_mappings = engine.find_mappings_for(ParameterType.VOLUME, 0)
|
||||||
|
assert len(vol_mappings) == 2
|
||||||
|
|
||||||
|
def test_stats(self, engine):
|
||||||
|
engine.start()
|
||||||
|
mapping = MIDIMapping(controller=7)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=64,
|
||||||
|
)
|
||||||
|
engine.process_event(msg)
|
||||||
|
|
||||||
|
stats = engine.stats
|
||||||
|
assert stats["events_total"] == 10
|
||||||
|
assert stats["events_mapped"] == 10
|
||||||
|
assert stats["mappings_active"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestNRPNStateMachine:
|
||||||
|
@pytest.fixture
|
||||||
|
def engine(self):
|
||||||
|
return MIDIEngine()
|
||||||
|
|
||||||
|
def test_nrpn_complete_cycle(self, engine):
|
||||||
|
"""Full NRPN: CC 99 → CC 98 → CC 6 → CC 38."""
|
||||||
|
mapping = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_number=(0x01 << 7) | 0x02,
|
||||||
|
midi_min=0,
|
||||||
|
midi_max=16383, # 14-bit NRPN range
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
param_channel=0,
|
||||||
|
)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
|
||||||
|
# Step 1: NRPN MSB (CC 99)
|
||||||
|
r1 = engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01,
|
||||||
|
))
|
||||||
|
assert len(r1) == 0 # Intermediate
|
||||||
|
|
||||||
|
# Step 2: NRPN LSB (CC 98)
|
||||||
|
r2 = engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02,
|
||||||
|
))
|
||||||
|
assert len(r2) == 0 # Intermediate
|
||||||
|
|
||||||
|
# Step 3: Data Entry MSB (CC 6)
|
||||||
|
r3 = engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 6, 0x40,
|
||||||
|
))
|
||||||
|
assert len(r3) == 0 # Waiting for LSB
|
||||||
|
|
||||||
|
# Step 4: Data Entry LSB (CC 38)
|
||||||
|
r4 = engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 38, 0x20,
|
||||||
|
))
|
||||||
|
assert len(r4) == 1 # Complete!
|
||||||
|
mapping_out, value = r4[0]
|
||||||
|
assert mapping_out.is_nrpn
|
||||||
|
# Value = (0x40 << 7) | 0x20 = 0x2020 = 8224
|
||||||
|
# Scaled: 8224 / 16383 ≈ 0.502
|
||||||
|
assert value == pytest.approx(8224 / 16383, abs=0.01)
|
||||||
|
|
||||||
|
def test_nrpn_reset_on_regular_cc(self, engine):
|
||||||
|
"""Sending a regular CC after NRPN preamble should reset state."""
|
||||||
|
# Start NRPN
|
||||||
|
engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01,
|
||||||
|
))
|
||||||
|
engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Send a regular CC (should pass through and reset NRPN state)
|
||||||
|
mapping = MIDIMapping(controller=7)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
r = engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 7, 100,
|
||||||
|
))
|
||||||
|
assert len(r) == 1 # Regular CC passes through
|
||||||
|
assert not r[0][0].is_nrpn
|
||||||
|
|
||||||
|
def test_nrpn_incomplete_msb_lsb_ignored(self, engine):
|
||||||
|
"""CC 6 without prior CC 99/98 should not create NRPN."""
|
||||||
|
r = engine.process_event(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 6, 100,
|
||||||
|
))
|
||||||
|
assert len(r) == 0 # NRPN state incomplete
|
||||||
|
|
||||||
|
def test_nrpn_different_channels_independent(self, engine):
|
||||||
|
"""NRPN state is per-channel."""
|
||||||
|
# Channel 0 NRPN
|
||||||
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01))
|
||||||
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02))
|
||||||
|
|
||||||
|
# Channel 1 NRPN (should not interfere)
|
||||||
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 99, 0x03))
|
||||||
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 98, 0x04))
|
||||||
|
|
||||||
|
# Complete channel 1
|
||||||
|
mapping = MIDIMapping(
|
||||||
|
channel=1, # Match the message channel
|
||||||
|
is_nrpn=True, nrpn_number=(0x03 << 7) | 0x04,
|
||||||
|
midi_max=16383,
|
||||||
|
param_type=ParameterType.VOLUME, param_channel=1,
|
||||||
|
)
|
||||||
|
engine.add_mapping(mapping)
|
||||||
|
r = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 6, 50))
|
||||||
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 0))
|
||||||
|
assert len(r) == 0 # Wait for CC38
|
||||||
|
|
||||||
|
r2 = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 30))
|
||||||
|
assert len(r2) == 1
|
||||||
|
assert r2[0][0].nrpn_number == (0x03 << 7) | 0x04
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tests for MIDI learn mode."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.midi.types import (
|
||||||
|
MIDIMessage,
|
||||||
|
MIDIMessageType,
|
||||||
|
MIDIMapping,
|
||||||
|
ParameterType,
|
||||||
|
)
|
||||||
|
from src.midi.midi_learn import MIDILearn, LearnState
|
||||||
|
|
||||||
|
|
||||||
|
class TestMIDILearn:
|
||||||
|
@pytest.fixture
|
||||||
|
def learn(self):
|
||||||
|
return MIDILearn()
|
||||||
|
|
||||||
|
def test_initial_idle(self, learn):
|
||||||
|
assert learn.is_idle
|
||||||
|
assert not learn.is_listening
|
||||||
|
|
||||||
|
def test_start_learn_sets_listening(self, learn):
|
||||||
|
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||||
|
assert learn.is_listening
|
||||||
|
assert not learn.is_idle
|
||||||
|
assert "volume" in learn.session.param_type.value
|
||||||
|
|
||||||
|
def test_feed_cc_triggers_capture(self, learn):
|
||||||
|
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
result = learn.feed_message(msg)
|
||||||
|
|
||||||
|
assert result is None # Still in CAPTURED, waiting for confirm
|
||||||
|
assert learn.is_captured
|
||||||
|
assert learn.session.captured_cc == 7
|
||||||
|
assert learn.session.captured_channel == 0
|
||||||
|
|
||||||
|
def test_confirm_returns_mapping(self, learn):
|
||||||
|
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
learn.feed_message(msg)
|
||||||
|
mapping = learn.confirm()
|
||||||
|
|
||||||
|
assert mapping is not None
|
||||||
|
assert mapping.param_type == ParameterType.VOLUME
|
||||||
|
assert mapping.param_channel == 0
|
||||||
|
assert mapping.controller == 7
|
||||||
|
assert mapping.channel == 0
|
||||||
|
assert learn.is_idle # Reset after confirm
|
||||||
|
|
||||||
|
def test_confirm_without_capture_returns_none(self, learn):
|
||||||
|
assert learn.confirm() is None
|
||||||
|
|
||||||
|
def test_discard_resets(self, learn):
|
||||||
|
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||||
|
learn.feed_message(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 7, 100,
|
||||||
|
))
|
||||||
|
learn.discard()
|
||||||
|
assert learn.is_idle
|
||||||
|
|
||||||
|
def test_note_also_captured(self, learn):
|
||||||
|
"""Note On/Off messages should also be captured (for transport triggers)."""
|
||||||
|
learn.start_learn(ParameterType.PLAY)
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.NOTE_ON,
|
||||||
|
channel=0,
|
||||||
|
controller=60,
|
||||||
|
value=127,
|
||||||
|
)
|
||||||
|
learn.feed_message(msg)
|
||||||
|
mapping = learn.confirm()
|
||||||
|
|
||||||
|
assert mapping is not None
|
||||||
|
assert mapping.msg_type == MIDIMessageType.NOTE_ON
|
||||||
|
|
||||||
|
def test_nrpn_capture(self, learn):
|
||||||
|
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||||
|
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_msb=0x01,
|
||||||
|
nrpn_lsb=0x02,
|
||||||
|
value=8192,
|
||||||
|
)
|
||||||
|
learn.feed_message(msg)
|
||||||
|
mapping = learn.confirm()
|
||||||
|
|
||||||
|
assert mapping is not None
|
||||||
|
assert mapping.is_nrpn
|
||||||
|
assert mapping.nrpn_number == (0x01 << 7) | 0x02
|
||||||
|
|
||||||
|
def test_learned_callback_fires(self, learn):
|
||||||
|
fired = []
|
||||||
|
learn.on_learned(lambda m: fired.append(m))
|
||||||
|
|
||||||
|
learn.start_learn(ParameterType.VOLUME, 0)
|
||||||
|
learn.feed_message(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 7, 100,
|
||||||
|
))
|
||||||
|
learn.confirm()
|
||||||
|
|
||||||
|
assert len(fired) == 1
|
||||||
|
assert fired[0].controller == 7
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchLearn:
|
||||||
|
@pytest.fixture
|
||||||
|
def learn(self):
|
||||||
|
return MIDILearn()
|
||||||
|
|
||||||
|
def test_batch_learn_full_sequence(self, learn):
|
||||||
|
params = [
|
||||||
|
(ParameterType.VOLUME, 0, "CH0 Vol"),
|
||||||
|
(ParameterType.PAN, 0, "CH0 Pan"),
|
||||||
|
(ParameterType.MUTE, 0, "CH0 Mute"),
|
||||||
|
]
|
||||||
|
learn.start_batch(params)
|
||||||
|
assert learn.is_batch
|
||||||
|
assert learn.session.batch_index == 0
|
||||||
|
|
||||||
|
# Wiggle control 1 → volume
|
||||||
|
r1 = learn.feed_message(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 70, 100,
|
||||||
|
))
|
||||||
|
assert r1 is not None
|
||||||
|
assert r1.param_type == ParameterType.VOLUME
|
||||||
|
assert r1.controller == 70
|
||||||
|
assert learn.session.batch_index == 1 # Advanced
|
||||||
|
|
||||||
|
# Wiggle control 2 → pan
|
||||||
|
r2 = learn.feed_message(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 10, 64,
|
||||||
|
))
|
||||||
|
assert r2 is not None
|
||||||
|
assert r2.param_type == ParameterType.PAN
|
||||||
|
assert r2.controller == 10
|
||||||
|
|
||||||
|
# Wiggle control 3 → mute (last one)
|
||||||
|
r3 = learn.feed_message(MIDIMessage(
|
||||||
|
MIDIMessageType.CONTROL_CHANGE, 0, 1, 127,
|
||||||
|
))
|
||||||
|
assert r3 is not None
|
||||||
|
assert r3.param_type == ParameterType.MUTE
|
||||||
|
|
||||||
|
# Batch should be complete
|
||||||
|
assert learn.is_idle
|
||||||
|
assert len(learn.session.batch_mappings) == 3
|
||||||
|
|
||||||
|
def test_batch_ignores_extra_messages_after_complete(self, learn):
|
||||||
|
params = [(ParameterType.VOLUME, 0, "CH0 Vol")]
|
||||||
|
learn.start_batch(params)
|
||||||
|
|
||||||
|
learn.feed_message(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 70, 100))
|
||||||
|
# Batch done, extra message should return None
|
||||||
|
r = learn.feed_message(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 71, 100))
|
||||||
|
assert r is None
|
||||||
|
assert learn.is_idle
|
||||||
|
|
||||||
|
def test_status_text(self, learn):
|
||||||
|
learn.start_learn(ParameterType.VOLUME, 0)
|
||||||
|
assert "Listening" in learn.status_text
|
||||||
|
|
||||||
|
learn.feed_message(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 7, 100))
|
||||||
|
assert "Captured" in learn.status_text
|
||||||
|
assert "CC7" in learn.status_text
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Tests for OSC server."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.midi.types import ParameterType, MixerParameter, ParameterCategory
|
||||||
|
from src.mixer.osc_client import encode_osc, decode_osc
|
||||||
|
from src.network.osc_server import (
|
||||||
|
OSCServer,
|
||||||
|
_parse_osc_address,
|
||||||
|
_extract_value,
|
||||||
|
DEFAULT_OSC_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOSCAddressParsing:
|
||||||
|
"""Tests for OSC address parsing."""
|
||||||
|
|
||||||
|
def test_channel_volume(self):
|
||||||
|
result = _parse_osc_address("/mixer/channel/0/volume")
|
||||||
|
assert result is not None
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert cat == ParameterCategory.CHANNEL
|
||||||
|
assert pt == ParameterType.VOLUME
|
||||||
|
assert ch == 0
|
||||||
|
|
||||||
|
def test_channel_mute(self):
|
||||||
|
result = _parse_osc_address("/mixer/channel/5/mute")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert pt == ParameterType.MUTE
|
||||||
|
assert ch == 5
|
||||||
|
|
||||||
|
def test_channel_eq_low_gain(self):
|
||||||
|
result = _parse_osc_address("/mixer/channel/3/eq_low_gain")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert pt == ParameterType.EQ_LOW_GAIN
|
||||||
|
assert ch == 3
|
||||||
|
|
||||||
|
def test_channel_comp_ratio(self):
|
||||||
|
result = _parse_osc_address("/mixer/channel/7/comp_ratio")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert pt == ParameterType.COMP_RATIO
|
||||||
|
|
||||||
|
def test_master_volume(self):
|
||||||
|
result = _parse_osc_address("/mixer/master/volume")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert cat == ParameterCategory.MASTER
|
||||||
|
assert pt == ParameterType.MASTER_VOLUME
|
||||||
|
assert ch == -1
|
||||||
|
|
||||||
|
def test_master_mute(self):
|
||||||
|
result = _parse_osc_address("/mixer/master/mute")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert pt == ParameterType.MASTER_MUTE
|
||||||
|
|
||||||
|
def test_master_dim(self):
|
||||||
|
result = _parse_osc_address("/mixer/master/dim")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert pt == ParameterType.MASTER_DIM
|
||||||
|
|
||||||
|
def test_fx_return(self):
|
||||||
|
result = _parse_osc_address("/mixer/fx/return_a")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert cat == ParameterCategory.FX
|
||||||
|
assert pt == ParameterType.FX_RETURN_A
|
||||||
|
|
||||||
|
def test_transport_play(self):
|
||||||
|
result = _parse_osc_address("/mixer/transport/play")
|
||||||
|
cat, pt, ch, _ = result
|
||||||
|
assert cat == ParameterCategory.TRANSPORT
|
||||||
|
assert pt == ParameterType.PLAY
|
||||||
|
|
||||||
|
def test_transport_tempo(self):
|
||||||
|
result = _parse_osc_address("/mixer/transport/tempo")
|
||||||
|
_, pt, _, _ = result
|
||||||
|
assert pt == ParameterType.TEMPO
|
||||||
|
|
||||||
|
def test_utility_snapshot_load(self):
|
||||||
|
result = _parse_osc_address("/mixer/utility/snapshot_load")
|
||||||
|
_, pt, _, _ = result
|
||||||
|
assert pt == ParameterType.SNAPSHOT_LOAD
|
||||||
|
|
||||||
|
def test_unknown_address(self):
|
||||||
|
result = _parse_osc_address("/mixer/nonexistent/path")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_unknown_param_name(self):
|
||||||
|
result = _parse_osc_address("/mixer/channel/0/invalid_param")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_invalid_channel_number(self):
|
||||||
|
result = _parse_osc_address("/mixer/channel/abc/volume")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractValue:
|
||||||
|
"""Tests for _extract_value."""
|
||||||
|
|
||||||
|
def test_float(self):
|
||||||
|
assert _extract_value([3.14]) == pytest.approx(3.14)
|
||||||
|
|
||||||
|
def test_int(self):
|
||||||
|
assert _extract_value([42]) == 42.0
|
||||||
|
|
||||||
|
def test_bool_true(self):
|
||||||
|
assert _extract_value([True]) == 1.0
|
||||||
|
|
||||||
|
def test_bool_false(self):
|
||||||
|
assert _extract_value([False]) == 0.0
|
||||||
|
|
||||||
|
def test_string_number(self):
|
||||||
|
assert _extract_value(["0.75"]) == 0.75
|
||||||
|
|
||||||
|
def test_empty_args(self):
|
||||||
|
assert _extract_value([]) == 0.0
|
||||||
|
|
||||||
|
def test_multiple_args_uses_first(self):
|
||||||
|
assert _extract_value([0.5, 1.0, 2.0]) == 0.5
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
assert _extract_value([0.0]) == 0.0
|
||||||
|
|
||||||
|
def test_negative(self):
|
||||||
|
assert _extract_value([-6.0]) == -6.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestOSCServer:
|
||||||
|
"""Tests for the async OSC server.
|
||||||
|
|
||||||
|
Each test manages its own server lifecycle using asyncio.run().
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_udp(packet: bytes, host: str, port: int) -> None:
|
||||||
|
"""Send a UDP packet."""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
sock.sendto(packet, (host, port))
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_free_port() -> int:
|
||||||
|
"""Find a free UDP port."""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
async def _create_server(self, received: list, port: int | None = None) -> OSCServer:
|
||||||
|
"""Create and start a server, returning it."""
|
||||||
|
port = port or self._find_free_port()
|
||||||
|
server = OSCServer(host="127.0.0.1", port=port)
|
||||||
|
server.set_dispatcher(lambda p, v: received.append((p, v)))
|
||||||
|
await server.start()
|
||||||
|
return server
|
||||||
|
|
||||||
|
# ── Test runners (synchronous entry points) ──────────────────────
|
||||||
|
|
||||||
|
def test_receive_volume(self):
|
||||||
|
async def run():
|
||||||
|
received = []
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = await self._create_server(received, port)
|
||||||
|
try:
|
||||||
|
packet = encode_osc("/mixer/channel/0/volume", -3.0)
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
param, value = received[0]
|
||||||
|
assert param.param_type == ParameterType.VOLUME
|
||||||
|
assert param.channel == 0
|
||||||
|
assert value == pytest.approx(-3.0)
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_receive_mute(self):
|
||||||
|
async def run():
|
||||||
|
received = []
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = await self._create_server(received, port)
|
||||||
|
try:
|
||||||
|
packet = encode_osc("/mixer/channel/2/mute", True)
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
param, value = received[0]
|
||||||
|
assert param.param_type == ParameterType.MUTE
|
||||||
|
assert param.channel == 2
|
||||||
|
assert value == 1.0
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_receive_master_volume(self):
|
||||||
|
async def run():
|
||||||
|
received = []
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = await self._create_server(received, port)
|
||||||
|
try:
|
||||||
|
packet = encode_osc("/mixer/master/volume", -10.0)
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
param, value = received[0]
|
||||||
|
assert param.param_type == ParameterType.MASTER_VOLUME
|
||||||
|
assert value == pytest.approx(-10.0)
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_receive_transport(self):
|
||||||
|
async def run():
|
||||||
|
received = []
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = await self._create_server(received, port)
|
||||||
|
try:
|
||||||
|
packet = encode_osc("/mixer/transport/play", 1.0)
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
param, _ = received[0]
|
||||||
|
assert param.param_type == ParameterType.PLAY
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_receive_multiple(self):
|
||||||
|
async def run():
|
||||||
|
received = []
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = await self._create_server(received, port)
|
||||||
|
try:
|
||||||
|
msgs = [
|
||||||
|
encode_osc("/mixer/channel/0/volume", -6.0),
|
||||||
|
encode_osc("/mixer/channel/0/pan", 0.5),
|
||||||
|
encode_osc("/mixer/channel/1/mute", 1.0),
|
||||||
|
]
|
||||||
|
for packet in msgs:
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
|
||||||
|
assert len(received) == 3
|
||||||
|
types = [p.param_type for p, _ in received]
|
||||||
|
assert ParameterType.VOLUME in types
|
||||||
|
assert ParameterType.PAN in types
|
||||||
|
assert ParameterType.MUTE in types
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_unknown_address_not_dispatched(self):
|
||||||
|
async def run():
|
||||||
|
received = []
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = await self._create_server(received, port)
|
||||||
|
try:
|
||||||
|
packet = encode_osc("/unknown/path", 1.0)
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert len(received) == 0
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_stats(self):
|
||||||
|
async def run():
|
||||||
|
received = []
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = await self._create_server(received, port)
|
||||||
|
try:
|
||||||
|
packet = encode_osc("/mixer/channel/0/volume", 0.0)
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
stats = server.stats
|
||||||
|
assert stats["running"] is True
|
||||||
|
assert stats["messages"] >= 1
|
||||||
|
assert stats["host"] == "127.0.0.1"
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_no_dispatcher_no_crash(self):
|
||||||
|
async def run():
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = OSCServer(host="127.0.0.1", port=port)
|
||||||
|
await server.start()
|
||||||
|
try:
|
||||||
|
packet = encode_osc("/mixer/channel/0/volume", 0.0)
|
||||||
|
self._send_udp(packet, "127.0.0.1", port)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_start_stop(self):
|
||||||
|
async def run():
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = OSCServer(host="127.0.0.1", port=port)
|
||||||
|
assert not server.running
|
||||||
|
|
||||||
|
await server.start()
|
||||||
|
assert server.running
|
||||||
|
|
||||||
|
await server.stop()
|
||||||
|
assert not server.running
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_double_start(self):
|
||||||
|
async def run():
|
||||||
|
port = self._find_free_port()
|
||||||
|
server = OSCServer(host="127.0.0.1", port=port)
|
||||||
|
await server.start()
|
||||||
|
try:
|
||||||
|
await server.start() # should be idempotent
|
||||||
|
assert server.running
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
asyncio.run(run())
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Tests for mixer parameter registry."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.mixer import ParameterRegistry
|
||||||
|
from src.midi.types import ParameterType, ParameterCategory
|
||||||
|
|
||||||
|
|
||||||
|
class TestParameterRegistry:
|
||||||
|
@pytest.fixture
|
||||||
|
def registry(self):
|
||||||
|
return ParameterRegistry(channels=8)
|
||||||
|
|
||||||
|
def test_all_parameters_built(self, registry):
|
||||||
|
"""Registry should have all params (master + channels + fx + transport + utility)."""
|
||||||
|
params = list(registry.iter_all())
|
||||||
|
# Master: 5, FX: 4, Transport: 6, Utility: 4, 8 channels * 25 = 200
|
||||||
|
# Total: 5 + 4 + 6 + 4 + 200 = 219
|
||||||
|
assert len(params) == 219
|
||||||
|
|
||||||
|
def test_get_master_volume(self, registry):
|
||||||
|
p = registry.get(ParameterType.MASTER_VOLUME)
|
||||||
|
assert p is not None
|
||||||
|
assert p.param_type == ParameterType.MASTER_VOLUME
|
||||||
|
assert p.channel == -1
|
||||||
|
assert p.max_val == 12.0
|
||||||
|
|
||||||
|
def test_get_channel_volume(self, registry):
|
||||||
|
p = registry.get(ParameterType.VOLUME, channel=3)
|
||||||
|
assert p is not None
|
||||||
|
assert p.param_type == ParameterType.VOLUME
|
||||||
|
assert p.channel == 3
|
||||||
|
assert p.min_val == -60.0
|
||||||
|
|
||||||
|
def test_get_nonexistent(self, registry):
|
||||||
|
p = registry.get(ParameterType.VOLUME, channel=999)
|
||||||
|
assert p is None
|
||||||
|
|
||||||
|
def test_iter_by_category(self, registry):
|
||||||
|
channel_params = list(registry.iter_by_category(ParameterCategory.CHANNEL))
|
||||||
|
# 8 channels * 25 params = 200
|
||||||
|
assert len(channel_params) == 8 * 25
|
||||||
|
|
||||||
|
master_params = list(registry.iter_by_category(ParameterCategory.MASTER))
|
||||||
|
assert len(master_params) == 5
|
||||||
|
|
||||||
|
def test_iter_by_channel(self, registry):
|
||||||
|
ch0 = list(registry.iter_by_channel(0))
|
||||||
|
assert len(ch0) == 25 # Full channel strip
|
||||||
|
|
||||||
|
ch5 = list(registry.iter_by_channel(5))
|
||||||
|
assert len(ch5) == 25
|
||||||
|
|
||||||
|
def test_set_value_and_callback(self, registry):
|
||||||
|
values = []
|
||||||
|
registry.subscribe(lambda param, value: values.append((param.param_type, value)))
|
||||||
|
|
||||||
|
registry.set_value(ParameterType.VOLUME, 2, 0.75)
|
||||||
|
|
||||||
|
assert len(values) == 1
|
||||||
|
assert values[0][0] == ParameterType.VOLUME
|
||||||
|
assert values[0][1] == 0.75
|
||||||
|
|
||||||
|
def test_unsubscribe(self, registry):
|
||||||
|
values = []
|
||||||
|
|
||||||
|
def cb(param, value):
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
registry.subscribe(cb)
|
||||||
|
registry.set_value(ParameterType.VOLUME, 0, 0.5)
|
||||||
|
assert len(values) == 1
|
||||||
|
|
||||||
|
registry.unsubscribe(cb)
|
||||||
|
registry.set_value(ParameterType.VOLUME, 0, 0.8)
|
||||||
|
assert len(values) == 1 # Not called after unsubscribe
|
||||||
|
|
||||||
|
def test_set_nonexistent_parameter_no_error(self, registry):
|
||||||
|
"""Setting a parameter that doesn't exist should not crash."""
|
||||||
|
registry.set_value(ParameterType.VOLUME, 999, 0.5) # No error
|
||||||
|
|
||||||
|
def test_broken_callback_doesnt_block_others(self, registry):
|
||||||
|
def broken(param, value):
|
||||||
|
raise RuntimeError("broken")
|
||||||
|
|
||||||
|
good_values = []
|
||||||
|
def good(param, value):
|
||||||
|
good_values.append(value)
|
||||||
|
|
||||||
|
registry.subscribe(broken)
|
||||||
|
registry.subscribe(good)
|
||||||
|
|
||||||
|
registry.set_value(ParameterType.VOLUME, 0, 0.5)
|
||||||
|
assert len(good_values) == 1
|
||||||
|
assert good_values[0] == 0.5
|
||||||
|
|
||||||
|
def test_stereo_pan_parameters(self, registry):
|
||||||
|
p = registry.get(ParameterType.PAN, channel=0)
|
||||||
|
assert p is not None
|
||||||
|
assert p.min_val == -1.0
|
||||||
|
assert p.max_val == 1.0
|
||||||
|
assert p.default_val == 0.0
|
||||||
|
|
||||||
|
def test_mute_is_boolean(self, registry):
|
||||||
|
p = registry.get(ParameterType.MUTE, channel=0)
|
||||||
|
assert p is not None
|
||||||
|
assert p.min_val == 0.0
|
||||||
|
assert p.max_val == 1.0
|
||||||
|
assert p.step == 1.0 # Boolean-like
|
||||||
|
|
||||||
|
def test_transport_params(self, registry):
|
||||||
|
play = registry.get(ParameterType.PLAY)
|
||||||
|
stop = registry.get(ParameterType.STOP)
|
||||||
|
assert play is not None
|
||||||
|
assert stop is not None
|
||||||
|
assert play.step == 1.0 # Triggered
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Tests for token bucket rate limiter."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Depends, Request
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.network.rate_limiter import RateLimiter, TokenBucket, rate_limit
|
||||||
|
from src.network.auth import API_KEY_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenBucket:
|
||||||
|
"""Tests for TokenBucket."""
|
||||||
|
|
||||||
|
def test_initial_tokens(self):
|
||||||
|
bucket = TokenBucket(rate=10.0, capacity=20)
|
||||||
|
assert bucket.available == 20.0
|
||||||
|
|
||||||
|
def test_consume_reduces_tokens(self):
|
||||||
|
bucket = TokenBucket(rate=10.0, capacity=20)
|
||||||
|
assert bucket.consume(1)
|
||||||
|
# available may refill a tiny amount based on timing
|
||||||
|
assert bucket.available == pytest.approx(19.0, abs=0.1)
|
||||||
|
|
||||||
|
def test_consume_multiple(self):
|
||||||
|
bucket = TokenBucket(rate=10.0, capacity=20)
|
||||||
|
assert bucket.consume(5)
|
||||||
|
assert bucket.available == pytest.approx(15.0, abs=0.1)
|
||||||
|
|
||||||
|
def test_cannot_consume_more_than_available(self):
|
||||||
|
bucket = TokenBucket(rate=10.0, capacity=5)
|
||||||
|
# Consume all
|
||||||
|
for _ in range(5):
|
||||||
|
assert bucket.consume(1)
|
||||||
|
# No more
|
||||||
|
assert not bucket.consume(1)
|
||||||
|
|
||||||
|
def test_refill_over_time(self):
|
||||||
|
bucket = TokenBucket(rate=100.0, capacity=100)
|
||||||
|
# Drain all tokens
|
||||||
|
for _ in range(100):
|
||||||
|
assert bucket.consume(1)
|
||||||
|
|
||||||
|
assert bucket.available < 1.0
|
||||||
|
|
||||||
|
# Wait for refill
|
||||||
|
time.sleep(0.05) # 50ms → ~5 tokens at 100/s
|
||||||
|
assert bucket.available >= 4.0 # allow slight timing variance
|
||||||
|
|
||||||
|
def test_capacity_cap(self):
|
||||||
|
bucket = TokenBucket(rate=1000.0, capacity=10)
|
||||||
|
time.sleep(0.1) # 100ms → 100 tokens, but capped at 10
|
||||||
|
assert bucket.available <= 10.0 + 0.01 # small epsilon
|
||||||
|
|
||||||
|
def test_zero_rate(self):
|
||||||
|
bucket = TokenBucket(rate=0.0, capacity=10)
|
||||||
|
for _ in range(10):
|
||||||
|
assert bucket.consume(1)
|
||||||
|
# No refill
|
||||||
|
assert not bucket.consume(1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiter:
|
||||||
|
"""Tests for RateLimiter."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def limiter(self):
|
||||||
|
return RateLimiter(rate=100.0, capacity=200, eviction_timeout=0.5)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_rest_allows_first_request(self, limiter):
|
||||||
|
"""First request from an IP should be allowed."""
|
||||||
|
# We can't easily test this without a FastAPI request,
|
||||||
|
# but we can test the bucket directly.
|
||||||
|
# The check_rest method needs a Request object, so we test
|
||||||
|
# via FastAPI integration below.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimitIntegration:
|
||||||
|
"""Integration tests for rate limiting with FastAPI."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self):
|
||||||
|
limiter = RateLimiter(rate=50.0, capacity=100)
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/limited")
|
||||||
|
async def limited(_: None = Depends(rate_limit(limiter))):
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/unlimited")
|
||||||
|
async def unlimited():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_unlimited_endpoint(self, client):
|
||||||
|
resp = client.get("/unlimited")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_limited_endpoint_allows_requests(self, client):
|
||||||
|
"""Rate-limited endpoint should allow normal traffic."""
|
||||||
|
for _ in range(10):
|
||||||
|
resp = client.get("/limited")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_rate_limit_headers_on_client(self, client):
|
||||||
|
"""Basic sanity — requests should work."""
|
||||||
|
resp = client.get("/limited")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
def test_multiple_requests_within_limit(self, client):
|
||||||
|
"""Multiple requests within the rate limit should all succeed."""
|
||||||
|
for i in range(50):
|
||||||
|
resp = client.get("/limited")
|
||||||
|
assert resp.status_code == 200, f"Request {i} failed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiterStats:
|
||||||
|
"""Tests for rate limiter statistics."""
|
||||||
|
|
||||||
|
def test_initial_stats(self):
|
||||||
|
limiter = RateLimiter(rate=50.0, capacity=100)
|
||||||
|
assert limiter.active_connections == 0
|
||||||
|
stats = limiter.stats
|
||||||
|
assert stats["active_clients"] == 0
|
||||||
|
assert stats["rest_rate"] == 50.0
|
||||||
|
assert stats["rest_capacity"] == 100
|
||||||
|
|
||||||
|
def test_ws_rates(self):
|
||||||
|
limiter = RateLimiter(ws_rate=500.0, ws_capacity=1000)
|
||||||
|
stats = limiter.stats
|
||||||
|
assert stats["ws_rate"] == 500.0
|
||||||
|
assert stats["ws_capacity"] == 1000
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Tests for the REST API endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.network.auth import APIKeyAuth, API_KEY_HEADER
|
||||||
|
from src.network.rate_limiter import RateLimiter
|
||||||
|
from src.network.rest_api import MixerStateProvider, create_router
|
||||||
|
from src.network.schemas import (
|
||||||
|
ChannelSchema,
|
||||||
|
MasterBusSchema,
|
||||||
|
MixerStateSchema,
|
||||||
|
TransportSchema,
|
||||||
|
PluginSchema,
|
||||||
|
RoutingSchema,
|
||||||
|
EQSchema,
|
||||||
|
CompressorSchema,
|
||||||
|
GateSchema,
|
||||||
|
FXSendsSchema,
|
||||||
|
AuxBusSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRESTAPI:
|
||||||
|
"""Integration tests for the REST API."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def state_provider(self):
|
||||||
|
"""Create a mock state provider."""
|
||||||
|
sp = MixerStateProvider()
|
||||||
|
|
||||||
|
# Mock data
|
||||||
|
channels = [
|
||||||
|
ChannelSchema(channel=i, label=f"CH{i+1}", volume_db=0.0)
|
||||||
|
for i in range(16)
|
||||||
|
]
|
||||||
|
sp.get_all_channels = lambda: channels
|
||||||
|
sp.get_channel = lambda idx: channels[idx] if 0 <= idx < len(channels) else None
|
||||||
|
sp.get_master = lambda: MasterBusSchema(
|
||||||
|
volume_db=0.0,
|
||||||
|
aux_buses=[AuxBusSchema(index=0, label="Reverb")],
|
||||||
|
)
|
||||||
|
sp.get_transport = lambda: TransportSchema(playing=False)
|
||||||
|
sp.get_plugins = lambda: [
|
||||||
|
PluginSchema(plugin_id=1, name="Ch1 Gate", role="gate", channel=0),
|
||||||
|
]
|
||||||
|
sp.get_routing = lambda: RoutingSchema()
|
||||||
|
sp.list_scenes = lambda: ["Scene A", "Scene B"]
|
||||||
|
sp.load_scene = lambda name: name in ["Scene A", "Scene B"]
|
||||||
|
sp.save_scene = lambda name: True
|
||||||
|
|
||||||
|
# Parameter handler captures
|
||||||
|
sp._param_calls = []
|
||||||
|
sp.handle_parameter = lambda pt, v, ch: sp._param_calls.append((pt, v, ch))
|
||||||
|
|
||||||
|
# Transport handler captures
|
||||||
|
sp._transport_calls = []
|
||||||
|
sp.handle_transport = lambda cmd: sp._transport_calls.append(cmd)
|
||||||
|
|
||||||
|
return sp
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth(self):
|
||||||
|
return APIKeyAuth(keys=["test-api-key"])
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rate_limiter(self):
|
||||||
|
return RateLimiter(rate=1000.0, capacity=2000)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self, state_provider, auth, rate_limiter):
|
||||||
|
app = FastAPI()
|
||||||
|
router = create_router(
|
||||||
|
state=state_provider,
|
||||||
|
auth=auth,
|
||||||
|
rate_limiter=rate_limiter,
|
||||||
|
prefix="/api/v1",
|
||||||
|
)
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
# Health endpoint (no auth)
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Full state endpoint on app directly (no auth for test)
|
||||||
|
@app.get("/api/v1/state")
|
||||||
|
async def full_state():
|
||||||
|
return state_provider.get_full_state() if state_provider.get_full_state else {}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(self):
|
||||||
|
return {API_KEY_HEADER: "test-api-key"}
|
||||||
|
|
||||||
|
# ── Auth ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_health_no_auth(self, client):
|
||||||
|
resp = client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_channels_no_auth(self, client):
|
||||||
|
resp = client.get("/api/v1/channels")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_channels_wrong_key(self, client):
|
||||||
|
resp = client.get("/api/v1/channels", headers={API_KEY_HEADER: "wrong"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# ── Channels ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_list_channels(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/channels", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 16
|
||||||
|
assert data[0]["channel"] == 0
|
||||||
|
|
||||||
|
def test_get_channel_valid(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/channels/3", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["channel"] == 3
|
||||||
|
|
||||||
|
def test_get_channel_not_found(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/channels/99", headers=auth_headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_set_channel_parameter(self, client, auth_headers, state_provider):
|
||||||
|
body = {"param_type": "volume", "value": -3.0}
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/channels/0/parameter",
|
||||||
|
json=body,
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
assert len(state_provider._param_calls) == 1
|
||||||
|
assert state_provider._param_calls[0] == ("volume", -3.0, 0)
|
||||||
|
|
||||||
|
def test_set_channel_parameters_batch(self, client, auth_headers, state_provider):
|
||||||
|
body = {
|
||||||
|
"updates": [
|
||||||
|
{"param_type": "volume", "value": -6.0, "channel": 0},
|
||||||
|
{"param_type": "pan", "value": 0.5, "channel": 0},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/channels/0/parameters",
|
||||||
|
json=body,
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
assert len(state_provider._param_calls) == 2
|
||||||
|
|
||||||
|
# ── Mixes / Master ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_mixes(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/mixes", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "volume_db" in data
|
||||||
|
assert len(data["aux_buses"]) == 1
|
||||||
|
|
||||||
|
def test_set_master_parameter(self, client, auth_headers, state_provider):
|
||||||
|
body = {"param_type": "master_volume", "value": -6.0}
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/mixes/parameter",
|
||||||
|
json=body,
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(state_provider._param_calls) == 1
|
||||||
|
assert state_provider._param_calls[0] == ("master_volume", -6.0, -1)
|
||||||
|
|
||||||
|
# ── Plugins ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_list_plugins(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/plugins", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["name"] == "Ch1 Gate"
|
||||||
|
|
||||||
|
# ── Transport ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_transport(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/transport", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "playing" in data
|
||||||
|
assert "tempo_bpm" in data
|
||||||
|
|
||||||
|
def test_transport_command(self, client, auth_headers, state_provider):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/transport/command?command=play",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(state_provider._transport_calls) == 1
|
||||||
|
assert state_provider._transport_calls[0] == "play"
|
||||||
|
|
||||||
|
# ── Routing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_routing(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/routing", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "nodes" in data
|
||||||
|
assert "edges" in data
|
||||||
|
|
||||||
|
# ── Scenes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_list_scenes(self, client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/scenes", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data == ["Scene A", "Scene B"]
|
||||||
|
|
||||||
|
def test_load_scene_valid(self, client, auth_headers):
|
||||||
|
resp = client.post("/api/v1/scenes/Scene A/load", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
|
||||||
|
def test_load_scene_not_found(self, client, auth_headers):
|
||||||
|
resp = client.post("/api/v1/scenes/Nonexistent/load", headers=auth_headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_save_scene(self, client, auth_headers):
|
||||||
|
resp = client.post("/api/v1/scenes/New Scene/save", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Tests for JSON schemas / Pydantic models."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.network.schemas import (
|
||||||
|
ChannelSchema,
|
||||||
|
MasterBusSchema,
|
||||||
|
MixerStateSchema,
|
||||||
|
PluginSchema,
|
||||||
|
TransportSchema,
|
||||||
|
RoutingSchema,
|
||||||
|
AuxBusSchema,
|
||||||
|
SubgroupSchema,
|
||||||
|
VCASchema,
|
||||||
|
EQSchema,
|
||||||
|
EQBandSchema,
|
||||||
|
CompressorSchema,
|
||||||
|
GateSchema,
|
||||||
|
FXSendsSchema,
|
||||||
|
ParameterUpdateSchema,
|
||||||
|
ParameterUpdateBatchSchema,
|
||||||
|
ParameterSetRequest,
|
||||||
|
WSMessage,
|
||||||
|
WSMessageType,
|
||||||
|
MixerParameterSchema,
|
||||||
|
ParameterCategoryEnum,
|
||||||
|
ParameterTypeEnum,
|
||||||
|
RouteNodeSchema,
|
||||||
|
RoutingEdgeSchema,
|
||||||
|
NodeTypeEnum,
|
||||||
|
APIResponse,
|
||||||
|
PluginParamMapSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelSchema:
|
||||||
|
"""Tests for ChannelSchema."""
|
||||||
|
|
||||||
|
def test_default_channel(self):
|
||||||
|
ch = ChannelSchema(channel=0)
|
||||||
|
assert ch.channel == 0
|
||||||
|
assert ch.volume_db == 0.0
|
||||||
|
assert ch.pan == 0.0
|
||||||
|
assert not ch.mute
|
||||||
|
assert not ch.solo
|
||||||
|
|
||||||
|
def test_channel_with_eq(self):
|
||||||
|
ch = ChannelSchema(
|
||||||
|
channel=3,
|
||||||
|
volume_db=-6.0,
|
||||||
|
eq=EQSchema(
|
||||||
|
enabled=True,
|
||||||
|
low=EQBandSchema(freq_hz=80.0, gain_db=3.0, q=1.0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert ch.eq.enabled
|
||||||
|
assert ch.eq.low.freq_hz == 80.0
|
||||||
|
assert ch.eq.low.gain_db == 3.0
|
||||||
|
|
||||||
|
def test_serialize_deserialize(self):
|
||||||
|
ch = ChannelSchema(channel=5, volume_db=-3.0, pan=0.5)
|
||||||
|
data = ch.model_dump()
|
||||||
|
ch2 = ChannelSchema.model_validate(data)
|
||||||
|
assert ch2.channel == 5
|
||||||
|
assert ch2.volume_db == -3.0
|
||||||
|
assert ch2.pan == 0.5
|
||||||
|
|
||||||
|
def test_json_roundtrip(self):
|
||||||
|
ch = ChannelSchema(channel=0, mute=True)
|
||||||
|
json_str = ch.model_dump_json()
|
||||||
|
data = json.loads(json_str)
|
||||||
|
assert data["channel"] == 0
|
||||||
|
assert data["mute"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestMasterBusSchema:
|
||||||
|
"""Tests for MasterBusSchema."""
|
||||||
|
|
||||||
|
def test_default_master(self):
|
||||||
|
m = MasterBusSchema()
|
||||||
|
assert m.volume_db == 0.0
|
||||||
|
assert not m.muted
|
||||||
|
assert not m.dim_active
|
||||||
|
assert m.aux_buses == []
|
||||||
|
|
||||||
|
def test_with_buses(self):
|
||||||
|
m = MasterBusSchema(
|
||||||
|
volume_db=-10.0,
|
||||||
|
aux_buses=[
|
||||||
|
AuxBusSchema(index=0, label="Reverb"),
|
||||||
|
AuxBusSchema(index=1, label="Delay"),
|
||||||
|
],
|
||||||
|
subgroups=[
|
||||||
|
SubgroupSchema(index=0, label="Drums", members=[0, 1, 2]),
|
||||||
|
],
|
||||||
|
vca_groups=[
|
||||||
|
VCASchema(index=0, label="All Vocals", master_db=-3.0, members=[3, 4, 5]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert len(m.aux_buses) == 2
|
||||||
|
assert m.aux_buses[0].label == "Reverb"
|
||||||
|
assert len(m.subgroups) == 1
|
||||||
|
assert m.subgroups[0].members == [0, 1, 2]
|
||||||
|
assert m.vca_groups[0].master_db == -3.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransportSchema:
|
||||||
|
"""Tests for TransportSchema."""
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
t = TransportSchema()
|
||||||
|
assert not t.playing
|
||||||
|
assert not t.recording
|
||||||
|
assert t.tempo_bpm == 120.0
|
||||||
|
assert t.position_sec == 0.0
|
||||||
|
|
||||||
|
def test_playing_state(self):
|
||||||
|
t = TransportSchema(playing=True, tempo_bpm=142.0, position_sec=35.2)
|
||||||
|
assert t.playing
|
||||||
|
assert not t.recording
|
||||||
|
assert t.tempo_bpm == 142.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestMixerStateSchema:
|
||||||
|
"""Tests for full MixerStateSchema."""
|
||||||
|
|
||||||
|
def test_empty_state(self):
|
||||||
|
state = MixerStateSchema()
|
||||||
|
assert state.channels == []
|
||||||
|
assert state.master.volume_db == 0.0
|
||||||
|
assert state.transport.tempo_bpm == 120.0
|
||||||
|
|
||||||
|
def test_full_state(self):
|
||||||
|
state = MixerStateSchema(
|
||||||
|
channels=[
|
||||||
|
ChannelSchema(channel=0, volume_db=-6.0),
|
||||||
|
ChannelSchema(channel=1, mute=True),
|
||||||
|
],
|
||||||
|
master=MasterBusSchema(volume_db=-3.0),
|
||||||
|
transport=TransportSchema(playing=True),
|
||||||
|
scenes=["Intro", "Verse", "Chorus"],
|
||||||
|
uptime_seconds=3600.0,
|
||||||
|
)
|
||||||
|
assert len(state.channels) == 2
|
||||||
|
assert state.scenes == ["Intro", "Verse", "Chorus"]
|
||||||
|
assert state.uptime_seconds == 3600.0
|
||||||
|
|
||||||
|
def test_json_roundtrip(self):
|
||||||
|
state = MixerStateSchema(
|
||||||
|
channels=[ChannelSchema(channel=0)],
|
||||||
|
uptime_seconds=10.0,
|
||||||
|
)
|
||||||
|
json_str = state.model_dump_json()
|
||||||
|
data = json.loads(json_str)
|
||||||
|
assert data["uptime_seconds"] == 10.0
|
||||||
|
assert len(data["channels"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestParameterUpdate:
|
||||||
|
"""Tests for parameter update schemas."""
|
||||||
|
|
||||||
|
def test_single_update(self):
|
||||||
|
update = ParameterUpdateSchema(
|
||||||
|
param_type=ParameterTypeEnum.VOLUME,
|
||||||
|
value=-3.0,
|
||||||
|
channel=2,
|
||||||
|
)
|
||||||
|
assert update.param_type == ParameterTypeEnum.VOLUME
|
||||||
|
assert update.value == -3.0
|
||||||
|
assert update.channel == 2
|
||||||
|
|
||||||
|
def test_batch_update(self):
|
||||||
|
batch = ParameterUpdateBatchSchema(updates=[
|
||||||
|
ParameterUpdateSchema(param_type=ParameterTypeEnum.VOLUME, value=-6.0, channel=0),
|
||||||
|
ParameterUpdateSchema(param_type=ParameterTypeEnum.MUTE, value=1.0, channel=0),
|
||||||
|
ParameterUpdateSchema(param_type=ParameterTypeEnum.MASTER_VOLUME, value=-3.0),
|
||||||
|
])
|
||||||
|
assert len(batch.updates) == 3
|
||||||
|
assert batch.updates[2].param_type == ParameterTypeEnum.MASTER_VOLUME
|
||||||
|
|
||||||
|
|
||||||
|
class TestWSMessage:
|
||||||
|
"""Tests for WebSocket message schemas."""
|
||||||
|
|
||||||
|
def test_parameter_update_message(self):
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.PARAMETER_UPDATE,
|
||||||
|
payload={"param_type": "volume", "value": -3.0, "channel": 1},
|
||||||
|
)
|
||||||
|
assert msg.type == WSMessageType.PARAMETER_UPDATE
|
||||||
|
assert msg.payload["param_type"] == "volume"
|
||||||
|
|
||||||
|
def test_full_state_message(self):
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.FULL_STATE,
|
||||||
|
payload={"channels": []},
|
||||||
|
)
|
||||||
|
data = msg.model_dump()
|
||||||
|
assert data["type"] == "full_state"
|
||||||
|
|
||||||
|
def test_error_message(self):
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.ERROR,
|
||||||
|
payload={"message": "Something went wrong"},
|
||||||
|
)
|
||||||
|
assert msg.payload["message"] == "Something went wrong"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParameterSetRequest:
|
||||||
|
"""Tests for ParameterSetRequest."""
|
||||||
|
|
||||||
|
def test_default_param_type(self):
|
||||||
|
req = ParameterSetRequest(value=-3.0)
|
||||||
|
assert req.param_type == "volume"
|
||||||
|
assert req.value == -3.0
|
||||||
|
|
||||||
|
def test_explicit_param_type(self):
|
||||||
|
req = ParameterSetRequest(param_type="mute", value=1.0)
|
||||||
|
assert req.param_type == "mute"
|
||||||
|
|
||||||
|
def test_json_parse(self):
|
||||||
|
json_str = '{"param_type": "pan", "value": 0.5}'
|
||||||
|
req = ParameterSetRequest.model_validate_json(json_str)
|
||||||
|
assert req.param_type == "pan"
|
||||||
|
assert req.value == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginSchema:
|
||||||
|
"""Tests for PluginSchema."""
|
||||||
|
|
||||||
|
def test_plugin(self):
|
||||||
|
p = PluginSchema(
|
||||||
|
plugin_id=1,
|
||||||
|
name="Ch1 Gate",
|
||||||
|
role="gate",
|
||||||
|
channel=0,
|
||||||
|
params=[
|
||||||
|
PluginParamMapSchema(name="threshold", index=0, value=0.5),
|
||||||
|
PluginParamMapSchema(name="ratio", index=1, value=0.3),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert p.plugin_id == 1
|
||||||
|
assert len(p.params) == 2
|
||||||
|
assert p.params[0].name == "threshold"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutingSchema:
|
||||||
|
"""Tests for RoutingSchema."""
|
||||||
|
|
||||||
|
def test_empty_routing(self):
|
||||||
|
r = RoutingSchema()
|
||||||
|
assert r.nodes == []
|
||||||
|
assert r.edges == []
|
||||||
|
|
||||||
|
def test_with_nodes_and_edges(self):
|
||||||
|
r = RoutingSchema(
|
||||||
|
nodes=[
|
||||||
|
RouteNodeSchema(node_id="sys_in_1", type=NodeTypeEnum.SYSTEM_INPUT, jack_port="system:capture_1"),
|
||||||
|
RouteNodeSchema(node_id="ch_0_input", type=NodeTypeEnum.CHANNEL_INPUT, channel=0),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
RoutingEdgeSchema(source="sys_in_1", dest="ch_0_input"),
|
||||||
|
],
|
||||||
|
solo_nodes=["ch_0_input"],
|
||||||
|
)
|
||||||
|
assert len(r.nodes) == 2
|
||||||
|
assert len(r.edges) == 1
|
||||||
|
assert r.edges[0].source == "sys_in_1"
|
||||||
|
assert r.solo_nodes == ["ch_0_input"]
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for session-based WebSocket authentication."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from src.network.session import Session, SessionManager, DEFAULT_SESSION_TTL
|
||||||
|
|
||||||
|
|
||||||
|
class TestSession:
|
||||||
|
"""Tests for the Session data object."""
|
||||||
|
|
||||||
|
def test_session_creation(self):
|
||||||
|
s = Session(session_id="abc123", client_id="browser-1")
|
||||||
|
assert s.session_id == "abc123"
|
||||||
|
assert s.client_id == "browser-1"
|
||||||
|
assert not s.expired
|
||||||
|
assert s.ttl_remaining > 0
|
||||||
|
assert s.created_at > 0
|
||||||
|
|
||||||
|
def test_session_metadata(self):
|
||||||
|
s = Session("abc", "client", metadata={"user": "admin"})
|
||||||
|
assert s.metadata["user"] == "admin"
|
||||||
|
|
||||||
|
def test_session_expiry(self):
|
||||||
|
s = Session("abc", "client", ttl=0.01)
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert s.expired
|
||||||
|
assert s.ttl_remaining == 0.0
|
||||||
|
|
||||||
|
def test_session_not_expired(self):
|
||||||
|
s = Session("abc", "client", ttl=3600)
|
||||||
|
assert not s.expired
|
||||||
|
assert s.ttl_remaining > 3500 # generous margin
|
||||||
|
|
||||||
|
def test_session_custom_ttl(self):
|
||||||
|
s = Session("abc", "client", ttl=60)
|
||||||
|
assert 59 < s.ttl_remaining <= 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionManager:
|
||||||
|
"""Tests for SessionManager."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mgr(self):
|
||||||
|
return SessionManager(ttl=3600, max_sessions=10)
|
||||||
|
|
||||||
|
def test_initial_state(self, mgr):
|
||||||
|
assert mgr.active_sessions == 0
|
||||||
|
stats = mgr.stats
|
||||||
|
assert stats["active_sessions"] == 0
|
||||||
|
assert stats["total_created"] == 0
|
||||||
|
assert stats["total_destroyed"] == 0
|
||||||
|
assert stats["max_sessions"] == 10
|
||||||
|
assert stats["default_ttl"] == 3600
|
||||||
|
|
||||||
|
def test_create_session(self, mgr):
|
||||||
|
token = mgr.create_session("browser-chrome")
|
||||||
|
assert token is not None
|
||||||
|
assert len(token) == 64 # HMAC-SHA256 hex digest
|
||||||
|
assert mgr.active_sessions == 1
|
||||||
|
assert mgr.stats["total_created"] == 1
|
||||||
|
|
||||||
|
def test_validate_valid_token(self, mgr):
|
||||||
|
token = mgr.create_session("browser-firefox")
|
||||||
|
session = mgr.validate_token(token)
|
||||||
|
assert session is not None
|
||||||
|
assert session.client_id == "browser-firefox"
|
||||||
|
assert not session.expired
|
||||||
|
|
||||||
|
def test_validate_invalid_token(self, mgr):
|
||||||
|
session = mgr.validate_token("invalid-token")
|
||||||
|
assert session is None
|
||||||
|
|
||||||
|
def test_validate_empty_token(self, mgr):
|
||||||
|
assert mgr.validate_token("") is None
|
||||||
|
assert mgr.validate_token(None) is None # type: ignore
|
||||||
|
|
||||||
|
def test_validate_expired_token(self, mgr):
|
||||||
|
token = mgr.create_session("browser", ttl=0.01)
|
||||||
|
time.sleep(0.02)
|
||||||
|
session = mgr.validate_token(token)
|
||||||
|
assert session is None
|
||||||
|
assert mgr.active_sessions == 0
|
||||||
|
|
||||||
|
def test_destroy_session(self, mgr):
|
||||||
|
token = mgr.create_session("browser")
|
||||||
|
assert mgr.active_sessions == 1
|
||||||
|
destroyed = mgr.destroy_session(token)
|
||||||
|
assert destroyed
|
||||||
|
assert mgr.active_sessions == 0
|
||||||
|
assert mgr.validate_token(token) is None
|
||||||
|
|
||||||
|
def test_destroy_invalid_token(self, mgr):
|
||||||
|
assert mgr.destroy_session("nope") is False
|
||||||
|
|
||||||
|
def test_destroy_by_id(self, mgr):
|
||||||
|
token = mgr.create_session("browser")
|
||||||
|
session = mgr.validate_token(token)
|
||||||
|
assert session is not None
|
||||||
|
sid = session.session_id
|
||||||
|
destroyed = mgr.destroy_by_id(sid)
|
||||||
|
assert destroyed
|
||||||
|
assert mgr.validate_token(token) is None
|
||||||
|
|
||||||
|
def test_destroy_by_invalid_id(self, mgr):
|
||||||
|
assert mgr.destroy_by_id("nonexistent") is False
|
||||||
|
|
||||||
|
def test_get_session(self, mgr):
|
||||||
|
token = mgr.create_session("browser")
|
||||||
|
session = mgr.get_session(token)
|
||||||
|
assert session is not None
|
||||||
|
assert session.client_id == "browser"
|
||||||
|
|
||||||
|
# Even expired sessions are returned by get_session
|
||||||
|
session.expires_at = time.time() - 1
|
||||||
|
assert mgr.get_session(token) is not None
|
||||||
|
|
||||||
|
def test_refresh_session(self, mgr):
|
||||||
|
token = mgr.create_session("browser", ttl=3600)
|
||||||
|
session = mgr.get_session(token)
|
||||||
|
original_expiry = session.expires_at
|
||||||
|
|
||||||
|
# Fast-forward expiry then refresh
|
||||||
|
session.expires_at = time.time() + 1 # 1 second left
|
||||||
|
assert mgr.refresh_session(token)
|
||||||
|
session = mgr.get_session(token)
|
||||||
|
assert session.expires_at > original_expiry
|
||||||
|
|
||||||
|
def test_refresh_expired_session(self, mgr):
|
||||||
|
token = mgr.create_session("browser", ttl=0.01)
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert not mgr.refresh_session(token)
|
||||||
|
|
||||||
|
def test_multiple_sessions(self, mgr):
|
||||||
|
tokens = [mgr.create_session(f"client-{i}") for i in range(5)]
|
||||||
|
assert mgr.active_sessions == 5
|
||||||
|
assert mgr.stats["total_created"] == 5
|
||||||
|
|
||||||
|
# Validate all
|
||||||
|
for token in tokens:
|
||||||
|
assert mgr.validate_token(token) is not None
|
||||||
|
|
||||||
|
def test_max_sessions(self, mgr):
|
||||||
|
"""Test that exceeding max_sessions evicts oldest."""
|
||||||
|
# Fill up to max
|
||||||
|
for i in range(10):
|
||||||
|
mgr.create_session(f"client-{i}")
|
||||||
|
|
||||||
|
assert mgr.active_sessions == 10
|
||||||
|
|
||||||
|
# Create one more — should evict oldest
|
||||||
|
mgr.create_session("overflow")
|
||||||
|
assert mgr.active_sessions == 10
|
||||||
|
assert mgr.stats["total_created"] == 11
|
||||||
|
assert mgr.stats["total_destroyed"] == 1
|
||||||
|
|
||||||
|
def test_stale_eviction_on_validate(self, mgr):
|
||||||
|
"""Test that stale sessions are evicted when validate is called."""
|
||||||
|
token1 = mgr.create_session("client-a", ttl=0.01)
|
||||||
|
token2 = mgr.create_session("client-b", ttl=3600)
|
||||||
|
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
# Token1 expired, token2 still valid
|
||||||
|
assert mgr.validate_token(token1) is None
|
||||||
|
assert mgr.active_sessions == 1 # token1 evicted
|
||||||
|
assert mgr.validate_token(token2) is not None
|
||||||
|
|
||||||
|
def test_stats_evicts_stale(self, mgr):
|
||||||
|
"""Test that stats evicts stale sessions."""
|
||||||
|
mgr.create_session("client-a", ttl=0.01)
|
||||||
|
mgr.create_session("client-b", ttl=3600)
|
||||||
|
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
stats = mgr.stats
|
||||||
|
assert stats["active_sessions"] == 1 # Only client-b remains
|
||||||
|
|
||||||
|
def test_session_metadata_storage(self, mgr):
|
||||||
|
token = mgr.create_session("browser", metadata={"role": "admin", "permissions": ["read", "write"]})
|
||||||
|
session = mgr.validate_token(token)
|
||||||
|
assert session.metadata == {"role": "admin", "permissions": ["read", "write"]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionTokens:
|
||||||
|
"""Tests for token uniqueness and security properties."""
|
||||||
|
|
||||||
|
def test_tokens_are_unique(self):
|
||||||
|
mgr = SessionManager()
|
||||||
|
tokens = {mgr.create_session("client") for _ in range(100)}
|
||||||
|
assert len(tokens) == 100 # All unique
|
||||||
|
|
||||||
|
def test_tokens_are_opaque(self):
|
||||||
|
"""Tokens should not contain the session ID in plaintext."""
|
||||||
|
mgr = SessionManager()
|
||||||
|
token = mgr.create_session("client")
|
||||||
|
session = mgr.get_session(token)
|
||||||
|
assert session is not None
|
||||||
|
assert session.session_id not in token
|
||||||
|
assert "client" not in token
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""Tests for the touchscreen UI IPC client — REST + OSC communication.
|
||||||
|
|
||||||
|
Tests the MixerClient class without requiring a running mixer server
|
||||||
|
(uses mocking) and without requiring Kivy (pure Python).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python3 -m pytest tests/test_touch_ui.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from unittest.mock import patch, MagicMock, call
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.ui.ipc import MixerClient, encode_osc, _sync_fetch, _sync_put
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# OSC encoding tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestOSCEncode:
|
||||||
|
"""Test the pure-Python OSC encoder used by the IPC client."""
|
||||||
|
|
||||||
|
def test_encode_float(self):
|
||||||
|
packet = encode_osc("/test/value", 0.75)
|
||||||
|
# Should be a valid OSC packet
|
||||||
|
assert b"/test/value" in packet
|
||||||
|
assert b",f" in packet
|
||||||
|
|
||||||
|
def test_encode_int(self):
|
||||||
|
packet = encode_osc("/test/chan", 3)
|
||||||
|
assert b"/test/chan" in packet
|
||||||
|
assert b",i" in packet
|
||||||
|
|
||||||
|
def test_encode_string(self):
|
||||||
|
packet = encode_osc("/test/label", "hello")
|
||||||
|
assert b"/test/label" in packet
|
||||||
|
assert b",s" in packet
|
||||||
|
assert b"hello" in packet
|
||||||
|
|
||||||
|
def test_encode_mixed(self):
|
||||||
|
packet = encode_osc("/mixer/channel/3/volume", 3, 0.75)
|
||||||
|
assert b"/mixer/channel/3/volume" in packet
|
||||||
|
assert b",if" in packet
|
||||||
|
|
||||||
|
def test_encode_4byte_alignment(self):
|
||||||
|
"""OSC packets are padded to 4-byte boundaries."""
|
||||||
|
packet = encode_osc("/test", 1.0)
|
||||||
|
assert len(packet) % 4 == 0
|
||||||
|
|
||||||
|
def test_encode_multiple_args(self):
|
||||||
|
packet = encode_osc("/complex", 1.0, 2, "three")
|
||||||
|
assert len(packet) % 4 == 0
|
||||||
|
assert b",fis" in packet
|
||||||
|
|
||||||
|
def test_encode_no_args(self):
|
||||||
|
packet = encode_osc("/naked")
|
||||||
|
assert b"/naked" in packet
|
||||||
|
assert b",\x00" in packet or b",\x00\x00\x00" in packet
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# MixerClient REST API tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestMixerClient:
|
||||||
|
"""Test the MixerClient REST communication layer."""
|
||||||
|
|
||||||
|
def test_init_defaults(self):
|
||||||
|
client = MixerClient()
|
||||||
|
assert client.host == "127.0.0.1"
|
||||||
|
assert client.rest_port == 8000
|
||||||
|
assert client.osc_port == 9001
|
||||||
|
assert client.api_key == ""
|
||||||
|
|
||||||
|
def test_init_custom(self):
|
||||||
|
client = MixerClient(
|
||||||
|
host="192.168.1.10",
|
||||||
|
rest_port=8080,
|
||||||
|
osc_port=9002,
|
||||||
|
api_key="secret",
|
||||||
|
)
|
||||||
|
assert client.host == "192.168.1.10"
|
||||||
|
assert client.rest_port == 8080
|
||||||
|
assert client.osc_port == 9002
|
||||||
|
assert client.api_key == "secret"
|
||||||
|
|
||||||
|
def test_url_building(self):
|
||||||
|
client = MixerClient(host="mixer.local", rest_port=8000)
|
||||||
|
assert client._url("/state") == "http://mixer.local:8000/state"
|
||||||
|
assert client._url("/channels") == "http://mixer.local:8000/channels"
|
||||||
|
|
||||||
|
def test_headers_without_api_key(self):
|
||||||
|
client = MixerClient()
|
||||||
|
h = client._headers()
|
||||||
|
assert "Content-Type" in h
|
||||||
|
assert "X-API-Key" not in h
|
||||||
|
|
||||||
|
def test_headers_with_api_key(self):
|
||||||
|
client = MixerClient(api_key="abc123")
|
||||||
|
h = client._headers()
|
||||||
|
assert h["X-API-Key"] == "abc123"
|
||||||
|
|
||||||
|
@patch("src.ui.ipc.UrlRequest")
|
||||||
|
def test_fetch_state_builds_correct_url(self, mock_urlreq):
|
||||||
|
client = MixerClient(host="test.local", rest_port=9999)
|
||||||
|
callback = MagicMock()
|
||||||
|
|
||||||
|
client.fetch_state(on_success=callback)
|
||||||
|
|
||||||
|
# UrlRequest should have been called with the correct URL
|
||||||
|
mock_urlreq.assert_called_once()
|
||||||
|
call_args = mock_urlreq.call_args
|
||||||
|
url = call_args[0][0] if call_args[0] else call_args[1].get("url")
|
||||||
|
# Positional arg or keyword — check both
|
||||||
|
assert "test.local:9999/state" in str(call_args)
|
||||||
|
|
||||||
|
@patch("src.ui.ipc.UrlRequest")
|
||||||
|
def test_set_parameter_channel(self, mock_urlreq):
|
||||||
|
client = MixerClient(host="test.local", rest_port=8000)
|
||||||
|
client.set_parameter("volume", -6.0, channel=3)
|
||||||
|
|
||||||
|
mock_urlreq.assert_called_once()
|
||||||
|
call_args = mock_urlreq.call_args
|
||||||
|
args_str = str(call_args)
|
||||||
|
assert "channels/3/parameter" in args_str or "/3/" in str(call_args)
|
||||||
|
|
||||||
|
@patch("src.ui.ipc.UrlRequest")
|
||||||
|
def test_set_master_parameter(self, mock_urlreq):
|
||||||
|
client = MixerClient(host="test.local", rest_port=8000)
|
||||||
|
client.set_master_parameter("master_volume", -3.0)
|
||||||
|
|
||||||
|
mock_urlreq.assert_called_once()
|
||||||
|
call_args = mock_urlreq.call_args
|
||||||
|
args_str = str(call_args)
|
||||||
|
assert "mixes/parameter" in args_str
|
||||||
|
|
||||||
|
@patch("src.ui.ipc.UrlRequest")
|
||||||
|
def test_transport_command(self, mock_urlreq):
|
||||||
|
client = MixerClient(host="test.local", rest_port=8000)
|
||||||
|
client.transport_command("play")
|
||||||
|
|
||||||
|
mock_urlreq.assert_called_once()
|
||||||
|
call_args = mock_urlreq.call_args
|
||||||
|
args_str = str(call_args)
|
||||||
|
assert "transport/command" in args_str
|
||||||
|
|
||||||
|
def test_osc_send_creates_socket(self):
|
||||||
|
client = MixerClient()
|
||||||
|
# Mock socket to avoid actual network
|
||||||
|
with patch("socket.socket") as mock_sock_class:
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock_class.return_value = mock_sock
|
||||||
|
result = client.osc_send("/test", 1.0)
|
||||||
|
assert result is True
|
||||||
|
mock_sock.sendto.assert_called_once()
|
||||||
|
|
||||||
|
def test_osc_set_channel_param(self):
|
||||||
|
client = MixerClient()
|
||||||
|
with patch("socket.socket") as mock_sock_class:
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock_class.return_value = mock_sock
|
||||||
|
result = client.osc_set_channel_param(3, "volume", -6.0)
|
||||||
|
assert result is True
|
||||||
|
# Verify the packet contains the right address
|
||||||
|
call_args = mock_sock.sendto.call_args[0]
|
||||||
|
packet = call_args[0]
|
||||||
|
assert b"/mixer/channel/3/volume" in packet
|
||||||
|
|
||||||
|
def test_close_cleans_up(self):
|
||||||
|
client = MixerClient()
|
||||||
|
with patch("socket.socket") as mock_sock_class:
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock_class.return_value = mock_sock
|
||||||
|
client.osc_send("/test", 1.0)
|
||||||
|
client.close()
|
||||||
|
mock_sock.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Synchronous fallback tests (no Kivy required)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestSyncFallback:
|
||||||
|
"""Test the synchronous urllib fallback used when Kivy is unavailable."""
|
||||||
|
|
||||||
|
@patch("urllib.request.urlopen")
|
||||||
|
def test_sync_fetch_success(self, mock_urlopen):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.read.return_value = json.dumps({"ok": True, "data": {"channels": []}}).encode()
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_resp
|
||||||
|
|
||||||
|
results = []
|
||||||
|
_sync_fetch("http://localhost:8000/state", on_success=lambda d: results.append(d))
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["ok"] is True
|
||||||
|
|
||||||
|
@patch("urllib.request.urlopen")
|
||||||
|
def test_sync_fetch_http_error(self, mock_urlopen):
|
||||||
|
import urllib.error
|
||||||
|
mock_urlopen.side_effect = urllib.error.HTTPError(
|
||||||
|
"http://localhost", 500, "Server Error", {}, None
|
||||||
|
)
|
||||||
|
errors = []
|
||||||
|
_sync_fetch("http://localhost:8000/state", on_success=None, on_error=lambda e: errors.append(e))
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "500" in errors[0]
|
||||||
|
|
||||||
|
@patch("urllib.request.urlopen")
|
||||||
|
def test_sync_put_success(self, mock_urlopen):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.read.return_value = json.dumps({"ok": True}).encode()
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_resp
|
||||||
|
|
||||||
|
results = []
|
||||||
|
_sync_put(
|
||||||
|
"http://localhost:8000/channels/0/parameter",
|
||||||
|
json.dumps({"param_type": "volume", "value": 0.75}),
|
||||||
|
on_success=lambda d: results.append(d),
|
||||||
|
)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Fader value conversion tests (pure math, no GUI needed)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestFaderValueConversion:
|
||||||
|
"""Test the fader value conversion math without instantiating Kivy widgets."""
|
||||||
|
|
||||||
|
def test_normalized_range(self):
|
||||||
|
"""Test basic value-to-normalized conversion."""
|
||||||
|
min_val, max_val = -60.0, 12.0
|
||||||
|
def normalize(v):
|
||||||
|
return (v - min_val) / (max_val - min_val)
|
||||||
|
|
||||||
|
assert normalize(-60.0) == pytest.approx(0.0)
|
||||||
|
assert normalize(12.0) == pytest.approx(1.0)
|
||||||
|
assert normalize(-24.0) == pytest.approx(36.0 / 72.0)
|
||||||
|
|
||||||
|
def test_db_to_normalized_edge_cases(self):
|
||||||
|
"""Edge cases for dB conversion."""
|
||||||
|
def normalize(v, lo=-60.0, hi=12.0):
|
||||||
|
return max(0.0, min(1.0, (v - lo) / (hi - lo)))
|
||||||
|
|
||||||
|
assert normalize(-100.0) == 0.0 # below min
|
||||||
|
assert normalize(50.0) == 1.0 # above max
|
||||||
|
assert normalize(-60.0) == 0.0 # at min
|
||||||
|
assert normalize(12.0) == 1.0 # at max
|
||||||
|
|
||||||
|
def test_y_position_mapping(self):
|
||||||
|
"""Test the fader position math matches expected range."""
|
||||||
|
min_val, max_val = -60.0, 12.0
|
||||||
|
track_bottom = 40
|
||||||
|
track_top = 600 - 12
|
||||||
|
track_height = track_top - track_bottom
|
||||||
|
|
||||||
|
def value_to_y(v):
|
||||||
|
norm = (v - min_val) / (max_val - min_val)
|
||||||
|
return track_bottom + norm * track_height
|
||||||
|
|
||||||
|
def y_to_value(y):
|
||||||
|
norm = max(0.0, min(1.0, (y - track_bottom) / track_height))
|
||||||
|
return min_val + norm * (max_val - min_val)
|
||||||
|
|
||||||
|
# Round-trip test
|
||||||
|
for db in [-60, -40, -24, -12, 0, 6, 12]:
|
||||||
|
y = value_to_y(db)
|
||||||
|
back = y_to_value(y)
|
||||||
|
assert back == pytest.approx(db, abs=0.1), f"Round-trip failed for {db} dB: got {back}"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Theme tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestTheme:
|
||||||
|
"""Verify theme constants are reasonable."""
|
||||||
|
|
||||||
|
def test_colors_are_valid_rgba(self):
|
||||||
|
from src.ui.theme import Colors
|
||||||
|
for name in dir(Colors):
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
val = getattr(Colors, name)
|
||||||
|
if isinstance(val, (tuple, list)) and len(val) == 4:
|
||||||
|
assert all(0.0 <= c <= 1.0 for c in val), f"{name} has out-of-range color"
|
||||||
|
assert val[3] == 1.0, f"{name} alpha not 1.0"
|
||||||
|
|
||||||
|
def test_fader_colors_count(self):
|
||||||
|
from src.ui.theme import Colors
|
||||||
|
assert len(Colors.FADER_COLORS) == 8
|
||||||
|
|
||||||
|
def test_sizes_are_positive(self):
|
||||||
|
from src.ui.theme import Sizes
|
||||||
|
from kivy.metrics import dp
|
||||||
|
for name in dir(Sizes):
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
val = getattr(Sizes, name)
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
assert val > 0, f"{name} is not positive: {val}"
|
||||||
|
|
||||||
|
def test_fonts_are_positive(self):
|
||||||
|
from src.ui.theme import Fonts
|
||||||
|
for name in dir(Fonts):
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
val = getattr(Fonts, name)
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
assert val > 0, f"{name} is not positive: {val}"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Integration test: client lifecycle
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestClientLifecycle:
|
||||||
|
"""Test the client lifecycle: init → use → close."""
|
||||||
|
|
||||||
|
def test_multiple_osc_sends_reuse_socket(self):
|
||||||
|
client = MixerClient()
|
||||||
|
with patch("socket.socket") as mock_sock_class:
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock_class.return_value = mock_sock
|
||||||
|
|
||||||
|
client.osc_send("/test/a", 1.0)
|
||||||
|
client.osc_send("/test/b", 2.0)
|
||||||
|
|
||||||
|
# Should only create one socket
|
||||||
|
assert mock_sock_class.call_count == 1
|
||||||
|
assert mock_sock.sendto.call_count == 2
|
||||||
|
|
||||||
|
def test_close_then_send_recreates_socket(self):
|
||||||
|
client = MixerClient()
|
||||||
|
with patch("socket.socket") as mock_sock_class:
|
||||||
|
mock_sock1 = MagicMock()
|
||||||
|
mock_sock2 = MagicMock()
|
||||||
|
mock_sock_class.side_effect = [mock_sock1, mock_sock2]
|
||||||
|
|
||||||
|
client.osc_send("/test", 1.0)
|
||||||
|
client.close()
|
||||||
|
mock_sock1.close.assert_called_once()
|
||||||
|
|
||||||
|
client.osc_send("/test", 2.0)
|
||||||
|
assert mock_sock_class.call_count == 2
|
||||||
|
|
||||||
|
def test_cancel_all_clears_pending(self):
|
||||||
|
client = MixerClient()
|
||||||
|
client._pending_requests.add("fake_req")
|
||||||
|
client.cancel_all()
|
||||||
|
assert len(client._pending_requests) == 0
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Tests for MIDI types and message handling."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.midi.types import (
|
||||||
|
MIDIMessage,
|
||||||
|
MIDIMessageType,
|
||||||
|
MIDIMapping,
|
||||||
|
MixerParameter,
|
||||||
|
ParameterCategory,
|
||||||
|
ParameterType,
|
||||||
|
MIDISystemMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMIDIMessageType:
|
||||||
|
def test_from_status_cc(self):
|
||||||
|
assert MIDIMessageType.from_status(0xB0) == MIDIMessageType.CONTROL_CHANGE
|
||||||
|
assert MIDIMessageType.from_status(0xB3) == MIDIMessageType.CONTROL_CHANGE
|
||||||
|
|
||||||
|
def test_from_status_note_on(self):
|
||||||
|
assert MIDIMessageType.from_status(0x90) == MIDIMessageType.NOTE_ON
|
||||||
|
assert MIDIMessageType.from_status(0x9F) == MIDIMessageType.NOTE_ON
|
||||||
|
|
||||||
|
def test_from_status_note_off(self):
|
||||||
|
assert MIDIMessageType.from_status(0x80) == MIDIMessageType.NOTE_OFF
|
||||||
|
|
||||||
|
def test_channel(self):
|
||||||
|
assert MIDIMessageType.channel(0xB0) == 0
|
||||||
|
assert MIDIMessageType.channel(0xB3) == 3
|
||||||
|
assert MIDIMessageType.channel(0xBF) == 15
|
||||||
|
|
||||||
|
|
||||||
|
class TestMIDIMessage:
|
||||||
|
def test_cc_message(self):
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
assert msg.is_cc
|
||||||
|
assert not msg.is_nrpn
|
||||||
|
assert msg.controller == 7
|
||||||
|
assert msg.value == 100
|
||||||
|
assert msg.value_normalised == pytest.approx(100 / 127, abs=0.01)
|
||||||
|
|
||||||
|
def test_note_on_message(self):
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.NOTE_ON,
|
||||||
|
channel=1,
|
||||||
|
controller=60,
|
||||||
|
value=127,
|
||||||
|
)
|
||||||
|
assert not msg.is_cc
|
||||||
|
assert msg.channel == 1
|
||||||
|
|
||||||
|
def test_pitch_bend_message(self):
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.PITCH_BEND,
|
||||||
|
channel=0,
|
||||||
|
value=8192, # Centre
|
||||||
|
)
|
||||||
|
assert msg.value_normalised == pytest.approx(0.0, abs=0.01)
|
||||||
|
|
||||||
|
msg2 = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.PITCH_BEND,
|
||||||
|
channel=0,
|
||||||
|
value=16383,
|
||||||
|
)
|
||||||
|
assert msg2.value_normalised == pytest.approx(1.0, abs=0.02)
|
||||||
|
|
||||||
|
def test_nrpn_message(self):
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_msb=0,
|
||||||
|
nrpn_lsb=1,
|
||||||
|
value=1000,
|
||||||
|
)
|
||||||
|
assert msg.is_nrpn
|
||||||
|
assert msg.nrpn_number == 1 # (0 << 7) | 1
|
||||||
|
|
||||||
|
def test_nrpn_number_14bit(self):
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_msb=0x01,
|
||||||
|
nrpn_lsb=0x02,
|
||||||
|
)
|
||||||
|
assert msg.nrpn_number == (0x01 << 7) | 0x02
|
||||||
|
|
||||||
|
def test_repr(self):
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
source_device="TestDevice",
|
||||||
|
)
|
||||||
|
r = repr(msg)
|
||||||
|
assert "CONTROL_CHANGE" in r
|
||||||
|
assert "CC=7" in r
|
||||||
|
assert "val=100" in r
|
||||||
|
assert "TestDevice" in r
|
||||||
|
|
||||||
|
|
||||||
|
class TestMIDIMapping:
|
||||||
|
def test_cc_mapping_match(self):
|
||||||
|
m = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
param_type=ParameterType.VOLUME,
|
||||||
|
param_channel=0,
|
||||||
|
)
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
assert m.matches(msg)
|
||||||
|
|
||||||
|
def test_cc_mapping_no_match_wrong_channel(self):
|
||||||
|
m = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
)
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=1,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
assert not m.matches(msg)
|
||||||
|
|
||||||
|
def test_cc_mapping_no_match_wrong_controller(self):
|
||||||
|
m = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
)
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=10,
|
||||||
|
value=100,
|
||||||
|
)
|
||||||
|
assert not m.matches(msg)
|
||||||
|
|
||||||
|
def test_device_filter(self):
|
||||||
|
m = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
source_device="X-Touch",
|
||||||
|
)
|
||||||
|
msg_match = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
source_device="X-Touch",
|
||||||
|
)
|
||||||
|
msg_no_match = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
controller=7,
|
||||||
|
value=100,
|
||||||
|
source_device="MIDImix",
|
||||||
|
)
|
||||||
|
assert m.matches(msg_match)
|
||||||
|
assert not m.matches(msg_no_match)
|
||||||
|
|
||||||
|
def test_nrpn_mapping_match(self):
|
||||||
|
m = MIDIMapping(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_number=258, # (2 << 7) | 2
|
||||||
|
)
|
||||||
|
msg = MIDIMessage(
|
||||||
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||||
|
channel=0,
|
||||||
|
is_nrpn=True,
|
||||||
|
nrpn_msb=2,
|
||||||
|
nrpn_lsb=2,
|
||||||
|
value=8192,
|
||||||
|
)
|
||||||
|
assert m.matches(msg)
|
||||||
|
|
||||||
|
def test_scale_value_linear(self):
|
||||||
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0)
|
||||||
|
assert m.scale_value(0) == pytest.approx(0.0)
|
||||||
|
assert m.scale_value(127) == pytest.approx(1.0)
|
||||||
|
assert m.scale_value(63) == pytest.approx(0.5, abs=0.02)
|
||||||
|
|
||||||
|
def test_scale_value_invert(self):
|
||||||
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, invert=True)
|
||||||
|
assert m.scale_value(0) == pytest.approx(1.0)
|
||||||
|
assert m.scale_value(127) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_scale_value_db_range(self):
|
||||||
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=-60.0, param_max=12.0)
|
||||||
|
assert m.scale_value(0) == pytest.approx(-60.0)
|
||||||
|
assert m.scale_value(127) == pytest.approx(12.0)
|
||||||
|
|
||||||
|
def test_scale_value_exponential(self):
|
||||||
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, curve="exponential")
|
||||||
|
assert m.scale_value(0) == pytest.approx(0.0)
|
||||||
|
assert m.scale_value(127) == pytest.approx(1.0)
|
||||||
|
# Should be non-linear (value at 50% is < 50%)
|
||||||
|
assert m.scale_value(63) < 0.5
|
||||||
|
|
||||||
|
def test_disabled_mapping(self):
|
||||||
|
m = MIDIMapping(controller=7, enabled=False)
|
||||||
|
msg = MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 7, 100)
|
||||||
|
# The engine skips disabled mappings; matches() still works but caller checks enabled
|
||||||
|
assert m.matches(msg)
|
||||||
|
assert not m.enabled
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Tests for web application routes — login, settings, file management."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.network.auth import APIKeyAuth, API_KEY_HEADER
|
||||||
|
from src.network.session import SessionManager
|
||||||
|
from src.network.web_routes import create_web_routes, get_static_files_app
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginRoutes:
|
||||||
|
"""Tests for session auth login/logout endpoints."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth(self):
|
||||||
|
return APIKeyAuth(keys=["test-key-123"])
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_mgr(self):
|
||||||
|
return SessionManager(ttl=3600, max_sessions=10)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self, auth, session_mgr):
|
||||||
|
app = FastAPI()
|
||||||
|
routers = create_web_routes(
|
||||||
|
auth=auth,
|
||||||
|
session_manager=session_mgr,
|
||||||
|
settings_getter=lambda: {},
|
||||||
|
settings_setter=lambda _: True,
|
||||||
|
)
|
||||||
|
for router in routers:
|
||||||
|
app.include_router(router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_login_with_header(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
headers={API_KEY_HEADER: "test-key-123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data
|
||||||
|
assert "expires_in" in data
|
||||||
|
assert len(data["token"]) == 64
|
||||||
|
|
||||||
|
def test_login_with_json_body(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"api_key": "test-key-123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data
|
||||||
|
|
||||||
|
def test_login_invalid_key(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"api_key": "wrong-key"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_login_missing_key(self, client):
|
||||||
|
resp = client.post("/api/v1/auth/login")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_login_no_body_or_header(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
headers={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_login_creates_session(self, client, session_mgr):
|
||||||
|
assert session_mgr.active_sessions == 0
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
headers={API_KEY_HEADER: "test-key-123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert session_mgr.active_sessions == 1
|
||||||
|
|
||||||
|
def test_logout_valid_token(self, client, session_mgr):
|
||||||
|
# Create a session first
|
||||||
|
token = session_mgr.create_session("browser")
|
||||||
|
assert session_mgr.active_sessions == 1
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
json={"token": token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
assert session_mgr.active_sessions == 0
|
||||||
|
|
||||||
|
def test_logout_invalid_token(self, client, session_mgr):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
json={"token": "not-a-real-token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is False
|
||||||
|
|
||||||
|
def test_logout_missing_token(self, client):
|
||||||
|
resp = client.post("/api/v1/auth/logout")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_login_without_content_type(self, client):
|
||||||
|
"""Test login without JSON content-type header."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
content="not json",
|
||||||
|
headers={API_KEY_HEADER: "test-key-123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200 # Falls back to header auth
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsRoutes:
|
||||||
|
"""Tests for settings REST endpoints."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings_state(self):
|
||||||
|
return {"sample_rate": 48000, "buffer_size": 256, "volume_limit": -3.0}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth(self):
|
||||||
|
return APIKeyAuth(keys=["test-key"])
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_mgr(self):
|
||||||
|
return SessionManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self, auth, session_mgr, settings_state):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
def getter():
|
||||||
|
return dict(settings_state)
|
||||||
|
|
||||||
|
def setter(updates):
|
||||||
|
valid = set(settings_state.keys())
|
||||||
|
for k in updates:
|
||||||
|
if k not in valid:
|
||||||
|
return False
|
||||||
|
settings_state.update(updates)
|
||||||
|
return True
|
||||||
|
|
||||||
|
routers = create_web_routes(
|
||||||
|
auth=auth,
|
||||||
|
session_manager=session_mgr,
|
||||||
|
settings_getter=getter,
|
||||||
|
settings_setter=setter,
|
||||||
|
)
|
||||||
|
for router in routers:
|
||||||
|
app.include_router(router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_get_settings(self, client, settings_state):
|
||||||
|
resp = client.get("/api/v1/settings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data == settings_state
|
||||||
|
|
||||||
|
def test_update_settings_valid(self, client, settings_state):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={"buffer_size": 512},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
assert settings_state["buffer_size"] == 512
|
||||||
|
|
||||||
|
def test_update_settings_invalid_key(self, client, settings_state):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={"not_a_setting": 123},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_update_settings_invalid_body(self, client):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/settings",
|
||||||
|
content="not json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_update_settings_not_a_dict(self, client):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json=[1, 2, 3],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileRoutes:
|
||||||
|
"""Tests for file management REST endpoints."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def audio_dir(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
audio_path = Path(tmp)
|
||||||
|
# Create some test audio files
|
||||||
|
(audio_path / "track1.wav").write_text("fake wav data")
|
||||||
|
(audio_path / "track2.mp3").write_text("fake mp3 data")
|
||||||
|
(audio_path / "readme.txt").write_text("not audio")
|
||||||
|
(audio_path / "track3.flac").write_text("fake flac data")
|
||||||
|
yield audio_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth(self):
|
||||||
|
return APIKeyAuth(keys=["test-key"])
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_mgr(self):
|
||||||
|
return SessionManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self, auth, session_mgr, audio_dir):
|
||||||
|
app = FastAPI()
|
||||||
|
routers = create_web_routes(
|
||||||
|
auth=auth,
|
||||||
|
session_manager=session_mgr,
|
||||||
|
settings_getter=lambda: {},
|
||||||
|
settings_setter=lambda _: True,
|
||||||
|
audio_dir=audio_dir,
|
||||||
|
)
|
||||||
|
for router in routers:
|
||||||
|
app.include_router(router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_list_files(self, client):
|
||||||
|
resp = client.get("/api/v1/files")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["exists"] is True
|
||||||
|
assert data["count"] == 3 # wav, mp3, flac (not .txt)
|
||||||
|
names = {f["name"] for f in data["files"]}
|
||||||
|
assert "track1.wav" in names
|
||||||
|
assert "track2.mp3" in names
|
||||||
|
assert "track3.flac" in names
|
||||||
|
assert "readme.txt" not in names
|
||||||
|
|
||||||
|
def test_download_audio_file(self, client):
|
||||||
|
resp = client.get("/api/v1/files/track1.wav")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == b"fake wav data"
|
||||||
|
|
||||||
|
def test_download_nonexistent_file(self, client):
|
||||||
|
resp = client.get("/api/v1/files/nope.wav")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_download_non_audio_file(self, client):
|
||||||
|
resp = client.get("/api/v1/files/readme.txt")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_path_traversal_prevented(self, client):
|
||||||
|
resp = client.get("/api/v1/files/../../../etc/passwd")
|
||||||
|
assert resp.status_code in (403, 404)
|
||||||
|
|
||||||
|
def test_nonexistent_directory(self):
|
||||||
|
"""Test listing files when directory doesn't exist."""
|
||||||
|
app = FastAPI()
|
||||||
|
auth = APIKeyAuth(keys=["test-key"])
|
||||||
|
session_mgr = SessionManager()
|
||||||
|
routers = create_web_routes(
|
||||||
|
auth=auth,
|
||||||
|
session_manager=session_mgr,
|
||||||
|
settings_getter=lambda: {},
|
||||||
|
settings_setter=lambda _: True,
|
||||||
|
audio_dir=Path("/tmp/nonexistent-audio-dir-xyz"),
|
||||||
|
)
|
||||||
|
for router in routers:
|
||||||
|
app.include_router(router)
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/files")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["exists"] is False
|
||||||
|
assert data["files"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaticFiles:
|
||||||
|
"""Tests for static file serving."""
|
||||||
|
|
||||||
|
def test_get_static_files_app(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
web_dir = Path(tmp)
|
||||||
|
(web_dir / "index.html").write_text("<h1>Hello</h1>")
|
||||||
|
app = get_static_files_app(web_dir)
|
||||||
|
assert app is not None
|
||||||
|
|
||||||
|
def test_placeholder_created(self):
|
||||||
|
"""Test that placeholder UI is created when dir doesn't exist."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
web_dir = Path(tmp) / "nonexistent-web"
|
||||||
|
app = get_static_files_app(web_dir)
|
||||||
|
assert web_dir.exists()
|
||||||
|
index = web_dir / "index.html"
|
||||||
|
assert index.exists()
|
||||||
|
content = index.read_text()
|
||||||
|
assert "RPi Audio Mixer" in content
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests for WebSocket manager."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.network.websocket import WebSocketManager
|
||||||
|
from src.network.schemas import (
|
||||||
|
MixerStateSchema,
|
||||||
|
ChannelSchema,
|
||||||
|
WSMessage,
|
||||||
|
WSMessageType,
|
||||||
|
)
|
||||||
|
from src.network.auth import APIKeyAuth
|
||||||
|
from src.network.rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketManager:
|
||||||
|
"""Tests for WebSocketManager (unit tests for the manager class)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(self):
|
||||||
|
return WebSocketManager(max_connections=10)
|
||||||
|
|
||||||
|
def test_initial_state(self, manager):
|
||||||
|
assert manager.connected_clients == 0
|
||||||
|
stats = manager.stats
|
||||||
|
assert stats["connected_clients"] == 0
|
||||||
|
assert stats["total_connections"] == 0
|
||||||
|
assert stats["total_broadcasts"] == 0
|
||||||
|
|
||||||
|
def test_set_state_provider(self, manager):
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def get_state():
|
||||||
|
called.append(True)
|
||||||
|
return MixerStateSchema(
|
||||||
|
channels=[ChannelSchema(channel=0)],
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.set_state_provider(get_state)
|
||||||
|
assert manager._state_provider is not None
|
||||||
|
|
||||||
|
def test_set_parameter_handler(self, manager):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(pt, v, ch):
|
||||||
|
calls.append((pt, v, ch))
|
||||||
|
|
||||||
|
manager.set_parameter_handler(handler)
|
||||||
|
assert manager._parameter_handler is not None
|
||||||
|
|
||||||
|
def test_set_transport_handler(self, manager):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(cmd):
|
||||||
|
calls.append(cmd)
|
||||||
|
|
||||||
|
manager.set_transport_handler(handler)
|
||||||
|
assert manager._transport_handler is not None
|
||||||
|
|
||||||
|
def test_max_connections_property(self, manager):
|
||||||
|
assert manager._max_connections == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketBroadcast:
|
||||||
|
"""Tests for WebSocket broadcasting logic."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(self):
|
||||||
|
mgr = WebSocketManager(max_connections=10)
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_no_clients(self, manager):
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.PARAMETER_UPDATE,
|
||||||
|
payload={"param_type": "volume", "value": -3.0, "channel": 0},
|
||||||
|
)
|
||||||
|
count = await manager.broadcast(msg)
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_parameter_update_no_clients(self, manager):
|
||||||
|
count = await manager.broadcast_parameter_update("volume", -3.0, 0)
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_transport_no_clients(self, manager):
|
||||||
|
count = await manager.broadcast_transport_update({"command": "play"})
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_add_remove(self, manager):
|
||||||
|
"""Test client bookkeeping (without actual WebSocket)."""
|
||||||
|
assert manager.connected_clients == 0
|
||||||
|
|
||||||
|
# Simulate adding a client
|
||||||
|
async with manager._lock:
|
||||||
|
manager._clients["test-client"] = "mock-ws"
|
||||||
|
manager._total_connections += 1
|
||||||
|
|
||||||
|
assert manager.connected_clients == 1
|
||||||
|
assert manager.stats["total_connections"] == 1
|
||||||
|
|
||||||
|
# Remove client
|
||||||
|
await manager._remove_client("test-client")
|
||||||
|
assert manager.connected_clients == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketStats:
|
||||||
|
"""Tests for WebSocket statistics."""
|
||||||
|
|
||||||
|
def test_stats_structure(self):
|
||||||
|
manager = WebSocketManager(max_connections=5)
|
||||||
|
stats = manager.stats
|
||||||
|
assert "connected_clients" in stats
|
||||||
|
assert "total_connections" in stats
|
||||||
|
assert "total_messages" in stats
|
||||||
|
assert "total_broadcasts" in stats
|
||||||
|
assert "uptime_seconds" in stats
|
||||||
|
assert "max_connections" in stats
|
||||||
|
assert stats["max_connections"] == 5
|
||||||
|
|
||||||
|
def test_uptime_increases(self):
|
||||||
|
manager = WebSocketManager()
|
||||||
|
stats1 = manager.stats
|
||||||
|
# Stats should have a reasonable uptime
|
||||||
|
assert stats1["uptime_seconds"] >= 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketMessageTypes:
|
||||||
|
"""Tests for WebSocket message type handling."""
|
||||||
|
|
||||||
|
def test_ws_message_serialization(self):
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.FULL_STATE,
|
||||||
|
payload={"channels": [], "master": {"volume_db": 0.0}},
|
||||||
|
)
|
||||||
|
data = msg.model_dump()
|
||||||
|
assert data["type"] == "full_state"
|
||||||
|
|
||||||
|
def test_all_message_types(self):
|
||||||
|
for msg_type in WSMessageType:
|
||||||
|
msg = WSMessage(type=msg_type, payload={})
|
||||||
|
assert msg.type == msg_type
|
||||||
|
assert msg.payload == {}
|
||||||
|
|
||||||
|
def test_parameter_update_message(self):
|
||||||
|
msg = WSMessage(
|
||||||
|
type=WSMessageType.PARAMETER_UPDATE,
|
||||||
|
payload={
|
||||||
|
"param_type": "comp_threshold",
|
||||||
|
"value": -15.0,
|
||||||
|
"channel": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert msg.payload["param_type"] == "comp_threshold"
|
||||||
|
assert msg.payload["value"] == -15.0
|
||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
// RPi Audio Mixer — Web UI Application
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let sessionToken = null;
|
||||||
|
let connected = false;
|
||||||
|
let channelCount = 16;
|
||||||
|
let paramState = {}; // channel -> { param_type -> value }
|
||||||
|
|
||||||
|
// ── Logging ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function log(msg, cls) {
|
||||||
|
const el = document.getElementById('log');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = new Date().toLocaleTimeString() + ' ' + msg;
|
||||||
|
div.className = cls || '';
|
||||||
|
el.prepend(div);
|
||||||
|
if (el.children.length > 50) el.lastChild.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const apiKey = document.getElementById('apikey-input').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) {
|
||||||
|
sessionToken = data.token;
|
||||||
|
document.getElementById('connect-btn').disabled = false;
|
||||||
|
log('Session obtained (expires in ' + data.expires_in + 's)', 'log-info');
|
||||||
|
// Auto-connect
|
||||||
|
connectWS();
|
||||||
|
} else {
|
||||||
|
log('Login failed: ' + JSON.stringify(data), 'log-error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Login error: ' + e.message, 'log-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWS() {
|
||||||
|
if (!sessionToken) {
|
||||||
|
log('No session token — login first', 'log-error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ws) ws.close();
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = proto + '//' + location.host + '/ws?session=' + sessionToken;
|
||||||
|
|
||||||
|
setStatus('connecting', '● Connecting...');
|
||||||
|
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected = true;
|
||||||
|
setStatus('connected', '● Connected');
|
||||||
|
document.getElementById('connect-btn').disabled = true;
|
||||||
|
log('WebSocket connected', 'log-state');
|
||||||
|
};
|
||||||
|
ws.onclose = (e) => {
|
||||||
|
connected = false;
|
||||||
|
setStatus('disconnected', '● Disconnected');
|
||||||
|
document.getElementById('connect-btn').disabled = false;
|
||||||
|
log('WebSocket closed: ' + (e.reason || 'code ' + e.code), 'log-error');
|
||||||
|
};
|
||||||
|
ws.onerror = () => log('WebSocket error', 'log-error');
|
||||||
|
ws.onmessage = handleMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWS() {
|
||||||
|
if (ws) ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(cls, text) {
|
||||||
|
const el = document.getElementById('conn-status');
|
||||||
|
el.className = 'status ' + cls;
|
||||||
|
el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handling ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleMessage(event) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'full_state':
|
||||||
|
handleFullState(msg.payload);
|
||||||
|
break;
|
||||||
|
case 'parameter_update':
|
||||||
|
handleParameterUpdate(msg.payload);
|
||||||
|
break;
|
||||||
|
case 'transport_command':
|
||||||
|
handleTransportUpdate(msg.payload);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
log('Server error: ' + msg.payload.message, 'log-error');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log('Unknown msg type: ' + msg.type, 'log-info');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Parse error: ' + e.message, 'log-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFullState(state) {
|
||||||
|
log('Full state received: ' + (state.channels ? state.channels.length : 0) + ' channels', 'log-state');
|
||||||
|
|
||||||
|
if (state.channels) {
|
||||||
|
channelCount = state.channels.length;
|
||||||
|
buildChannelStrips(state.channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.transport) {
|
||||||
|
updateTransportUI(state.transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.master) {
|
||||||
|
updateMasterUI(state.master);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleParameterUpdate(payload) {
|
||||||
|
const { param_type, value, channel } = payload;
|
||||||
|
if (!paramState[channel]) paramState[channel] = {};
|
||||||
|
paramState[channel][param_type] = value;
|
||||||
|
|
||||||
|
updateChannelUI(channel, param_type, value);
|
||||||
|
|
||||||
|
if (param_type === 'master_volume') {
|
||||||
|
document.getElementById('master-vol-val').textContent = value.toFixed(1) + ' dB';
|
||||||
|
document.getElementById('master-fader').querySelector('input').value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransportUpdate(payload) {
|
||||||
|
updateTransportUI(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel strip builder ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildChannelStrips(channels) {
|
||||||
|
const container = document.getElementById('channels');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
channels.forEach((ch, i) => {
|
||||||
|
const strip = document.createElement('div');
|
||||||
|
strip.className = 'channel-strip';
|
||||||
|
strip.id = 'ch-' + i;
|
||||||
|
|
||||||
|
const volDb = ch.volume_db !== undefined ? ch.volume_db : 0;
|
||||||
|
|
||||||
|
strip.innerHTML = `
|
||||||
|
<h4>${ch.label || 'CH' + (i + 1)}</h4>
|
||||||
|
<div class="fader-v">
|
||||||
|
<input type="range" min="-60" max="12" value="${volDb}" step="0.5" orient="vertical"
|
||||||
|
oninput="sendParam('volume', this.value, ${i})">
|
||||||
|
<span class="fader-label">Vol</span>
|
||||||
|
<span class="fader-value">${volDb.toFixed(1)} dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="pan-knob">
|
||||||
|
<input type="range" min="-1" max="1" value="${ch.pan || 0}" step="0.05"
|
||||||
|
oninput="sendParam('pan', this.value, ${i})">
|
||||||
|
<div class="pan-value">Pan: ${((ch.pan || 0) * 100).toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="chan-buttons">
|
||||||
|
<button class="chan-btn${ch.mute ? ' muted' : ''}" id="mute-${i}"
|
||||||
|
onclick="toggleMute(${i})">M</button>
|
||||||
|
<button class="chan-btn${ch.solo ? ' soloed' : ''}" id="solo-${i}"
|
||||||
|
onclick="toggleSolo(${i})">S</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(strip);
|
||||||
|
|
||||||
|
// Initialize state
|
||||||
|
if (!paramState[i]) paramState[i] = {};
|
||||||
|
paramState[i].mute = ch.mute || false;
|
||||||
|
paramState[i].solo = ch.solo || false;
|
||||||
|
paramState[i].volume = volDb;
|
||||||
|
paramState[i].pan = ch.pan || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update channel count in master section context if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChannelUI(channel, paramType, value) {
|
||||||
|
const strip = document.getElementById('ch-' + channel);
|
||||||
|
if (!strip) return;
|
||||||
|
|
||||||
|
switch (paramType) {
|
||||||
|
case 'volume': {
|
||||||
|
const fader = strip.querySelector('.fader-v input');
|
||||||
|
const label = strip.querySelector('.fader-value');
|
||||||
|
if (fader) fader.value = value;
|
||||||
|
if (label) label.textContent = value.toFixed(1) + ' dB';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pan': {
|
||||||
|
const knob = strip.querySelector('.pan-knob input');
|
||||||
|
const label = strip.querySelector('.pan-value');
|
||||||
|
if (knob) knob.value = value;
|
||||||
|
if (label) label.textContent = 'Pan: ' + (value * 100).toFixed(0) + '%';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'mute': {
|
||||||
|
const btn = document.getElementById('mute-' + channel);
|
||||||
|
if (btn) {
|
||||||
|
btn.className = 'chan-btn' + (value >= 0.5 ? ' muted' : '');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'solo': {
|
||||||
|
const btn = document.getElementById('solo-' + channel);
|
||||||
|
if (btn) {
|
||||||
|
btn.className = 'chan-btn' + (value >= 0.5 ? ' soloed' : '');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMasterUI(master) {
|
||||||
|
if (master.volume_db !== undefined) {
|
||||||
|
document.getElementById('master-fader').querySelector('input').value = master.volume_db;
|
||||||
|
document.getElementById('master-vol-val').textContent = master.volume_db.toFixed(1) + ' dB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTransportUI(transport) {
|
||||||
|
document.getElementById('btn-play').className = 'tbtn' + (transport.playing ? ' active' : '');
|
||||||
|
document.getElementById('btn-record').className = 'tbtn' + (transport.recording ? ' active' : '');
|
||||||
|
document.getElementById('btn-loop').className = 'tbtn' + (transport.loop ? ' active' : '');
|
||||||
|
|
||||||
|
if (transport.tempo_bpm) {
|
||||||
|
document.getElementById('tempo-display').textContent = transport.tempo_bpm.toFixed(1) + ' BPM';
|
||||||
|
}
|
||||||
|
if (transport.position_sec !== undefined) {
|
||||||
|
const secs = Math.floor(transport.position_sec);
|
||||||
|
const h = Math.floor(secs / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
document.getElementById('time-display').textContent =
|
||||||
|
String(h).padStart(2, '0') + ':' +
|
||||||
|
String(m).padStart(2, '0') + ':' +
|
||||||
|
String(s).padStart(2, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute(channel) {
|
||||||
|
const current = paramState[channel] && paramState[channel].mute ? 0 : 1;
|
||||||
|
sendParam('mute', current, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSolo(channel) {
|
||||||
|
const current = paramState[channel] && paramState[channel].solo ? 0 : 1;
|
||||||
|
sendParam('solo', current, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sending ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sendParam(paramType, value, channel) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const msg = {
|
||||||
|
type: 'parameter_update',
|
||||||
|
payload: {
|
||||||
|
param_type: String(paramType),
|
||||||
|
value: parseFloat(value),
|
||||||
|
channel: parseInt(channel),
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTransport(command) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'transport_command',
|
||||||
|
payload: { command: command },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Startup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Auto-login on page load
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
log('Mixer UI loaded — login to connect', 'log-info');
|
||||||
|
// Auto-login with default key
|
||||||
|
login();
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!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>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header id="toolbar">
|
||||||
|
<h1>RPi Audio Mixer</h1>
|
||||||
|
<div id="connection">
|
||||||
|
<span id="conn-status" class="status disconnected">● Disconnected</span>
|
||||||
|
<input type="text" id="apikey-input" placeholder="API Key" value="mixer-local" size="14">
|
||||||
|
<button id="login-btn" onclick="login()">Login</button>
|
||||||
|
<button id="connect-btn" onclick="connectWS()" disabled>Connect</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="transport-bar">
|
||||||
|
<button class="tbtn" id="btn-play" onclick="sendTransport('play')">▶ Play</button>
|
||||||
|
<button class="tbtn" id="btn-stop" onclick="sendTransport('stop')">■ Stop</button>
|
||||||
|
<button class="tbtn" id="btn-record" onclick="sendTransport('record')">● Rec</button>
|
||||||
|
<button class="tbtn" id="btn-loop" onclick="sendTransport('loop')">↻ Loop</button>
|
||||||
|
<span id="tempo-display">120 BPM</span>
|
||||||
|
<span id="time-display">00:00:00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mixer">
|
||||||
|
<div id="channels"></div>
|
||||||
|
<div id="master-section">
|
||||||
|
<h3>Master</h3>
|
||||||
|
<div class="fader-v" id="master-fader">
|
||||||
|
<input type="range" min="-60" max="12" value="0" step="0.5" orient="vertical"
|
||||||
|
oninput="sendParam('master_volume', this.value, -1)">
|
||||||
|
<span class="fader-label">Master</span>
|
||||||
|
<span class="fader-value" id="master-vol-val">0.0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="master-controls">
|
||||||
|
<button id="btn-mute-master" onclick="sendParam('master_mute', 1, -1)">Mute</button>
|
||||||
|
<button id="btn-dim" onclick="sendParam('master_dim', 1, -1)">Dim</button>
|
||||||
|
<button id="btn-mono" onclick="sendParam('master_mono', 1, -1)">Mono</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log-panel">
|
||||||
|
<h3>Activity Log</h3>
|
||||||
|
<div id="log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
/* RPi Audio Mixer — Web UI Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--panel: #16213e;
|
||||||
|
--accent: #e94560;
|
||||||
|
--text: #eee;
|
||||||
|
--text-dim: #888;
|
||||||
|
--fader-track: #0f3460;
|
||||||
|
--fader-thumb: #e94560;
|
||||||
|
--btn-bg: #0f3460;
|
||||||
|
--btn-hover: #1a4a7a;
|
||||||
|
--channel-bg: #1a1a3e;
|
||||||
|
--connected: #00cc66;
|
||||||
|
--disconnected: #ff4444;
|
||||||
|
--warning: #ffaa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
#toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toolbar h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection input {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--text-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection button {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection button:hover { background: var(--btn-hover); }
|
||||||
|
#connection button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connected { color: var(--connected); }
|
||||||
|
.status.disconnected { color: var(--disconnected); }
|
||||||
|
.status.connecting { color: var(--warning); }
|
||||||
|
|
||||||
|
/* Transport */
|
||||||
|
#transport-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbtn {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbtn:hover { background: var(--accent); }
|
||||||
|
.tbtn.active { background: var(--accent); }
|
||||||
|
|
||||||
|
#tempo-display, #time-display {
|
||||||
|
margin-left: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mixer area */
|
||||||
|
#mixer {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channels {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--channel-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-strip h4 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader-v {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader-v input[type="range"] {
|
||||||
|
writing-mode: bt-lr;
|
||||||
|
-webkit-appearance: slider-vertical;
|
||||||
|
width: 24px;
|
||||||
|
height: 120px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 80px;
|
||||||
|
background: var(--fader-track);
|
||||||
|
accent-color: var(--fader-thumb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader-value {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan-btn {
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan-btn:hover { background: var(--btn-hover); }
|
||||||
|
.chan-btn.muted { background: var(--accent); }
|
||||||
|
.chan-btn.soloed { background: var(--warning); color: #000; }
|
||||||
|
|
||||||
|
.pan-knob {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-knob input { width: 100%; accent-color: var(--accent); }
|
||||||
|
.pan-value { font-size: 0.65rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Master section */
|
||||||
|
#master-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--channel-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 100px;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#master-section h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#master-section .fader-v input[type="range"] {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-controls button {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-controls button:hover { background: var(--btn-hover); }
|
||||||
|
.master-controls button.active { background: var(--accent); }
|
||||||
|
|
||||||
|
/* Log panel */
|
||||||
|
#log-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
padding: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-panel h3 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log {
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 110px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log .log-param { color: var(--accent); }
|
||||||
|
#log .log-state { color: var(--connected); }
|
||||||
|
#log .log-error { color: var(--disconnected); }
|
||||||
|
#log .log-info { color: var(--text-dim); }
|
||||||
Reference in New Issue
Block a user