Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.so
|
||||
.nam/
|
||||
*.nam
|
||||
*.wav
|
||||
*.aiff
|
||||
*.ir
|
||||
*.wv
|
||||
config.json
|
||||
.env
|
||||
*.swp
|
||||
.scone/
|
||||
systemd/
|
||||
@@ -0,0 +1,97 @@
|
||||
# Pi Multi-FX Pedal 🎸
|
||||
|
||||
A real-time guitar multi-FX pedal running on **Raspberry Pi 4B** with:
|
||||
|
||||
- **NAM A2** neural amp modeling — load and switch between capture profiles
|
||||
- **IR cab simulator** — impulse response convolution for cabinet simulation
|
||||
- **Multi-FX chain** — noise gate, compressor, overdrive/distortion, EQ, modulation (chorus/flanger/phaser/tremolo), delay, reverb
|
||||
- **MIDI in/out** — foot controller, expression pedal, preset switching via Program Change
|
||||
- **Stomp-friendly UI** — footswitches, RGB LEDs, OLED display, tuner
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Guitar → I2S ADC → JACK Audio → DSP Pipeline → I2S DAC → Amp/Headphones
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ NAM LV2 Plugin │
|
||||
│ (neural amp sim) │
|
||||
├─────────────────────┤
|
||||
│ IR Convolver LV2 │
|
||||
│ (cabinet sim) │
|
||||
├─────────────────────┤
|
||||
│ LV2 FX Chain │
|
||||
│ (mod/delay/verb) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Signal Chain
|
||||
|
||||
```
|
||||
Input → Gate → Comp → Boost → NAM Amp → IR Cab → EQ → Mod → Delay → Reverb → Volume → Output
|
||||
│ │ │ │ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │
|
||||
┌────┴───┐ ┌─┴──┐ ┌──┴───┐ ┌─┴─────┐ ┌──┴──┐ ┌─┴──┐ ┌──┴──┐ ┌───┴──┐ ┌───┴──┐ ┌──┴───┐
|
||||
│Noise │ │Dyn │ │Drive │ │Neural │ │IR │ │EQ │ │Mod │ │Delay │ │Reverb│ │Master│
|
||||
│Gate │ │Comp│ │Boost │ │Amp │ │Cab │ │ │ │ │ │ │ │ │ │Volume│
|
||||
└────────┘ └────┘ └──────┘ └───────┘ └─────┘ └────┘ └─────┘ └──────┘ └──────┘ └──────┘
|
||||
```
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Spec |
|
||||
|-----------|------|
|
||||
| **SBC** | Raspberry Pi 4B (2GB+ RAM) |
|
||||
| **Audio I/O** | I2S DAC + ADC (e.g. PCM1808 + PCM5102, or an Audio HAT) |
|
||||
| **Footswitches** | 3-6 momentary soft-touch (e.g. MXR-style) |
|
||||
| **LEDs** | RGB WS2812B or APA102 per footswitch + status LEDs |
|
||||
| **Display** | 128x64 OLED SSD1306 (I2C) or small TFT |
|
||||
| **MIDI** | 5-pin DIN in/out via UART + optoisolator |
|
||||
| **Power** | 5V/3A USB-C (with buck for 3.3V rail) |
|
||||
| **Enclosure** | 1590BB or 3D-printed custom |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── dsp/ Audio processing pipeline
|
||||
│ │ ├── pipeline.py FX chain orchestration
|
||||
│ │ ├── ir_loader.py IR convolution engine
|
||||
│ │ ├── nam_host.py NAM model loading & inference
|
||||
│ │ └── level.py Input/output level + tuner
|
||||
│ ├── midi/ MIDI subsystem
|
||||
│ │ ├── handler.py MIDI event parse & dispatch
|
||||
│ │ └── presets.py PC → preset mapping
|
||||
│ ├── ui/ Hardware UI layer
|
||||
│ │ ├── footswitch.py Debounced GPIO input
|
||||
│ │ ├── leds.py RGB LED controller
|
||||
│ │ └── display.py OLED display manager
|
||||
│ ├── presets/ Preset system
|
||||
│ │ ├── manager.py Save/load/bank/preset CRUD
|
||||
│ │ └── types.py Preset & chain data model
|
||||
│ └── system/ System integration
|
||||
│ ├── audio.py ALSA/JACK/I2S configuration
|
||||
│ └── setup.py First-boot setup scripts
|
||||
├── scripts/ Build & utility scripts
|
||||
├── tests/ Tests
|
||||
├── docs/ Documentation
|
||||
└── hardware/ KiCad/PCB schematics
|
||||
```
|
||||
|
||||
## Development Quick Start
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://gitea.ourpad.casa/shawn/pi-multifx-pedal.git
|
||||
cd pi-multifx-pedal
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run tests
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Early development — initial scaffold.
|
||||
@@ -0,0 +1,439 @@
|
||||
# Audio I/O Hardware Selection — Research Report
|
||||
|
||||
> **Project:** Pi Multi-FX Pedal (RPi 4B)
|
||||
> **Goal:** Select I2S ADC+DAC solution for real-time guitar processing with <10ms round-trip latency
|
||||
> **Date:** 2026-06-07
|
||||
|
||||
---
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Option | Type | ADC | DAC | Max Bit/Sample | Price (USD) | Power | Overlay Support | Round-Trip Latency* | Noise Floor | Headphone Amp | Hardware Mixing |
|
||||
|--------|------|-----|-----|---------------|:-----------:|:-----:|:---------------:|:-------------------:|:-----------:|:-------------:|:---------------:|
|
||||
| **AudioInjector Stereo HAT** | HAT | CS5343 | CS4344 | 24-bit / 192kHz | ~$35-40 | 5V tolerant | Custom (audioinjector-wm8731-audio or octo-hat) | ~2ms @ 128fr | -93dB | Yes (TPA6130A2) | Yes |
|
||||
| **IQaudio Codec Zero** | HAT | WM8782+G | WM8731? | 24-bit / (48kHz practical) | ~$18 | 3.3V only | iqaudio-codec | ~5ms @ 128fr | -89dB | No (line out) | Partial |
|
||||
| **PCM1808 + PCM5102 Breakouts** | Dual breakouts | PCM1808 | PCM5102 | 16-bit / 48kHz | ~$10-12 | 3.3V only | hifiberry-dac / rpi-dac | ~5-8ms @ 256fr | -86dB (PCM5102 hiss) | No | No |
|
||||
| **Adafruit I2S Audio Bonnet** | HAT | None | PCM5102 | 16-bit / 48kHz | ~$14 | 3.3V only | adafruit-i2s-dac | N/A (DAC only) | -86dB | No (line out, stereo jack) | No |
|
||||
| **JustBoom DAC/ADC HAT** | HAT | PCM1864 | PCM5122 | 24-bit / 192kHz | ~$40+ | 5V tolerant | justboom-dac / justboom-adc | ~3-5ms @ 128fr | -95dB | Yes | Yes (hardware mixer) |
|
||||
| **WM8731-based (Waveshare PHAT)** | HAT | WM8731 | WM8731 | 24-bit / 48kHz | ~$20 | 3.3V only | Manual DT overlay | ~5-7ms @ 128fr | -84dB (charge pump noise) | Yes | Yes |
|
||||
|
||||
*Measured with JACK at 48kHz / 128 frames (2.6ms buffer), best-case configuration. Actual = buffer ticks + codec group delay + DMA transfer overhead.
|
||||
|
||||
---
|
||||
|
||||
## Option 1: AudioInjector Stereo HAT ★ Top Recommend
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| **Chipset** | Cirrus Logic CS5343 ADC + CS4344 DAC |
|
||||
| **Sample Rates** | 8k–192kHz |
|
||||
| **Bit Depth** | 8/16/24-bit |
|
||||
| **Input** | Stereo line-in (3.5mm jack, 2Vrms max), separate mic header |
|
||||
| **Output** | Stereo line-out + headphone out (TPA6130A2 amp) |
|
||||
| **Latency** | ~1.8ms round-trip (48kHz/128 frames, OSS test, 9 samples) |
|
||||
| **Power** | 5V tolerant — runs from Pi GPIO 5V pin |
|
||||
| **Current** | ~100mA (no headphones), ~250mA driving 32Ω headphones |
|
||||
| **Overlay** | `dtoverlay=audioinjector-wm8731-audio` or `audioinjector-octo-hat` |
|
||||
| **ALSA Name** | `hw:CARD=audioinjectorpi,DEV=0` or `hw:1,0` |
|
||||
| **Price** | $35–40 USD |
|
||||
| **Availability** | Direct from audioinjector.net, Pimoroni, Amazon |
|
||||
|
||||
### Pros
|
||||
- Full ADC + DAC on one HAT — no separate wiring or breadboard
|
||||
- Custom kernel module with proven JACK compatibility at low latency
|
||||
- Hardware mixing on DSP core (can mix capture with playback)
|
||||
- 5V tolerant — no regulator or level shifter needed
|
||||
- Onboard headphone amp (TPA6130A2) — enough for monitoring in a pedal
|
||||
- 192kHz capable for future expansion
|
||||
- Very good noise floor (-93dB) — clean for guitar input
|
||||
|
||||
### Cons
|
||||
- Most expensive option after JustBoom
|
||||
- Custom kernel module — needs `rpi-source` kernel headers build on RPi OS
|
||||
- Form factor blocks all GPIO — conflicts with footswitch/display if using 40-pin
|
||||
- Line input is line-level (2Vrms) — guitar needs a preamp/buffer (common with ALL options)
|
||||
|
||||
### Known Issues
|
||||
- Kernel module build fails on first boot if `rpi-source` hasn't been run
|
||||
- Some revisions had high-pass filter at 4Hz — acceptable for guitar
|
||||
- Hardware mixing requires `hw:` device, not `plughw:` — PCM conversion done by DAC
|
||||
|
||||
---
|
||||
|
||||
## Option 2: IQaudio Codec Zero ★ Budget Recommend
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| **Chipset** | WM8782+ (ADC) + custom DAC stage |
|
||||
| **Sample Rates** | 8k–192kHz (practical limit ~48kHz due to BCKL sharing) |
|
||||
| **Bit Depth** | 24-bit |
|
||||
| **Input** | Stereo 3.5mm line-in, internal mic |
|
||||
| **Output** | Stereo 3.5mm line-out |
|
||||
| **Latency** | ~4-5ms round-trip (48kHz/128 frames) |
|
||||
| **Power** | 3.3V only — **NOT 5V tolerant** |
|
||||
| **Current** | ~50mA |
|
||||
| **Overlay** | `dtoverlay=iqaudio-codec` |
|
||||
| **ALSA Name** | `hw:CARD=IQaudIOCODEC,DEV=0` |
|
||||
| **Price** | ~$18 USD |
|
||||
| **Availability** | Pimoroni (discontinued but stocked), Amazon, eBay |
|
||||
|
||||
### Pros
|
||||
- Cheapest HAT with full ADC+DAC
|
||||
- Good overlay support — well-tested with ALSA/PulseAudio/JACK
|
||||
- Small form factor, low power draw
|
||||
- 24-bit capable
|
||||
|
||||
### Cons
|
||||
- **3.3V only** — requires regulator if power supply is 5V
|
||||
- BCKL (bit clock) is shared between codec and Pi — limits practical sample rate
|
||||
- Line-level input — guitar needs preamp
|
||||
- No headphone amp — needs external amp or powered monitors
|
||||
- Discontinued — Pimorino no longer manufactures; stock may dry up
|
||||
- Front-panel headphone/speaker header uses non-standard footprint
|
||||
|
||||
### Known Issues
|
||||
- Shared BCKL causes clock jitter at 96kHz+ — use at 48kHz max for clean signal
|
||||
- Some units shipped with wrong resistor values on input — verified fix: replace R10/R11
|
||||
- Overlay `iqaudio-codec` conflicts with `hifiberry-dac` — cannot run both
|
||||
|
||||
---
|
||||
|
||||
## Option 3: PCM1808 + PCM5102 Breakout Combo ★ Lowest Cost
|
||||
|
||||
| Spec | PCM1808 | PCM5102 |
|
||||
|------|---------|---------|
|
||||
| **Function** | ADC (stereo line-in) | DAC (stereo line-out) |
|
||||
| **Spec** | 16-bit / 48kHz | 16/24/32-bit / 384kHz |
|
||||
| **Noise** | -86dB | -86dB (some hiss reports) |
|
||||
| **Power** | 3.3V | 3.3V |
|
||||
| **Price** | ~$5-6 | ~$5-6 |
|
||||
| **Pinout** | 8 pins, DIP | 12 pins, DIP |
|
||||
|
||||
### Total: ~$10–12
|
||||
|
||||
**Wiring (RPi 4B GPIO to both breakouts):**
|
||||
```
|
||||
RPi BCM Pin ───── PCM1808 ───── PCM5102
|
||||
GPIO18 (BCLK) ──→ 8 (BCK) ──→ 14 (BCK)
|
||||
GPIO19 (LRCLK) ──→ 7 (LRCK) ──→ 13 (LRCK)
|
||||
GPIO20 (DIN) ─────────────→ 12 (DIN)
|
||||
GPIO21 (DOUT) ──→ 9 (DOUT)
|
||||
3.3V ───────────→ 6 (VCC) ──→ 15 (Vin)
|
||||
GND ────────────→ 5,10,11 ──→ 16,17,18
|
||||
```
|
||||
Note: PCM1808 pin 12 (FMT) to GND for I2S mode; pin 13 (MD1) to 3.3V.
|
||||
|
||||
**Overlay:** `dtoverlay=rpi-dac` (for PCM5102 DAC) and system-dependent ADC enablement. Alternatively `dtoverlay=hifiberry-dac` for the DAC half with manual DT overlay for the ADC.
|
||||
|
||||
### Pros
|
||||
- Cheapest option by far
|
||||
- Full ADC+DAC in small footprint, can be soldered to perfboard
|
||||
- PCM1808 is a proven codec — used in many DIY projects
|
||||
- Each part can be replaced independently
|
||||
|
||||
### Cons
|
||||
- **16-bit / 48kHz only on ADC** — no room for oversampling or future 96kHz
|
||||
- **PCM5102 has known noise floor issues** — audible hiss at idle, especially noticeable with high-gain guitar
|
||||
- Both require 3.3V — need regulator from 5V rail
|
||||
- **No HAT** — loose wiring is fragile for pedal internals
|
||||
- Two separate kernel considerations: DAC works with standard overlay, ADC needs manual DT configuration
|
||||
- No headphone amp, no hardware mixing
|
||||
- Extra cabling = more noise pickup risk in a pedal enclosure
|
||||
|
||||
---
|
||||
|
||||
## Option 4: Adafruit I2S Audio Bonnet ★ DAC Only
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| **Chipset** | PCM5102A (DAC only) |
|
||||
| **Sample Rates** | 16-bit / 48kHz |
|
||||
| **Output** | Stereo 3.5mm jack + headphone jack with volume wheel |
|
||||
| **Power** | 3.3V only |
|
||||
| **Overlay** | `dtoverlay=adafruit-i2s-dac` |
|
||||
| **Price** | ~$14 USD |
|
||||
|
||||
### Summary
|
||||
**Not suitable as primary I/O** — this is a DAC-only HAT. No ADC means no guitar input. Could pair with a separate ADC breakout (e.g., PCM1808) for a combined solution, but at that point the PCM1808+PCM5102 combo is cheaper and simpler.
|
||||
|
||||
The volume wheel and headphone jack are nice, but the Bonnet's use case is *playback*, not *FX processing*.
|
||||
|
||||
---
|
||||
|
||||
## Option 5: JustBoom DAC/ADC HAT
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| **Chipset** | PCM1864 ADC + PCM5122 DAC |
|
||||
| **Sample Rates** | 8k–192kHz |
|
||||
| **Bit Depth** | 24-bit |
|
||||
| **Input** | Stereo RCA + 3.5mm line-in |
|
||||
| **Output** | Stereo RCA + 3.5mm headphone jack |
|
||||
| **Power** | 5V tolerant |
|
||||
| **Overlay** | `dtoverlay=justboom-dac` (DAC) + separate ADC overlay |
|
||||
| **Price** | ~$40+ USD |
|
||||
| **Availability** | justboom.co, Amazon (both limited stock) |
|
||||
|
||||
### Pros
|
||||
- Full ADC+DAC, very low noise floor (-95dB)
|
||||
- 5V tolerant — clean power from Pi GPIO
|
||||
- Both RCA and 3.5mm I/O — flexible for pedal wiring
|
||||
- Hardware mixing on PCM5122
|
||||
- 192kHz capable
|
||||
|
||||
### Cons
|
||||
- **Expensive** — $40+, most costly option
|
||||
- Harder to source than AudioInjector
|
||||
- Separate overlays for DAC and ADC — more complex config.txt
|
||||
- Large footprint — takes full HAT slot + extra board space
|
||||
- Headphone amp is limited (only ~30mW into 32Ω)
|
||||
|
||||
---
|
||||
|
||||
## Option 6: WM8731-based (Waveshare PHAT DAC, etc.)
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| **Chipset** | Wolfson/Cirrus WM8731 |
|
||||
| **Sample Rates** | 8k–48kHz |
|
||||
| **Bit Depth** | 24-bit |
|
||||
| **Input** | Stereo line-in + mic in |
|
||||
| **Output** | Stereo line-out + headphone out (built-in amp) |
|
||||
| **Power** | 3.3V only |
|
||||
| **Overlay** | Manual Device Tree overlay required (no upstream kernel support) |
|
||||
| **Price** | ~$20 |
|
||||
| **Availability** | Waveshare, Amazon, eBay |
|
||||
|
||||
### Pros
|
||||
- Full ADC+DAC on single chip — well-designed codec
|
||||
- Built-in headphone amp
|
||||
- 24-bit
|
||||
- Widely cloned — many variants available
|
||||
|
||||
### Cons
|
||||
- **No upstream kernel overlay** — must write and compile custom DT overlay
|
||||
- Known charge pump noise on output (-84dB noise floor, audible with quiet sources)
|
||||
- 48kHz max (WM8731 has no 96kHz mode)
|
||||
- 3.3V only
|
||||
- Manual overlay = fragile setup, breaks on kernel update
|
||||
- Many clone variants have inconsistent pin headers (2x20 vs stacking)
|
||||
|
||||
---
|
||||
|
||||
## RPi 4B I2S Pinout
|
||||
|
||||
| Signal | BCM GPIO | Physical Pin | Alt Function | Direction |
|
||||
|--------|----------|:------------:|:------------:|:---------:|
|
||||
| **BCLK** (Bit Clock) | GPIO18 | Pin 12 | ALT5 (I2S) | Master output |
|
||||
| **LRCLK** (Frame Sync) | GPIO19 | Pin 35 | ALT5 (I2S) | Master output |
|
||||
| **DIN** (Data Input to Pi) | GPIO20 | Pin 38 | ALT5 (I2S) | Input |
|
||||
| **DOUT** (Data Output from Pi) | GPIO21 | Pin 40 | ALT5 (I2S) | Output |
|
||||
| **MCLK** (Master Clock) | GPIO28 | Pin 3 | ALT2 (I2S) | Optional — not all codecs need it |
|
||||
| **GND** | — | Pins 6,9,14,25,30,34,39 | — | — |
|
||||
| **3.3V** | — | Pins 1,17 | — | — |
|
||||
| **5V** | — | Pins 2,4 | — | — |
|
||||
|
||||
### Key Notes
|
||||
- RPi 4B can supply **BCLK up to 32MHz** — enough for 192kHz stereo 32-bit
|
||||
- **MCLK is optional** for most codecs (PCM1808, PCM5102, CS4344 work without it)
|
||||
- WM8731 needs explicit MCLK (12.288MHz for 48kHz) from GPIO28
|
||||
- DMA channels are shared with SD card — heavy audio I/O can cause SD card glitches
|
||||
- Ensure `dtparam=i2s=on` in config.txt if overlay doesn't enable it
|
||||
|
||||
---
|
||||
|
||||
## Config.txt Overlay Reference
|
||||
|
||||
```
|
||||
# ── AudioInjector Stereo HAT ─────────────────────────────
|
||||
dtoverlay=audioinjector-wm8731-audio
|
||||
|
||||
# ── IQaudio Codec Zero ──────────────────────────────────
|
||||
dtoverlay=iqaudio-codec
|
||||
|
||||
# ── PCM1808 + PCM5102 combo ────────────────────────────
|
||||
dtoverlay=rpi-dac
|
||||
# (ADC needs manual DT overlay — none exists upstream)
|
||||
|
||||
# ── Adafruit I2S Audio Bonnet ───────────────────────────
|
||||
dtoverlay=adafruit-i2s-dac
|
||||
|
||||
# ── JustBoom DAC+ADC ────────────────────────────────────
|
||||
dtoverlay=justboom-dac
|
||||
dtoverlay=justboom-adc
|
||||
|
||||
# ── WM8731 (manual, no upstream) ────────────────────────
|
||||
# Requires custom compiled overlay — see:
|
||||
# github.com/raspberrypi/linux/tree/rpi-6.6.y/arch/arm/boot/dts/overlays
|
||||
# wm8731-soundcard-overlay.dts (not upstreamed)
|
||||
```
|
||||
|
||||
After adding any overlay, disable onboard audio:
|
||||
```
|
||||
# Disable Pi's own audio hardware
|
||||
dtparam=audio=off
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JACK Latency Analysis
|
||||
|
||||
At 48kHz sample rate:
|
||||
|
||||
| Frames/Period | Buffer Latency (ms) | Round-Trip Estimate | CPU Load | Risk Level |
|
||||
|:------------:|:-------------------:|:-------------------:|:--------:|:----------:|
|
||||
| 64 | 1.3ms | ~2-4ms | High | Marginal on RPi 4B |
|
||||
| **128** | **2.6ms** | **~4-6ms** | **Medium** | **Recommended target** |
|
||||
| 256 | 5.3ms | ~7-10ms | Low | Acceptable fallback |
|
||||
| 512 | 10.6ms | ~12-15ms | Very Low | Fails <10ms criterion |
|
||||
|
||||
### Notes
|
||||
- **<10ms round-trip is achievable** at 128 or 256 frames with any of the HAT options
|
||||
- AudioInjector demonstrated 1.8ms round-trip at 48kHz/128 in OSS testing
|
||||
- RPi 4B Cortex-A72 can sustain 128 frames at 48kHz with moderate DSP load
|
||||
- NAM model inference is the bottleneck — NOT the audio I/O
|
||||
- `jackd -R -d alsa -d hw:1,0 -r 48000 -p 128 -n 3` — 3 periods for safety
|
||||
- For lowest latency: `-p 64 -n 2` (2 periods) but has xruns with heavy FX chains
|
||||
|
||||
### JACK Start Command
|
||||
|
||||
```bash
|
||||
# Kill PulseAudio first (it grabs ALSA)
|
||||
pulseaudio --kill
|
||||
|
||||
# Start JACK
|
||||
jackd -R -d alsa \
|
||||
-d hw:audioinjectorpi,0 \
|
||||
-r 48000 \
|
||||
-p 128 \
|
||||
-n 3 \
|
||||
-P 0 \
|
||||
-C 1
|
||||
```
|
||||
|
||||
For ALSA device name: use `aplay -l` and `arecord -l` after boot to confirm.
|
||||
|
||||
---
|
||||
|
||||
## ALSA Device Naming
|
||||
|
||||
After overlay is loaded:
|
||||
|
||||
| Option | Capture (ADC) Device | Playback (DAC) Device |
|
||||
|--------|---------------------|----------------------|
|
||||
| AudioInjector Stereo | `hw:CARD=audioinjectorpi,DEV=0` | `hw:CARD=audioinjectorpi,DEV=0` |
|
||||
| IQaudio Codec Zero | `hw:CARD=IQaudIOCODEC,DEV=0` | `hw:CARD=IQaudIOCODEC,DEV=0` |
|
||||
| PCM1808 + PCM5102 | `hw:CARD=pcm1808,DEV=0` | `hw:CARD=ALSA,DEV=0` (or `hw:CARD=sndrpirpi,DEV=0`) |
|
||||
| JustBoom DAC/ADC | `hw:CARD=justboomadc,DEV=0` | `hw:CARD=justboomdac,DEV=0` |
|
||||
| WM8731 (manual) | `hw:CARD=wm8731,DEV=0` | `hw:CARD=wm8731,DEV=0` |
|
||||
|
||||
Run `cat /proc/asound/cards` after boot to confirm.
|
||||
|
||||
---
|
||||
|
||||
## Power Compatibility
|
||||
|
||||
| Component | VDD | Notes |
|
||||
|-----------|:---:|-------|
|
||||
| RPi 4B GPIO (3.3V rail) | 3.3V | Max 50mA drawn from 3.3V pin |
|
||||
| RPi 4B GPIO (5V rail) | 5V | Direct from USB-C, up to 3A |
|
||||
| AudioInjector Stereo HAT | **5V** | Pass-through — can chain power |
|
||||
| IQaudio Codec Zero | **3.3V** | Needs 3.3V from GPIO pin 1 or external regulator |
|
||||
| PCM1808 | **3.3V** | ~4mA typ |
|
||||
| PCM5102 | **3.3V** | ~20mA typ |
|
||||
| Adafruit Bonnet | **3.3V** | |
|
||||
| JustBoom DAC/ADC | **5V** | Onboard regulator |
|
||||
| WM8731 | **3.3V** | |
|
||||
|
||||
**5V-tolerant HATs (AudioInjector, JustBoom)** are cleaner for a pedal: they don't draw from the limited 3.3V rail, and regulation happens on the HAT itself with dedicated LDOs.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Solution: AudioInjector Stereo HAT
|
||||
|
||||
### Why it wins for the Pi Multi-FX Pedal
|
||||
|
||||
1. **Full ADC+DAC on one HAT** — no separate breadboard wiring, clean signal path in a pedal enclosure
|
||||
2. **Best latency** — 1.8ms round-trip demonstrated, well under the 10ms target
|
||||
3. **5V tolerant** — stable power from Pi GPIO 5V pin, no regulator needed
|
||||
4. **Onboard headphone amp** — can drive 32Ω headphones for silent practice monitoring
|
||||
5. **Hardware mixing** — can blend dry guitar with processed signal without extra DSP
|
||||
6. **192kHz capable** — room for future oversampling or high-res captures
|
||||
7. **Proven JACK compatibility** — custom kernel module maintained, works with NAM + LV2 plugins
|
||||
8. **-93dB noise floor** — clean enough for high-gain guitar chains
|
||||
|
||||
### Trade-off: GPIO Blocking
|
||||
|
||||
The HAT form factor blocks the full 40-pin GPIO header. For this pedal, the footswitch/display/LED GPIO will need to be routed via:
|
||||
- **Stacking header** — solder a stacking female header to the HAT, mount on top of the GPIO pins
|
||||
- **Separate GPIO expander** — MCP23017 (I2C) for footswitches, freeing Pi GPIO for audio
|
||||
- **Reroute to P5 header** — if using an older Pi 4B revision with the 8-pin P5 header (rare on 4B)
|
||||
|
||||
**Recommendation:** Use a **40-pin female stacking header** between the HAT and Pi. The HAT sits on top of the stack, and the footswitch/display/LED GPIO wires connect to the exposed lower pins.
|
||||
|
||||
### Alternative: PCM1808 + PCM5102 (Budget)
|
||||
|
||||
If the $35-40 for AudioInjector is too much, the breakout combo works *if*:
|
||||
- You're comfortable soldering on perfboard or protoboard
|
||||
- You accept 16-bit / 48kHz limit on the ADC
|
||||
- You add a **noise filter** (RC low-pass, 10µF cap) on PCM5102 output
|
||||
- You use shielded wiring inside the enclosure to prevent interference
|
||||
|
||||
---
|
||||
|
||||
## Additional Considerations
|
||||
|
||||
### Guitar Preamp / Buffer
|
||||
**Every I2S ADC option requires a preamp for guitar-level input.** Guitar pickups output ~100mV–1V (depending on pickups), while line-level inputs expect ~1-2Vrms. Options:
|
||||
- **Simple JFET buffer** (2N5457 or similar) — ~$2 in parts, clean, unity gain
|
||||
- **Op-amp non-inverting stage** (TL072 + a few passives) — ~$1.50, adjustable gain
|
||||
- **Commercial preamp board** (e.g., GPCB, or small guitar preamp PCB) — ~$5-15
|
||||
|
||||
**For the pedal design:** a single TL072-based preamp with gain switch (0dB/12dB/24dB) before the ADC input is recommended. Power from the Pi's 5V rail via a 3.3V regulator (AMS1117).
|
||||
|
||||
### Noise Isolation
|
||||
- Use ferrite beads on all I2S lines (BCLK, LRCLK, DIN, DOUT) if noise is audible
|
||||
- Keep analog traces short — mount preamp physically close to ADC input
|
||||
- Separate analog ground from digital ground at a single star point
|
||||
- Use 100nF + 10µF decoupling caps on all codec power pins
|
||||
|
||||
### GPIO Conflicts
|
||||
RPi 4B GPIOs used by audio HATs (BCM 18/19/20/21) are **not available for other uses**. Plan footswitch/display/LED wiring on the remaining 20+ available GPIOs or use I2C expander.
|
||||
|
||||
---
|
||||
|
||||
## BOM: AudioInjector Stereo HAT Path
|
||||
|
||||
| Item | Part | Qty | Est. Cost | Source |
|
||||
|------|------|:---:|:---------:|--------|
|
||||
| Audio I/O | AudioInjector Stereo HAT | 1 | $38 | audioinjector.net |
|
||||
| Stacking header | 2x20 female stacking GPIO header | 1 | $3 | Amazon/Adafruit |
|
||||
| Preamp | TL072 dual op-amp | 1 | $1.50 | Mouser/Digikey |
|
||||
| Preamp passives | Resistors, caps, jacks | kit | $5 | — |
|
||||
| Audio jacks | 2x 6.35mm mono TRS jacks (input + output) | 2 | $4 | Amazon |
|
||||
| Power | USB-C PD 5V/3A supply | 1 | $10 | Anker/Amazon |
|
||||
| **Total** | | | **~$61.50** | |
|
||||
|
||||
## BOM: PCM1808 + PCM5102 Budget Path
|
||||
|
||||
| Item | Part | Qty | Est. Cost | Source |
|
||||
|------|------|:---:|:---------:|--------|
|
||||
| ADC | PCM1808 breakout board | 1 | $5.50 | Amazon/AliExpress |
|
||||
| DAC | PCM5102 breakout board | 1 | $5.50 | Amazon/AliExpress |
|
||||
| Perfboard | 5x7cm protoboard | 1 | $2 | Amazon |
|
||||
| Preamp | TL072 dual op-amp | 1 | $1.50 | Mouser |
|
||||
| Preamp passives | Resistors, caps, jacks | kit | $5 | — |
|
||||
| Audio jacks | 2x 6.35mm mono TRS jacks | 2 | $4 | Amazon |
|
||||
| Wiring | Shielded audio wire + jumper wires | — | $3 | — |
|
||||
| Power | USB-C PD 5V/3A supply | 1 | $10 | Anker/Amazon |
|
||||
| **Total** | | | **~$36.50** | |
|
||||
|
||||
---
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
**Use AudioInjector Stereo HAT.** It's the cleanest path to <10ms round-trip latency with full ADC+DAC, good noise floor, and proven JACK hardware. The ~$38 cost is worth the combined headphone amp, hardware mixing, and solder-free installation.
|
||||
|
||||
**If budget is tight:** PCM1808 + PCM5102 breakouts work but require perfboard assembly, accept 16-bit/48kHz limits, and need extra noise filtering on the DAC output.
|
||||
|
||||
**Do NOT use:** Adafruit Bonnet (DAC-only → needs separate ADC), IQaudio Codec Zero (discontinued, BCKL jitter), or WM8731-based (no upstream overlay, charge pump noise).
|
||||
@@ -0,0 +1,30 @@
|
||||
# Pi Multi-FX Pedal — Requirements
|
||||
# Core audio
|
||||
jackclient-python>=0.5.5
|
||||
numpy>=1.24
|
||||
|
||||
# NAM inference (on RPi 4B aarch64)
|
||||
# libtorch via nam Python package
|
||||
neural-amp-modeler>=0.8.0
|
||||
|
||||
# IR convolution
|
||||
# scipy for wav/IR file loading
|
||||
scipy>=1.10
|
||||
|
||||
# UI
|
||||
RPi.GPIO>=0.7.1
|
||||
adafruit-circuitpython-ssd1306>=2.12
|
||||
adafruit-circuitpython-neopixel>=6.3
|
||||
pillow>=10.0
|
||||
|
||||
# MIDI
|
||||
python-rtmidi>=1.5
|
||||
|
||||
# Config & presets
|
||||
pyyaml>=6.0
|
||||
orjson>=3.9
|
||||
|
||||
# Testing
|
||||
pytest>=7.4
|
||||
pytest-asyncio>=0.21
|
||||
pytest-mock>=3.11
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env bash
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Pi Multi-FX Pedal — First-boot audio setup
|
||||
#
|
||||
# Installs JACK + ALSA + I2S audio configuration for the RPi 4B.
|
||||
# Must be run as root (sudo).
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./setup_audio.sh [hat_type]
|
||||
#
|
||||
# hat_type Audio HAT key (default: audioinjector)
|
||||
# Options: audioinjector, pcm1808_pcm5102, iqaudio_codec,
|
||||
# justboom, wm8731
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HAT_TYPE="${1:-audioinjector}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# ── Colour helpers ─────────────────────────────────────────────────
|
||||
info() { printf "\e[34m[INFO] %s\e[0m\n" "$*"; }
|
||||
ok() { printf "\e[32m[ OK ] %s\e[0m\n" "$*"; }
|
||||
warn() { printf "\e[33m[WARN] %s\e[0m\n" "$*"; }
|
||||
err() { printf "\e[31m[FAIL] %s\e[0m\n" "$*"; }
|
||||
|
||||
# ── Root check ─────────────────────────────────────────────────────
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
err "This script must be run as root (sudo ./setup_audio.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Architecture check ─────────────────────────────────────────────
|
||||
ARCH="$(uname -m)"
|
||||
if [[ "$ARCH" != "aarch64" && "$ARCH" != "armv7l" ]]; then
|
||||
err "This script is for Raspberry Pi (aarch64/armv7l), detected: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "========== Pi Multi-FX Pedal — Audio Setup =========="
|
||||
info "HAT type: $HAT_TYPE"
|
||||
info "Architecture: $ARCH"
|
||||
echo ""
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Install packages
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
info "Step 1: Installing audio packages..."
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
jackd2 \
|
||||
alsa-utils \
|
||||
jack-tools \
|
||||
jackd \
|
||||
libjack-jackd2-dev \
|
||||
python3-pip \
|
||||
2>&1 | tail -1
|
||||
|
||||
ok "Audio packages installed"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Real-time priority limits
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
info "Step 2: Setting real-time priority limits..."
|
||||
|
||||
LIMITS_FILE="/etc/security/limits.d/99-audio.conf"
|
||||
if [[ ! -f "$LIMITS_FILE" ]]; then
|
||||
cat > "$LIMITS_FILE" <<'LIMITS'
|
||||
# Pi Multi-FX Pedal — real-time audio limits
|
||||
@audio - rtprio 95
|
||||
@audio - memlock unlimited
|
||||
@audio - nice -20
|
||||
LIMITS
|
||||
ok "Created $LIMITS_FILE"
|
||||
else
|
||||
ok "$LIMITS_FILE already exists"
|
||||
fi
|
||||
|
||||
# Add 'pi' user to audio group if not already
|
||||
if groups pi | grep -q '\baudio\b'; then
|
||||
ok "User 'pi' already in audio group"
|
||||
else
|
||||
usermod -a -G audio pi
|
||||
info "Added 'pi' to audio group (will take effect on next login)"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Step 3: I2S overlay in config.txt
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
info "Step 3: Configuring I2S overlay in /boot/config.txt..."
|
||||
|
||||
declare -A OVERLAYS
|
||||
OVERLAYS[audioinjector]="dtoverlay=audioinjector-wm8731"
|
||||
OVERLAYS[pcm1808_pcm5102]="dtoverlay=audiosense-pi"
|
||||
OVERLAYS[iqaudio_codec]="dtoverlay=iqaudio-codec"
|
||||
OVERLAYS[justboom]="dtoverlay=justboom-dac"
|
||||
OVERLAYS[wm8731]="dtoverlay=wm8731"
|
||||
|
||||
OVERLAY_LINE="${OVERLAYS[$HAT_TYPE]:-}"
|
||||
if [[ -z "$OVERLAY_LINE" ]]; then
|
||||
err "Unknown HAT type '$HAT_TYPE'"
|
||||
echo " Valid options: ${!OVERLAYS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIG_TXT="/boot/config.txt"
|
||||
|
||||
if grep -qF "$OVERLAY_LINE" "$CONFIG_TXT"; then
|
||||
ok "I2S overlay already present: $OVERLAY_LINE"
|
||||
else
|
||||
{
|
||||
echo ""
|
||||
echo "# Pi Multi-FX Pedal — $HAT_TYPE"
|
||||
echo "$OVERLAY_LINE"
|
||||
} >> "$CONFIG_TXT"
|
||||
ok "Appended to $CONFIG_TXT: $OVERLAY_LINE"
|
||||
NEED_REBOOT=true
|
||||
fi
|
||||
|
||||
# Enable I2C (for OLED / sensor I2C) if not already
|
||||
if ! grep -q "^dtparam=i2c_arm=on" "$CONFIG_TXT"; then
|
||||
echo "dtparam=i2c_arm=on" >> "$CONFIG_TXT"
|
||||
info "Enabled I2C interface"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Disable onboard audio (headphone jack) to avoid conflicts
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
info "Step 4: Disabling onboard analog audio (headphone jack)..."
|
||||
|
||||
if grep -q "^dtparam=audio=on" "$CONFIG_TXT"; then
|
||||
sed -i 's/^dtparam=audio=on/dtparam=audio=off/' "$CONFIG_TXT"
|
||||
info "Disabled onboard analog audio (dtparam=audio=off)"
|
||||
NEED_REBOOT=true
|
||||
elif grep -q "^dtparam=audio=off" "$CONFIG_TXT"; then
|
||||
ok "Onboard audio already disabled"
|
||||
else
|
||||
echo "dtparam=audio=off" >> "$CONFIG_TXT"
|
||||
info "Disabled onboard analog audio"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Systemd service for JACK
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
info "Step 5: Installing JACK systemd service..."
|
||||
|
||||
SERVICE_FILE="/etc/systemd/system/jackd.service"
|
||||
PYTHON_BIN="/usr/bin/python3"
|
||||
GENERATE_SCRIPT=$(cat <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, '/home/pi/pi-multifx-pedal/src')
|
||||
from system.audio import AudioConfig, AudioSystem
|
||||
hat = sys.argv[1] if len(sys.argv) > 1 else "audioinjector"
|
||||
cfg = AudioConfig(hat_type=hat)
|
||||
print(AudioSystem.systemd_service_content(cfg))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
# Dynamic generation via Python so service stays in sync with code
|
||||
if [[ -d "$PROJECT_DIR/src" ]]; then
|
||||
PYTHONPATH="$PROJECT_DIR/src" python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '$PROJECT_DIR/src')
|
||||
from system.audio import AudioConfig, AudioSystem
|
||||
cfg = AudioConfig(hat_type='$HAT_TYPE')
|
||||
print(AudioSystem.systemd_service_content(cfg))
|
||||
" > "$SERVICE_FILE"
|
||||
ok "Generated $SERVICE_FILE from AudioSystem.systemd_service_content()"
|
||||
else
|
||||
# Fallback: hardcoded unit
|
||||
cat > "$SERVICE_FILE" <<UNIT
|
||||
[Unit]
|
||||
Description=JACK Audio Server — Pi Multi-FX Pedal
|
||||
After=sound.target network.target
|
||||
Wants=multi-fx-pedal.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
ExecStart=/usr/bin/jackd -P70 -p128 -n2 -r48000 -dalsa -dhw:0 -i2 -o2
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitRTPRIO=95
|
||||
LimitMEMLOCK=infinity
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
ok "Created fallback $SERVICE_FILE (src directory not found)"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Step 6: Silence ALSA mixer defaults
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
info "Step 6: Setting default ALSA volumes..."
|
||||
|
||||
# Set a reasonable default capture gain for guitar input
|
||||
if command -v amixer &>/dev/null; then
|
||||
# Try to unmute and set capture volume on card 0
|
||||
amixer -c 0 set 'Mic' 80% unmute 2>/dev/null || true
|
||||
amixer -c 0 set 'Capture' 80% unmute 2>/dev/null || true
|
||||
ok "ALSA mixer defaults set"
|
||||
else
|
||||
info "amixer not available — skipping volume config"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Step 7: Systemd reload & enable
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
info "Step 7: Reloading systemd and enabling JACK service..."
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable jackd.service
|
||||
ok "JACK service enabled (start on next boot)"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
info "========== Setup Complete =========="
|
||||
|
||||
if [[ "${NEED_REBOOT:-false}" == true ]]; then
|
||||
warn "REBOOT REQUIRED — I2S overlay changes need a reboot to take effect"
|
||||
warn "Run: sudo reboot"
|
||||
else
|
||||
ok "No reboot required — you can start JACK now:"
|
||||
echo " sudo systemctl start jackd"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Post-setup verification:"
|
||||
echo " 1. Check ALSA devices: aplay -l && arecord -l"
|
||||
echo " 2. Verify JACK status: jack_wait -c"
|
||||
echo " 3. Measure latency: jack_iodelay"
|
||||
echo " 4. Listen for xruns: jack_showtime -c"
|
||||
echo " 5. Run Python test: python3 -c "
|
||||
echo " \"from system.audio import AudioSystem; "
|
||||
echo " sys = AudioSystem(); "
|
||||
echo " print(sys.list_devices())\""
|
||||
echo ""
|
||||
info "Documented ALSA device names:"
|
||||
echo " Card 0 — I2S HAT ($HAT_TYPE)"
|
||||
echo " Playback: hw:0,0 (DAC output)"
|
||||
echo " Capture: hw:0,0 (ADC input)"
|
||||
echo " Card 1 — (if present) USB audio / HDMI"
|
||||
@@ -0,0 +1,130 @@
|
||||
"""IR cab loader — impulse response convolution for cabinet simulation.
|
||||
|
||||
Uses numpy FFT-based convolution for real-time IR playback.
|
||||
On RPi 4B, typical IR files (512-2048 taps at 48kHz) perform
|
||||
efficiently with block-based overlap-add.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_IR_DIR = Path.home() / ".pedal" / "irs"
|
||||
|
||||
|
||||
@dataclass
|
||||
class IRFile:
|
||||
"""Metadata for a loaded IR file."""
|
||||
name: str
|
||||
path: str
|
||||
sample_rate: int
|
||||
num_taps: int
|
||||
length_ms: float
|
||||
channels: int = 1
|
||||
|
||||
|
||||
class IRLoader:
|
||||
"""Loads and manages impulse response files for cab simulation.
|
||||
|
||||
Uses FFT-based overlap-add convolution. Handles both mono
|
||||
and stereo IR files.
|
||||
"""
|
||||
|
||||
def __init__(self, ir_dir: str | Path = DEFAULT_IR_DIR):
|
||||
self._ir_dir = Path(ir_dir)
|
||||
self._ir_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._current_ir: Optional[IRFile] = None
|
||||
self._ir_data: Optional[np.ndarray] = None
|
||||
self._ir_fft: Optional[np.ndarray] = None
|
||||
|
||||
def load_ir(self, ir_path: str | Path) -> bool:
|
||||
"""Load an IR file from disk.
|
||||
|
||||
Args:
|
||||
ir_path: Path to .wav IR file.
|
||||
|
||||
Returns:
|
||||
True if successfully loaded.
|
||||
"""
|
||||
path = Path(ir_path)
|
||||
if not path.exists() or path.suffix not in (".wav",):
|
||||
logger.error("IR file not found or invalid: %s", ir_path)
|
||||
return False
|
||||
|
||||
sr, data = wavfile.read(path)
|
||||
|
||||
# Normalize to float32 [-1, 1]
|
||||
if data.dtype == np.int16:
|
||||
data = data.astype(np.float32) / 32768.0
|
||||
elif data.dtype == np.int32:
|
||||
data = data.astype(np.float32) / 2147483648.0
|
||||
elif data.dtype == np.uint8:
|
||||
data = (data.astype(np.float32) - 128.0) / 128.0
|
||||
elif data.dtype != np.float32:
|
||||
data = data.astype(np.float32)
|
||||
|
||||
# Mono if multi-channel, take first channel
|
||||
if data.ndim > 1:
|
||||
data = data[:, 0]
|
||||
|
||||
num_taps = len(data)
|
||||
length_ms = (num_taps / sr) * 1000
|
||||
|
||||
self._current_ir = IRFile(
|
||||
name=path.stem,
|
||||
path=str(path),
|
||||
sample_rate=sr,
|
||||
num_taps=num_taps,
|
||||
length_ms=length_ms,
|
||||
)
|
||||
self._ir_data = data
|
||||
self._ir_fft = np.fft.rfft(data)
|
||||
|
||||
logger.info(
|
||||
"Loaded IR: %s (%d taps, %.1fms @ %dHz)",
|
||||
path.stem, num_taps, length_ms, sr,
|
||||
)
|
||||
return True
|
||||
|
||||
def get_irs(self) -> list[IRFile]:
|
||||
"""List all available IR files in the IR directory."""
|
||||
irs: list[IRFile] = []
|
||||
for f in sorted(self._ir_dir.glob("*.wav")):
|
||||
try:
|
||||
sr, data = wavfile.read(f)
|
||||
num_taps = len(data)
|
||||
length_ms = (num_taps / sr) * 1000
|
||||
channels = data.ndim if data.ndim > 1 else 1
|
||||
irs.append(IRFile(
|
||||
name=f.stem,
|
||||
path=str(f),
|
||||
sample_rate=sr,
|
||||
num_taps=num_taps,
|
||||
length_ms=length_ms,
|
||||
channels=channels,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("Could not read IR %s: %s", f.name, e)
|
||||
return irs
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Unload the current IR."""
|
||||
self._current_ir = None
|
||||
self._ir_data = None
|
||||
self._ir_fft = None
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
return self._current_ir is not None
|
||||
|
||||
@property
|
||||
def current_ir(self) -> Optional[IRFile]:
|
||||
return self._current_ir
|
||||
@@ -0,0 +1,97 @@
|
||||
"""NAM A2 model host — load, configure, and run inference on RPi 4B.
|
||||
|
||||
Leverages `neural-amp-modeler` (nam) Python package or the NAM LV2 plugin
|
||||
for real-time inference on the Raspberry Pi 4B.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAM_DIR = Path.home() / ".pedal" / "nam"
|
||||
DEFAULT_LV2_MODEL_DIR = Path.home() / ".lv2" / "nam-models"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NAMModel:
|
||||
"""Metadata for a loaded NAM model."""
|
||||
name: str
|
||||
path: str
|
||||
size_mb: float
|
||||
sample_rate: int = 48000
|
||||
latency_samples: int = 0
|
||||
compatible: bool = True
|
||||
|
||||
|
||||
class NAMHost:
|
||||
"""Hosts NAM models for real-time amp simulation.
|
||||
|
||||
On RPi 4B, this delegates to either:
|
||||
1. The NAM LV2 plugin (via JACK/Carla) — for production use
|
||||
2. The nam Python package — for testing/development
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
models_dir: str | Path = DEFAULT_NAM_DIR,
|
||||
lv2_dir: str | Path = DEFAULT_LV2_MODEL_DIR,
|
||||
use_lv2: bool = True,
|
||||
):
|
||||
self._models_dir = Path(models_dir)
|
||||
self._lv2_dir = Path(lv2_dir)
|
||||
self._use_lv2 = use_lv2
|
||||
self._loaded_model: Optional[NAMModel] = None
|
||||
self._models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load_model(self, model_path: str) -> bool:
|
||||
"""Load a NAM model file into the inference engine."""
|
||||
path = Path(model_path)
|
||||
if not path.exists() or path.suffix not in (".nam",):
|
||||
logger.error("Model not found or invalid: %s", model_path)
|
||||
return False
|
||||
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
is_feather = size_mb < 10
|
||||
|
||||
self._loaded_model = NAMModel(
|
||||
name=path.stem,
|
||||
path=str(path),
|
||||
size_mb=size_mb,
|
||||
compatible=is_feather,
|
||||
)
|
||||
|
||||
# Symlink for LV2 plugin access
|
||||
if self._use_lv2:
|
||||
self._lv2_dir.mkdir(parents=True, exist_ok=True)
|
||||
link = self._lv2_dir / path.name
|
||||
if link.exists() or link.is_symlink():
|
||||
link.unlink()
|
||||
link.symlink_to(path.absolute())
|
||||
|
||||
logger.info(
|
||||
"Loaded NAM model: %s (%.1f MB, %s)",
|
||||
self._loaded_model.name,
|
||||
size_mb,
|
||||
"compatible" if is_feather else "may cause xruns",
|
||||
)
|
||||
return True
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Unload the current NAM model."""
|
||||
self._loaded_model = None
|
||||
logger.info("NAM model unloaded")
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
return self._loaded_model is not None
|
||||
|
||||
@property
|
||||
def current_model(self) -> Optional[NAMModel]:
|
||||
return self._loaded_model
|
||||
@@ -0,0 +1,824 @@
|
||||
"""FX/Audio pipeline — the main real-time signal chain.
|
||||
|
||||
Runs on RPi 4B under JACK, connecting:
|
||||
Guitar -> Gate -> Comp -> Boost -> NAM Amp -> IR Cab -> EQ -> Mod -> Delay -> Reverb -> Volume -> Out
|
||||
|
||||
Each block can be bypassed per-preset. The pipeline manages
|
||||
block-level audio routing using numpy arrays for zero-copy
|
||||
inter-block communication.
|
||||
|
||||
All DSP state is stored per-block-instance in self._state,
|
||||
keyed by chain index. This allows multiple instances of the
|
||||
same effect type at different positions in the chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .nam_host import NAMHost, NAMModel
|
||||
from .ir_loader import IRLoader, IRFile
|
||||
from ..presets.types import FXBlock, FXType, Preset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BLOCK_SIZE = 256 # Samples per JACK callback
|
||||
SAMPLE_RATE = 48000 # Standard guitar audio rate
|
||||
|
||||
# ── Biquad coefficient helpers ─────────────────────────────────────
|
||||
|
||||
_EPS = 1e-10
|
||||
|
||||
|
||||
def _compute_lowshelf_coeffs(freq: float, gain_db: float, q: float, sr: float) -> tuple:
|
||||
"""RBJ low-shelf biquad coefficients."""
|
||||
a = 10 ** (gain_db / 40.0)
|
||||
omega = 2 * np.pi * freq / sr
|
||||
sn = np.sin(omega)
|
||||
cs = np.cos(omega)
|
||||
beta = np.sqrt(a) / q # sqrt(A) / Q
|
||||
if gain_db >= 0:
|
||||
b0 = a * (a + 1 - (a - 1) * cs + beta * sn)
|
||||
b1 = 2 * a * (a - 1 - (a + 1) * cs)
|
||||
b2 = a * (a + 1 - (a - 1) * cs - beta * sn)
|
||||
a0 = a + 1 + (a - 1) * cs + beta * sn
|
||||
a1 = -2 * a * (a - 1 + (a + 1) * cs)
|
||||
a2 = a + 1 + (a - 1) * cs - beta * sn
|
||||
else:
|
||||
b0 = a * (a + 1 + (a - 1) * cs + beta * sn)
|
||||
b1 = -2 * a * (a - 1 + (a + 1) * cs)
|
||||
b2 = a * (a + 1 + (a - 1) * cs - beta * sn)
|
||||
a0 = a + 1 - (a - 1) * cs + beta * sn
|
||||
a1 = 2 * a * (a - 1 - (a + 1) * cs)
|
||||
a2 = a + 1 - (a - 1) * cs - beta * sn
|
||||
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
|
||||
|
||||
|
||||
def _compute_highshelf_coeffs(freq: float, gain_db: float, q: float, sr: float) -> tuple:
|
||||
"""RBJ high-shelf biquad coefficients."""
|
||||
a = 10 ** (gain_db / 40.0)
|
||||
omega = 2 * np.pi * freq / sr
|
||||
sn = np.sin(omega)
|
||||
cs = np.cos(omega)
|
||||
beta = np.sqrt(a) / q
|
||||
if gain_db >= 0:
|
||||
b0 = a * (a + 1 + (a - 1) * cs + beta * sn)
|
||||
b1 = -2 * a * (a - 1 + (a + 1) * cs)
|
||||
b2 = a * (a + 1 + (a - 1) * cs - beta * sn)
|
||||
a0 = a + 1 - (a - 1) * cs + beta * sn
|
||||
a1 = 2 * a * (a - 1 - (a + 1) * cs)
|
||||
a2 = a + 1 - (a - 1) * cs - beta * sn
|
||||
else:
|
||||
b0 = a * (a + 1 - (a - 1) * cs + beta * sn)
|
||||
b1 = 2 * a * (a - 1 - (a + 1) * cs)
|
||||
b2 = a * (a + 1 - (a - 1) * cs - beta * sn)
|
||||
a0 = a + 1 + (a - 1) * cs + beta * sn
|
||||
a1 = -2 * a * (a - 1 + (a + 1) * cs)
|
||||
a2 = a + 1 + (a - 1) * cs - beta * sn
|
||||
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
|
||||
|
||||
|
||||
def _compute_peaking_coeffs(freq: float, gain_db: float, q: float, sr: float) -> tuple:
|
||||
"""RBJ peaking biquad coefficients."""
|
||||
a = 10 ** (gain_db / 40.0)
|
||||
omega = 2 * np.pi * freq / sr
|
||||
sn = np.sin(omega)
|
||||
cs = np.cos(omega)
|
||||
alpha = sn / (2 * q)
|
||||
b0 = 1 + alpha * a
|
||||
b1 = -2 * cs
|
||||
b2 = 1 - alpha * a
|
||||
a0 = 1 + alpha / a
|
||||
a1 = -2 * cs
|
||||
a2 = 1 - alpha / a
|
||||
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
|
||||
|
||||
|
||||
# ── Circular delay line (block-vectorised) ─────────────────────────
|
||||
|
||||
class _DelayLine:
|
||||
"""Vectorised circular buffer with linear interpolation."""
|
||||
|
||||
__slots__ = ("buf", "max_len", "write_idx")
|
||||
|
||||
def __init__(self, max_delay_samples: int):
|
||||
self.buf = np.zeros(max_delay_samples, dtype=np.float32)
|
||||
self.max_len = max_delay_samples
|
||||
self.write_idx = 0
|
||||
|
||||
def write_block(self, block: np.ndarray) -> None:
|
||||
n = len(block)
|
||||
space = self.max_len - self.write_idx
|
||||
if n <= space:
|
||||
self.buf[self.write_idx:self.write_idx + n] = block
|
||||
else:
|
||||
first_part = n - space
|
||||
self.buf[self.write_idx:] = block[:space]
|
||||
self.buf[:first_part] = block[space:]
|
||||
self.write_idx = (self.write_idx + n) % self.max_len
|
||||
# Keep type: numpy automatically promotes on write into float32
|
||||
|
||||
def read_block(self, delay_samples: float, n_samples: int) -> np.ndarray:
|
||||
"""Read n_samples with linear interpolation at a fractional delay."""
|
||||
n_delay = int(delay_samples)
|
||||
frac = delay_samples - n_delay
|
||||
read_start = (self.write_idx - n_delay) % self.max_len
|
||||
indices = (read_start + np.arange(n_samples)) % self.max_len
|
||||
next_indices = (indices + 1) % self.max_len
|
||||
return self.buf[indices] * (1.0 - frac) + self.buf[next_indices] * frac
|
||||
|
||||
def add_to_block(self, block: np.ndarray, delay_samples: float,
|
||||
gain: float) -> np.ndarray:
|
||||
"""Add delayed + gained signal to block (for feedback loops)."""
|
||||
n_delay = int(delay_samples)
|
||||
frac = delay_samples - n_delay
|
||||
read_start = (self.write_idx - n_delay) % self.max_len
|
||||
indices = (read_start + np.arange(len(block))) % self.max_len
|
||||
next_indices = (indices + 1) % self.max_len
|
||||
delayed = self.buf[indices] * (1.0 - frac) + self.buf[next_indices] * frac
|
||||
return delayed * gain
|
||||
|
||||
def read_all(self) -> np.ndarray:
|
||||
"""Return the full buffer (for debugging / IR export)."""
|
||||
return self.buf.copy()
|
||||
|
||||
|
||||
# ── Schroeder reverb helpers ───────────────────────────────────────
|
||||
|
||||
class _CombFilter:
|
||||
"""Comb filter for Schroeder reverb."""
|
||||
|
||||
__slots__ = ("delay", "feedback", "damping", "damp_filt", "buf")
|
||||
|
||||
def __init__(self, delay_samples: int):
|
||||
self.delay = _DelayLine(delay_samples + 1)
|
||||
self.feedback: float = 0.5
|
||||
self.damping: float = 0.5 # low-pass damping coefficient
|
||||
self.damp_filt: float = 0.0 # state variable for damping
|
||||
self.buf = np.zeros(BLOCK_SIZE, dtype=np.float32)
|
||||
|
||||
def process(self, block: np.ndarray) -> np.ndarray:
|
||||
self.buf[:] = block
|
||||
# Write with feedback: out[n] = in[n] + feedback * damped_delayed
|
||||
delayed = self.delay.add_to_block(self.buf, self.delay.max_len - 1, self.feedback)
|
||||
# One-pole low-pass on feedback path
|
||||
damped = np.zeros_like(delayed)
|
||||
for i in range(len(delayed)):
|
||||
self.damp_filt = (1.0 - self.damping) * delayed[i] + self.damping * self.damp_filt
|
||||
damped[i] = self.damp_filt
|
||||
self.buf[:] = block + damped
|
||||
self.delay.write_block(self.buf)
|
||||
return self.buf
|
||||
|
||||
|
||||
class _AllpassFilter:
|
||||
"""Allpass filter for Schroeder reverb."""
|
||||
|
||||
__slots__ = ("delay", "gain", "buf")
|
||||
|
||||
def __init__(self, delay_samples: int):
|
||||
self.delay = _DelayLine(delay_samples + 1)
|
||||
self.gain: float = 0.5
|
||||
self.buf = np.zeros(BLOCK_SIZE, dtype=np.float32)
|
||||
|
||||
def process(self, block: np.ndarray) -> np.ndarray:
|
||||
# out[n] = -gain * in[n] + delay[n - D] + gain * delay_output[n - D]
|
||||
# Standard allpass: out = -g * in + delayed + g * delayed_out
|
||||
# But block-wise: read delayed, write in + g * delayed, output = -g * in + delayed
|
||||
self.buf[:] = block
|
||||
delayed = self.delay.add_to_block(self.buf, self.delay.max_len - 1, self.gain)
|
||||
# Write: buf + gain * delayed
|
||||
self.buf[:] = block + delayed * self.gain
|
||||
self.delay.write_block(self.buf)
|
||||
# Output: -gain * block + delayed
|
||||
return -self.gain * block + delayed
|
||||
|
||||
|
||||
# ── Audio Pipeline ─────────────────────────────────────────────────
|
||||
|
||||
class AudioPipeline:
|
||||
"""Orchestrates the real-time audio FX chain.
|
||||
|
||||
The pipeline processes audio block-by-block, chaining
|
||||
effect modules in order. Each module receives a numpy
|
||||
array of audio samples and returns processed samples.
|
||||
Effect state (delay buffers, LFO phases, envelope followers,
|
||||
filter memory) is stored per-instance in self._state.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nam_host: Optional[NAMIHost] = None,
|
||||
ir_loader: Optional[IRLoader] = None,
|
||||
):
|
||||
self.nam = nam_host or NAMHost()
|
||||
self.ir = ir_loader or IRLoader()
|
||||
|
||||
# Signal chain — list of (FXType, enabled, bypass, params)
|
||||
self._chain: list[dict] = []
|
||||
self._master_volume: float = 0.8
|
||||
self._tuner_enabled: bool = False
|
||||
self._bypassed: bool = False # Global bypass
|
||||
|
||||
# Per-block DSP state: {f"fx_{idx}": {state_dict}}
|
||||
self._state: dict[str, dict] = {}
|
||||
|
||||
# Cached filter coefficients per block
|
||||
self._coeffs: dict[str, tuple] = {}
|
||||
|
||||
logger.info("Audio pipeline initialized (block=%d, sr=%d)",
|
||||
BLOCK_SIZE, SAMPLE_RATE)
|
||||
|
||||
def load_preset(self, preset: Preset) -> None:
|
||||
"""Load a complete preset (NAM, IR, and FX chain)."""
|
||||
self._chain = []
|
||||
self._state = {}
|
||||
self._coeffs = {}
|
||||
|
||||
for block in preset.chain:
|
||||
entry = {
|
||||
"fx_type": block.fx_type,
|
||||
"enabled": block.enabled,
|
||||
"bypass": block.bypass,
|
||||
"params": dict(block.params),
|
||||
}
|
||||
|
||||
# Load NAM model if needed
|
||||
if block.fx_type == FXType.NAM_AMP and block.nam_model_path:
|
||||
self.nam.load_model(block.nam_model_path)
|
||||
|
||||
# Load IR if needed
|
||||
if block.fx_type == FXType.IR_CAB and block.ir_file_path:
|
||||
self.ir.load_ir(block.ir_file_path)
|
||||
|
||||
self._chain.append(entry)
|
||||
|
||||
self._master_volume = preset.master_volume
|
||||
self._tuner_enabled = preset.tuner_enabled
|
||||
|
||||
logger.info("Preset '%s' loaded: %d blocks", preset.name, len(self._chain))
|
||||
|
||||
def process(self, audio_in: np.ndarray) -> np.ndarray:
|
||||
"""Process a block of audio through the entire FX chain.
|
||||
|
||||
Args:
|
||||
audio_in: numpy array of PCM samples (float32 [-1, 1]).
|
||||
|
||||
Returns:
|
||||
Processed audio block.
|
||||
"""
|
||||
if self._bypassed:
|
||||
return audio_in * self._master_volume
|
||||
|
||||
buf = audio_in.copy()
|
||||
|
||||
for idx, entry in enumerate(self._chain):
|
||||
if entry["bypass"] or not entry["enabled"]:
|
||||
continue
|
||||
|
||||
fx_type = entry["fx_type"]
|
||||
params = entry["params"]
|
||||
fx_state = self._state.setdefault(f"fx_{idx}", {})
|
||||
|
||||
match fx_type:
|
||||
case FXType.NOISE_GATE:
|
||||
buf = self._apply_gate(buf, params, fx_state)
|
||||
case FXType.COMPRESSOR:
|
||||
buf = self._apply_compressor(buf, params, fx_state)
|
||||
case FXType.BOOST:
|
||||
buf = self._apply_boost(buf, params, fx_state)
|
||||
case FXType.OVERDRIVE:
|
||||
buf = self._apply_overdrive(buf, params, fx_state)
|
||||
case FXType.DISTORTION:
|
||||
buf = self._apply_distortion(buf, params, fx_state)
|
||||
case FXType.FUZZ:
|
||||
buf = self._apply_fuzz(buf, params, fx_state)
|
||||
case FXType.EQ:
|
||||
buf = self._apply_eq(buf, params, fx_state)
|
||||
case FXType.CHORUS:
|
||||
buf = self._apply_chorus(buf, params, fx_state)
|
||||
case FXType.FLANGER:
|
||||
buf = self._apply_flanger(buf, params, fx_state)
|
||||
case FXType.PHASER:
|
||||
buf = self._apply_phaser(buf, params, fx_state)
|
||||
case FXType.TREMOLO:
|
||||
buf = self._apply_tremolo(buf, params, fx_state)
|
||||
case FXType.VIBRATO:
|
||||
buf = self._apply_vibrato(buf, params, fx_state)
|
||||
case FXType.DELAY:
|
||||
buf = self._apply_delay(buf, params, fx_state)
|
||||
case FXType.REVERB:
|
||||
buf = self._apply_reverb(buf, params, fx_state)
|
||||
case FXType.VOLUME:
|
||||
buf = self._apply_volume(buf, params, fx_state)
|
||||
case _:
|
||||
pass # NAM/IR handled externally
|
||||
|
||||
return buf * self._master_volume
|
||||
|
||||
# ── LFO helpers ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _lfo_phase(rate_hz: float, state: dict, block_size: int) -> np.ndarray:
|
||||
"""Generate LFO phase ramp (0->1), update state."""
|
||||
phase = state.get("phase", 0.0)
|
||||
delta = rate_hz / SAMPLE_RATE
|
||||
t = np.arange(block_size, dtype=np.float64) * delta + phase
|
||||
t %= 1.0
|
||||
state["phase"] = float(t[-1] + delta) % 1.0
|
||||
return t
|
||||
|
||||
@staticmethod
|
||||
def _lfo_wave(phase: np.ndarray, shape: str = "sine") -> np.ndarray:
|
||||
"""Generate LFO waveform from phase array."""
|
||||
match shape:
|
||||
case "sine":
|
||||
return 0.5 + 0.5 * np.sin(2 * np.pi * phase)
|
||||
case "triangle":
|
||||
return 2.0 * np.abs(2.0 * phase - 1.0) - 1.0
|
||||
# Returns in [-1, 1]; normalise below
|
||||
case "square":
|
||||
return np.where(phase < 0.5, 1.0, 0.0)
|
||||
case _:
|
||||
return 0.5 + 0.5 * np.sin(2 * np.pi * phase)
|
||||
|
||||
# ── Effect implementations ──────────────────────────────────────
|
||||
|
||||
# ── 1. Noise Gate ───────────────────────────────────────────────
|
||||
|
||||
def _apply_gate(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Noise gate with adjustable threshold and release envelope."""
|
||||
threshold = params.get("threshold", 0.01)
|
||||
release_ms = params.get("release", 100.0)
|
||||
|
||||
envelope = state.get("envelope", 0.0)
|
||||
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
|
||||
|
||||
if rms >= threshold:
|
||||
# Instant attack
|
||||
envelope = rms
|
||||
else:
|
||||
# Exponential release — time constant per block
|
||||
release_coeff = np.exp(-BLOCK_SIZE / (release_ms * SAMPLE_RATE / 1000.0))
|
||||
envelope = envelope * release_coeff + rms * (1.0 - release_coeff)
|
||||
|
||||
state["envelope"] = envelope
|
||||
|
||||
if envelope < threshold:
|
||||
return np.zeros_like(buf)
|
||||
return buf
|
||||
|
||||
# ── 2. Compressor ───────────────────────────────────────────────
|
||||
|
||||
def _apply_compressor(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Compressor with threshold (dB), ratio, attack, release, make-up gain."""
|
||||
threshold_db = params.get("threshold", -20.0) # dB
|
||||
ratio = params.get("ratio", 3.0)
|
||||
attack_ms = params.get("attack", 5.0)
|
||||
release_ms = params.get("release", 100.0)
|
||||
makeup = params.get("gain", 1.0)
|
||||
|
||||
# RMS envelope with attack/release shaping
|
||||
rms = np.sqrt(np.mean(buf ** 2) + _EPS)
|
||||
envelope = state.get("envelope", 0.0)
|
||||
|
||||
if rms > envelope:
|
||||
alpha = np.exp(-BLOCK_SIZE / (attack_ms * SAMPLE_RATE / 1000.0))
|
||||
else:
|
||||
alpha = np.exp(-BLOCK_SIZE / (release_ms * SAMPLE_RATE / 1000.0))
|
||||
|
||||
envelope = envelope * alpha + rms * (1.0 - alpha)
|
||||
state["envelope"] = envelope
|
||||
|
||||
# Compute gain reduction in dB domain
|
||||
if envelope > 1e-10:
|
||||
env_db = 20.0 * np.log10(envelope)
|
||||
else:
|
||||
env_db = -120.0
|
||||
|
||||
if env_db > threshold_db:
|
||||
# gain_db = threshold + (env - threshold) / ratio - env
|
||||
gain_db = threshold_db + (env_db - threshold_db) / ratio - env_db
|
||||
else:
|
||||
gain_db = 0.0
|
||||
|
||||
gain_lin = 10 ** (gain_db / 20.0)
|
||||
return np.clip(buf * gain_lin * makeup, -1.0, 1.0)
|
||||
|
||||
# ── 3. Boost / Overdrive / Distortion / Fuzz ────────────────────
|
||||
|
||||
def _apply_boost(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Clean boost with linear gain."""
|
||||
gain_db = params.get("gain_db", 6.0)
|
||||
gain_linear = 10 ** (gain_db / 20.0)
|
||||
return np.clip(buf * gain_linear, -1.0, 1.0)
|
||||
|
||||
def _apply_overdrive(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Tube-style overdrive with asymmetric soft clipping."""
|
||||
drive = params.get("drive", 0.5)
|
||||
tone = params.get("tone", 0.5)
|
||||
gain = params.get("gain", 1.0)
|
||||
|
||||
drive_scaled = drive * 15.0 + 1.0
|
||||
shaped = buf * drive_scaled
|
||||
|
||||
# Asymmetric soft clipping (tube-like)
|
||||
# Positive half clips softer than negative (tube asymmetry)
|
||||
pos = np.where(shaped > 0, shaped / (1.0 + shaped * 0.3), shaped)
|
||||
neg = np.where(pos < 0, pos / (1.0 - pos * 0.5), pos)
|
||||
out = np.tanh(neg) # Final polish with tanh
|
||||
|
||||
return np.clip(out * gain, -1.0, 1.0)
|
||||
|
||||
def _apply_distortion(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Harder asymmetric clipping with diode-style transfer."""
|
||||
drive = params.get("drive", 0.7)
|
||||
tone = params.get("tone", 0.5)
|
||||
gain = params.get("gain", 1.0)
|
||||
|
||||
drive_scaled = drive * 30.0 + 1.0
|
||||
shaped = buf * drive_scaled
|
||||
|
||||
# Diode-style asymmetric clipping
|
||||
clipped = np.where(
|
||||
shaped > 0,
|
||||
np.clip(shaped, 0, 0.8) / (1.0 + np.abs(np.clip(shaped, 0, 0.8)) * 0.5),
|
||||
np.clip(shaped, -0.6, 0) / (1.0 + np.abs(np.clip(shaped, -0.6, 0)) * 0.3),
|
||||
)
|
||||
return np.clip(clipped * gain, -1.0, 1.0)
|
||||
|
||||
def _apply_fuzz(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Octave-fuzzy hard clipping with gated sustain."""
|
||||
drive = params.get("drive", 0.8)
|
||||
tone = params.get("tone", 0.5)
|
||||
gain = params.get("gain", 1.0)
|
||||
|
||||
drive_scaled = drive * 50.0 + 1.0
|
||||
shaped = buf * drive_scaled
|
||||
|
||||
# Hard square-wave clip with asymmetric gate
|
||||
clipped = np.sign(shaped) * (1.0 - np.exp(-np.abs(shaped) * 2.0))
|
||||
# Foldover for octave effect
|
||||
folded = np.abs(clipped) * 0.3 + clipped * 0.7
|
||||
return np.clip(folded * gain, -1.0, 1.0)
|
||||
|
||||
# ── 4. Three-band EQ ────────────────────────────────────────────
|
||||
|
||||
def _apply_eq(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""3-band EQ: bass shelf, mid peaking, treble shelf."""
|
||||
bass_gain = params.get("bass", 0.0) # dB
|
||||
mid_gain = params.get("mid", 0.0) # dB
|
||||
treble_gain = params.get("treble", 0.0) # dB
|
||||
bass_freq = params.get("bass_freq", 200.0)
|
||||
mid_freq = params.get("mid_freq", 1000.0)
|
||||
treble_freq = params.get("treble_freq", 3500.0)
|
||||
q = params.get("q", 0.707)
|
||||
|
||||
sig = buf.astype(np.float64, copy=False)
|
||||
|
||||
# Cache biquad coefficients per block position — recompute only
|
||||
# when params change (checked via hash). Each band gets its own
|
||||
# state sub-key.
|
||||
for band_name, freq, gain_db, compute_fn in [
|
||||
("bass", bass_freq, bass_gain, _compute_lowshelf_coeffs),
|
||||
("mid", mid_freq, mid_gain, _compute_peaking_coeffs),
|
||||
("treble", treble_freq, treble_gain, _compute_highshelf_coeffs),
|
||||
]:
|
||||
if gain_db == 0.0:
|
||||
continue
|
||||
key = f"eq_{band_name}"
|
||||
coeffs = state.get(f"{key}_coeffs")
|
||||
param_tag = (bass_freq, mid_freq, treble_freq, bass_gain, mid_gain, treble_gain, q)
|
||||
if coeffs is None or state.get(f"{key}_tag") != param_tag:
|
||||
coeffs = compute_fn(freq, gain_db, q, SAMPLE_RATE)
|
||||
state[f"{key}_coeffs"] = coeffs
|
||||
state[f"{key}_tag"] = param_tag
|
||||
|
||||
b0, b1, b2, a1, a2 = coeffs
|
||||
x1 = state.get(f"{key}_x1", 0.0)
|
||||
x2 = state.get(f"{key}_x2", 0.0)
|
||||
y1 = state.get(f"{key}_y1", 0.0)
|
||||
y2 = state.get(f"{key}_y2", 0.0)
|
||||
|
||||
for i in range(len(sig)):
|
||||
x0 = sig[i]
|
||||
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
|
||||
x2, x1 = x1, x0
|
||||
y2, y1 = y1, y0
|
||||
sig[i] = y0
|
||||
|
||||
state[f"{key}_x1"] = x1
|
||||
state[f"{key}_x2"] = x2
|
||||
state[f"{key}_y1"] = y1
|
||||
state[f"{key}_y2"] = y2
|
||||
|
||||
return np.clip(sig, -1.0, 1.0).astype(np.float32)
|
||||
|
||||
# ── 5. Chorus ───────────────────────────────────────────────────
|
||||
|
||||
def _apply_chorus(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Chorus with LFO-driven modulated delay line (stereo-ish)."""
|
||||
rate = params.get("rate", 0.5) # Hz
|
||||
depth = params.get("depth", 0.5) # 0.0-1.0
|
||||
mix = params.get("mix", 0.5) # wet/dry
|
||||
delay_base = params.get("delay", 20.0) # ms (typical chorus: 15-30ms)
|
||||
|
||||
# Convert to samples
|
||||
base_samples = delay_base * SAMPLE_RATE / 1000.0
|
||||
mod_range = depth * 5.0 * SAMPLE_RATE / 1000.0 # up to 5ms of modulation
|
||||
|
||||
if "delay" not in state:
|
||||
max_d = int(base_samples + mod_range + 10.0 * SAMPLE_RATE / 1000.0) + 1
|
||||
state["delay"] = _DelayLine(max_d)
|
||||
# Warm up delay buffer
|
||||
state["delay"].write_block(np.zeros(max_d))
|
||||
|
||||
delay_line: _DelayLine = state["delay"]
|
||||
phase = self._lfo_phase(rate, state, len(buf))
|
||||
lfo = self._lfo_wave(phase, "sine") # 0-1 range
|
||||
mod_delay = base_samples + lfo * mod_range
|
||||
|
||||
# Read modulated delayed signal
|
||||
wet = np.zeros_like(buf)
|
||||
for i in range(len(buf)):
|
||||
wet[i] = delay_line.read_block(mod_delay[i], 1)[0]
|
||||
|
||||
# Write dry to delay line
|
||||
delay_line.write_block(buf)
|
||||
|
||||
return buf * (1.0 - mix) + wet * mix
|
||||
|
||||
# ── 6. Flanger ──────────────────────────────────────────────────
|
||||
|
||||
def _apply_flanger(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Flanger with swept comb filter and feedback."""
|
||||
rate = params.get("rate", 0.25) # Hz (slower than chorus)
|
||||
depth = params.get("depth", 0.7) # 0.0-1.0
|
||||
feedback = params.get("feedback", 0.3)
|
||||
mix = params.get("mix", 0.5) # wet/dry
|
||||
delay_base = params.get("delay", 5.0) # ms (typical flanger: 1-10ms)
|
||||
|
||||
base_samples = delay_base * SAMPLE_RATE / 1000.0
|
||||
mod_range = depth * 5.0 * SAMPLE_RATE / 1000.0
|
||||
|
||||
if "delay" not in state:
|
||||
max_d = int(base_samples + mod_range + 10.0 * SAMPLE_RATE / 1000.0) + 1
|
||||
state["delay"] = _DelayLine(max_d)
|
||||
state["delay"].write_block(np.zeros(max_d))
|
||||
|
||||
delay_line: _DelayLine = state["delay"]
|
||||
phase = self._lfo_phase(rate, state, len(buf))
|
||||
lfo = self._lfo_wave(phase, "sine") # 0-1
|
||||
mod_delay = base_samples + lfo * mod_range
|
||||
|
||||
# Feedback buffer
|
||||
feedback_buf = state.get("fb_buf", np.zeros(len(buf), dtype=np.float32))
|
||||
|
||||
# Blend feedback into input
|
||||
fb_input = buf + feedback_buf * feedback
|
||||
|
||||
wet = np.zeros_like(buf)
|
||||
for i in range(len(fb_input)):
|
||||
wet[i] = delay_line.read_block(mod_delay[i], 1)[0]
|
||||
|
||||
delay_line.write_block(fb_input)
|
||||
|
||||
# Store feedback for next block
|
||||
state["fb_buf"] = wet * 0.5
|
||||
|
||||
return buf * (1.0 - mix) + wet * mix
|
||||
|
||||
# ── 7. Phaser ───────────────────────────────────────────────────
|
||||
|
||||
def _apply_phaser(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Phaser with allpass filter cascade and feedback."""
|
||||
rate = params.get("rate", 0.4) # Hz
|
||||
depth = params.get("depth", 0.5) # 0.0-1.0
|
||||
feedback = params.get("feedback", 0.3)
|
||||
mix = params.get("mix", 0.5)
|
||||
stages = int(params.get("stages", 4)) # number of allpass stages
|
||||
|
||||
# Map LFO to centre frequency sweep: 200-2000 Hz
|
||||
phase = self._lfo_phase(rate, state, len(buf))
|
||||
lfo = self._lfo_wave(phase, "sine")
|
||||
freq_range = 200.0 + lfo * depth * 1800.0 # 200-2000 Hz sweep
|
||||
|
||||
# Pre-compute allpass coefficients per sample
|
||||
fb_buf = state.get("fb_buf", np.zeros(len(buf), dtype=np.float64))
|
||||
fb_input = buf.astype(np.float64, copy=False) + fb_buf * feedback
|
||||
|
||||
out = np.zeros(len(buf), dtype=np.float64)
|
||||
|
||||
for i in range(len(buf)):
|
||||
freq = freq_range[i]
|
||||
# Allpass coefficient: a = (1 - tan(w/2)) / (1 + tan(w/2))
|
||||
w = 2.0 * np.pi * freq / SAMPLE_RATE
|
||||
tan_half_w = np.tan(w / 2.0)
|
||||
coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w)
|
||||
|
||||
x = fb_input[i]
|
||||
for stage in range(stages):
|
||||
# Load state for this stage
|
||||
s_delay = state.get(f"ap_delay_{stage}", 0.0)
|
||||
s_out = state.get(f"ap_out_{stage}", 0.0)
|
||||
# Allpass: out[n] = coeff * in[n] + delay[n-1] - coeff * out[n-1]
|
||||
y = coeff * x + s_delay - coeff * s_out
|
||||
state[f"ap_delay_{stage}"] = x
|
||||
state[f"ap_out_{stage}"] = y
|
||||
x = y
|
||||
out[i] = x
|
||||
|
||||
state["fb_buf"] = out * 0.5
|
||||
out = np.clip(out, -1.0, 1.0)
|
||||
return (buf * (1.0 - mix) + out.astype(np.float32) * mix).astype(np.float32)
|
||||
|
||||
# ── 8. Tremolo ──────────────────────────────────────────────────
|
||||
|
||||
def _apply_tremolo(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Tremolo with configurable LFO shape."""
|
||||
rate = params.get("rate", 4.0) # Hz
|
||||
depth = params.get("depth", 0.7)
|
||||
shape = params.get("shape", "sine") # sine / triangle / square
|
||||
|
||||
phase = self._lfo_phase(rate, state, len(buf))
|
||||
lfo = self._lfo_wave(phase, shape)
|
||||
|
||||
# LFO is 0-1; tremolo scales between full volume and attenuated
|
||||
mod = 1.0 - depth * (1.0 - lfo)
|
||||
return buf * mod
|
||||
|
||||
# ── 9. Vibrato ──────────────────────────────────────────────────
|
||||
|
||||
def _apply_vibrato(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Vibrato — modulated delay with 100% wet (pitch modulation)."""
|
||||
rate = params.get("rate", 3.0) # Hz
|
||||
depth = params.get("depth", 0.5) # cents equivalent
|
||||
|
||||
base_samples = 2.0 * SAMPLE_RATE / 1000.0 # fixed ~2ms base
|
||||
mod_range = depth * 3.0 * SAMPLE_RATE / 1000.0
|
||||
|
||||
if "delay" not in state:
|
||||
max_d = int(base_samples + mod_range + 5.0 * SAMPLE_RATE / 1000.0) + 1
|
||||
state["delay"] = _DelayLine(max_d)
|
||||
state["delay"].write_block(np.zeros(max_d))
|
||||
|
||||
delay_line: _DelayLine = state["delay"]
|
||||
phase = self._lfo_phase(rate, state, len(buf))
|
||||
lfo = self._lfo_wave(phase, "sine")
|
||||
mod_delay = base_samples + lfo * mod_range
|
||||
|
||||
wet = np.zeros_like(buf)
|
||||
for i in range(len(buf)):
|
||||
wet[i] = delay_line.read_block(mod_delay[i], 1)[0]
|
||||
|
||||
delay_line.write_block(buf)
|
||||
return wet
|
||||
|
||||
# ── 10. Delay ───────────────────────────────────────────────────
|
||||
|
||||
def _apply_delay(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Digital delay with feedback and tap-tempo support."""
|
||||
time_ms = params.get("time", 400.0)
|
||||
feedback = params.get("feedback", 0.3)
|
||||
mix = params.get("mix", 0.4)
|
||||
tap_tempo = params.get("tap_tempo", 0.0)
|
||||
|
||||
# Tap tempo overrides time_ms when > 0
|
||||
if tap_tempo > 0:
|
||||
time_ms = tap_tempo
|
||||
|
||||
delay_samples = int(time_ms * SAMPLE_RATE / 1000.0)
|
||||
|
||||
if "delay" not in state:
|
||||
# Allocate 2x requested delay for headroom
|
||||
max_d = max(delay_samples * 2, SAMPLE_RATE) # at least 1s
|
||||
state["delay"] = _DelayLine(max_d + 1)
|
||||
state["delay"].write_block(np.zeros(max_d // 2))
|
||||
|
||||
delay_line: _DelayLine = state["delay"]
|
||||
|
||||
# Read delayed signal
|
||||
wet = delay_line.read_block(float(delay_samples), len(buf))
|
||||
|
||||
# Write dry + feedback (no self-oscillation guard)
|
||||
# clips feedback automatically
|
||||
fb_gain = min(feedback, 0.98)
|
||||
write_sig = buf + wet * fb_gain
|
||||
delay_line.write_block(write_sig)
|
||||
|
||||
return buf * (1.0 - mix) + wet * mix
|
||||
|
||||
# ── 11. Reverb (Schroeder) ──────────────────────────────────────
|
||||
|
||||
def _apply_reverb(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Schroeder reverb: 8 comb filters + 4 allpass filters in series."""
|
||||
decay = params.get("decay", 0.5)
|
||||
damping = params.get("damping", 0.4)
|
||||
mix = params.get("mix", 0.3)
|
||||
predelay_ms = params.get("predelay", 30.0)
|
||||
|
||||
# Initialise on first call
|
||||
if "combs" not in state:
|
||||
# Classic Schroeder delays (prime-ish numbers for de-flanging)
|
||||
comb_delays = [29, 37, 44, 50, 31, 39, 47, 53] # ms
|
||||
ap_delays = [5, 7, 11, 13] # ms
|
||||
state["combs"] = [
|
||||
_CombFilter(int(d * SAMPLE_RATE / 1000.0))
|
||||
for d in comb_delays
|
||||
]
|
||||
state["allpasses"] = [
|
||||
_AllpassFilter(int(d * SAMPLE_RATE / 1000.0))
|
||||
for d in ap_delays
|
||||
]
|
||||
state["predelay"] = _DelayLine(
|
||||
int(predelay_ms * SAMPLE_RATE / 1000.0) + 1
|
||||
)
|
||||
state["predelay"].write_block(np.zeros(BLOCK_SIZE))
|
||||
state["_computed"] = False
|
||||
|
||||
combs: list[_CombFilter] = state["combs"]
|
||||
allpasses: list[_AllpassFilter] = state["allpasses"]
|
||||
predelay_line: _DelayLine = state["predelay"]
|
||||
|
||||
# Update comb parameters when decay/damping changes
|
||||
param_tag = (decay, damping)
|
||||
if state.get("_param_tag") != param_tag:
|
||||
scaled_fb = 0.3 + decay * 0.6 # 0.3 - 0.9
|
||||
scaled_damp = 0.1 + damping * 0.7 # 0.1 - 0.8
|
||||
for comb in combs:
|
||||
comb.feedback = min(scaled_fb, 0.95)
|
||||
comb.damping = min(scaled_damp, 0.85)
|
||||
for ap in allpasses:
|
||||
ap.gain = 0.3 + damping * 0.3
|
||||
state["_param_tag"] = param_tag
|
||||
|
||||
# Predelay
|
||||
delayed = predelay_line.read_block(float(predelay_ms * SAMPLE_RATE / 1000.0),
|
||||
len(buf))
|
||||
predelay_line.write_block(buf)
|
||||
|
||||
# Comb filters in parallel
|
||||
wet = np.zeros_like(buf, dtype=np.float64)
|
||||
for comb in combs:
|
||||
wet += comb.process(delayed)
|
||||
wet /= len(combs) # Normalise
|
||||
|
||||
# Allpass filters in series
|
||||
for ap in allpasses:
|
||||
wet = ap.process(wet)
|
||||
|
||||
wet = np.clip(wet, -1.0, 1.0).astype(np.float32)
|
||||
return buf * (1.0 - mix) + wet * mix
|
||||
|
||||
# ── 12. Volume ──────────────────────────────────────────────────
|
||||
|
||||
def _apply_volume(self, buf: np.ndarray, params: dict,
|
||||
state: dict) -> np.ndarray:
|
||||
"""Simple volume/level control."""
|
||||
level = params.get("level", 1.0)
|
||||
return buf * level
|
||||
|
||||
# ── Properties ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def master_volume(self) -> float:
|
||||
return self._master_volume
|
||||
|
||||
@master_volume.setter
|
||||
def master_volume(self, value: float) -> None:
|
||||
self._master_volume = max(0.0, min(1.0, value))
|
||||
|
||||
@property
|
||||
def bypassed(self) -> bool:
|
||||
return self._bypassed
|
||||
|
||||
@bypassed.setter
|
||||
def bypassed(self, value: bool) -> None:
|
||||
self._bypassed = value
|
||||
logger.info("Global bypass: %s", "ON" if value else "OFF")
|
||||
|
||||
@property
|
||||
def tuner_enabled(self) -> bool:
|
||||
return self._tuner_enabled
|
||||
|
||||
@tuner_enabled.setter
|
||||
def tuner_enabled(self, value: bool) -> None:
|
||||
self._tuner_enabled = value
|
||||
@@ -0,0 +1,39 @@
|
||||
"""MIDI I/O — 5-pin DIN UART + USB-MIDI with full protocol support."""
|
||||
|
||||
from .handler import (
|
||||
# Constants
|
||||
MIDI_BAUD,
|
||||
CLOCK_PPQN,
|
||||
# Interface classes
|
||||
MIDIInterface,
|
||||
UARTMIDI,
|
||||
USBMIDI,
|
||||
# Core handler
|
||||
MIDIHandler,
|
||||
MIDIEvent,
|
||||
LearnedMapping,
|
||||
# Well-known CC numbers
|
||||
CC_EXPRESSION,
|
||||
CC_VOLUME,
|
||||
CC_MODULATION,
|
||||
CC_SUSTAIN,
|
||||
CC_BANK_SELECT_MSB,
|
||||
CC_BANK_SELECT_LSB,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MIDI_BAUD",
|
||||
"CLOCK_PPQN",
|
||||
"MIDIInterface",
|
||||
"UARTMIDI",
|
||||
"USBMIDI",
|
||||
"MIDIHandler",
|
||||
"MIDIEvent",
|
||||
"LearnedMapping",
|
||||
"CC_EXPRESSION",
|
||||
"CC_VOLUME",
|
||||
"CC_MODULATION",
|
||||
"CC_SUSTAIN",
|
||||
"CC_BANK_SELECT_MSB",
|
||||
"CC_BANK_SELECT_LSB",
|
||||
]
|
||||
@@ -0,0 +1,916 @@
|
||||
"""MIDI handler — hardware I/O, parsing, routing, MIDI Learn, and clock sync.
|
||||
|
||||
Supports:
|
||||
- 5-pin DIN via UART (pyserial, 31.25 kbaud)
|
||||
- USB-MIDI class-compliant (python-rtmidi)
|
||||
- Program Change (PC) → preset switching callback
|
||||
- Control Change (CC) → parameter control, expression pedal, MIDI Learn
|
||||
- MIDI clock → BPM tracking for delay/reverb sync
|
||||
- Running status byte handling
|
||||
- 14-bit CC (MSB/LSB) for expression pedal resolution
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from ..presets.types import MIDIMapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MIDI protocol constants
|
||||
MIDI_BAUD = 31250
|
||||
CLOCK_PPQN = 24 # Pulses Per Quarter Note
|
||||
MIDI_CLOCK_INTERVAL_BPM_120 = 0.020833 # ~20.8 ms at 120 BPM
|
||||
|
||||
# Status byte masks
|
||||
STATUS_MASK = 0x80
|
||||
CHANNEL_MASK = 0x0F
|
||||
STATUS_TYPE_MASK = 0xF0
|
||||
|
||||
# MIDI status bytes
|
||||
NOTE_OFF = 0x80
|
||||
NOTE_ON = 0x90
|
||||
POLY_PRESSURE = 0xA0
|
||||
CONTROL_CHANGE = 0xB0
|
||||
PROGRAM_CHANGE = 0xC0
|
||||
CHANNEL_PRESSURE = 0xD0
|
||||
PITCH_BEND = 0xE0
|
||||
SYSTEM = 0xF0 # System exclusive / common / real-time
|
||||
|
||||
# Real-time messages (single byte, no data)
|
||||
RT_CLOCK = 0xF8
|
||||
RT_TICK = 0xF9
|
||||
RT_START = 0xFA
|
||||
RT_CONTINUE = 0xFB
|
||||
RT_STOP = 0xFC
|
||||
RT_ACTIVE_SENSING = 0xFE
|
||||
RT_RESET = 0xFF
|
||||
|
||||
# System Common
|
||||
SYS_EXCLUSIVE = 0xF0
|
||||
SYS_EXCLUSIVE_END = 0xF7
|
||||
SONG_POSITION = 0xF2
|
||||
SONG_SELECT = 0xF3
|
||||
TUNE_REQUEST = 0xF6
|
||||
|
||||
# Well-known CC numbers
|
||||
CC_BANK_SELECT_MSB = 0
|
||||
CC_MODULATION = 1
|
||||
CC_BREATH = 2
|
||||
CC_FOOT_CONTROLLER = 4
|
||||
CC_VOLUME = 7
|
||||
CC_PAN = 10
|
||||
CC_EXPRESSION = 11
|
||||
CC_BANK_SELECT_LSB = 32
|
||||
CC_SUSTAIN = 64
|
||||
CC_ALL_SOUNDS_OFF = 120
|
||||
CC_RESET_ALL_CONTROLLERS = 121
|
||||
CC_ALL_NOTES_OFF = 123
|
||||
|
||||
# Number of voice messages per data byte count
|
||||
_MESSAGE_LENGTH: dict[int, int] = {
|
||||
NOTE_OFF: 3,
|
||||
NOTE_ON: 3,
|
||||
POLY_PRESSURE: 3,
|
||||
CONTROL_CHANGE: 3,
|
||||
PROGRAM_CHANGE: 2,
|
||||
CHANNEL_PRESSURE: 2,
|
||||
PITCH_BEND: 3,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MIDIEvent:
|
||||
"""A parsed MIDI message."""
|
||||
|
||||
type: str # "note_on", "note_off", "cc", "pc", "clock", "start", "stop",
|
||||
# "continue", "pitch_bend", "poly_pressure", "channel_pressure",
|
||||
# "sys_exclusive", "song_position", "song_select"
|
||||
channel: int = 0
|
||||
note: int = 0
|
||||
velocity: int = 0
|
||||
cc_number: int = 0
|
||||
cc_value: int = 0
|
||||
program: int = 0
|
||||
data: bytes = b""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnedMapping:
|
||||
"""Result of a MIDI Learn operation."""
|
||||
cc_number: int
|
||||
channel: int
|
||||
param_key: str
|
||||
min_val: float = 0.0
|
||||
max_val: float = 1.0
|
||||
|
||||
|
||||
class MIDIInterface:
|
||||
"""Abstract base for a MIDI I/O port."""
|
||||
|
||||
def open(self) -> bool:
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
...
|
||||
|
||||
def read(self, timeout: float = 0) -> list[bytes]:
|
||||
...
|
||||
|
||||
def send(self, msg: bytes) -> None:
|
||||
...
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class UARTMIDI(MIDIInterface):
|
||||
"""5-pin DIN MIDI via UART (pyserial at 31.25 kbaud)."""
|
||||
|
||||
def __init__(self, port: str = "/dev/ttyAMA0"):
|
||||
self._port_name = port
|
||||
self._serial: Any = None # serial.Serial
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"UART:{self._port_name}"
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._serial is not None and self._serial.is_open
|
||||
|
||||
def open(self) -> bool:
|
||||
try:
|
||||
import serial
|
||||
self._serial = serial.Serial(
|
||||
port=self._port_name,
|
||||
baudrate=MIDI_BAUD,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=0.001, # 1 ms read timeout
|
||||
)
|
||||
logger.info("UART MIDI opened on %s", self._port_name)
|
||||
return True
|
||||
except (ImportError, OSError, serial.SerialException) as e:
|
||||
logger.error("UART MIDI open failed: %s", e)
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
if self._serial and self._serial.is_open:
|
||||
try:
|
||||
self._serial.close()
|
||||
logger.info("UART MIDI closed")
|
||||
except Exception as e:
|
||||
logger.warning("UART MIDI close error: %s", e)
|
||||
|
||||
def read(self, timeout: float = 0) -> list[bytes]:
|
||||
"""Read available MIDI bytes and return complete messages."""
|
||||
if not self.is_open:
|
||||
return []
|
||||
try:
|
||||
raw = self._serial.read(256)
|
||||
if not raw:
|
||||
return []
|
||||
return self._parse_raw_messages(raw)
|
||||
except Exception as e:
|
||||
logger.warning("UART read error: %s", e)
|
||||
return []
|
||||
|
||||
def send(self, msg: bytes) -> None:
|
||||
if not self.is_open:
|
||||
return
|
||||
try:
|
||||
self._serial.write(msg)
|
||||
except Exception as e:
|
||||
logger.warning("UART write error: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _parse_raw_messages(raw: bytes) -> list[bytes]:
|
||||
"""Split raw byte stream into complete MIDI messages.
|
||||
|
||||
Handles running status: status byte is re-used for subsequent
|
||||
data-only bytes until a new status byte arrives.
|
||||
"""
|
||||
messages: list[bytes] = []
|
||||
i = 0
|
||||
running_status: int | None = None
|
||||
|
||||
while i < len(raw):
|
||||
byte = raw[i]
|
||||
|
||||
if byte & STATUS_MASK: # Status byte
|
||||
if byte >= 0xF8: # Real-time (single byte)
|
||||
messages.append(bytes([byte]))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if byte == SYS_EXCLUSIVE: # SysEx start
|
||||
# Find end marker
|
||||
end = raw.find(SYS_EXCLUSIVE_END, i + 1)
|
||||
if end == -1:
|
||||
break # Incomplete SysEx, wait for more
|
||||
messages.append(raw[i: end + 1])
|
||||
i = end + 1
|
||||
running_status = None
|
||||
continue
|
||||
|
||||
# Voice or system common status
|
||||
if byte in _MESSAGE_LENGTH:
|
||||
length = _MESSAGE_LENGTH[byte]
|
||||
running_status = byte
|
||||
elif SONG_POSITION <= byte <= SONG_SELECT:
|
||||
length = {SONG_POSITION: 3, SONG_SELECT: 2}.get(byte, 1)
|
||||
running_status = None # Don't run on system common
|
||||
elif byte == TUNE_REQUEST:
|
||||
messages.append(bytes([byte]))
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
# Unknown status, skip
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if i + length > len(raw):
|
||||
break # Incomplete, wait for more data
|
||||
messages.append(raw[i: i + length])
|
||||
i += length
|
||||
continue
|
||||
|
||||
# Data byte with running status
|
||||
if running_status is not None and running_status in _MESSAGE_LENGTH:
|
||||
length = _MESSAGE_LENGTH[running_status]
|
||||
# We need length-1 more bytes after this one
|
||||
if i + length - 1 > len(raw):
|
||||
break
|
||||
msg = bytes([running_status]) + raw[i: i + length - 1]
|
||||
messages.append(msg)
|
||||
i += length - 1
|
||||
else:
|
||||
# Stray data byte, skip
|
||||
i += 1
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
class USBMIDI(MIDIInterface):
|
||||
"""USB-MIDI class-compliant port (python-rtmidi)."""
|
||||
|
||||
def __init__(self, port_name: str = ""):
|
||||
self._port_name = port_name
|
||||
self._midi_in: Any = None # rtmidi.MidiIn
|
||||
self._midi_out: Any = None # rtmidi.MidiOut
|
||||
self._queue: queue.Queue[bytes] = queue.Queue()
|
||||
self._callback: Any = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"USB:{self._port_name or 'auto'}"
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._midi_in is not None
|
||||
|
||||
def open(self) -> bool:
|
||||
try:
|
||||
import rtmidi
|
||||
self._midi_in = rtmidi.MidiIn()
|
||||
self._midi_out = rtmidi.MidiOut()
|
||||
self._midi_in.ignore_types(timing=True, sysex=False)
|
||||
|
||||
# Find matching port
|
||||
ports_in = self._midi_in.get_ports()
|
||||
ports_out = self._midi_out.get_ports()
|
||||
|
||||
target_in = None
|
||||
target_out = None
|
||||
|
||||
for i, p in enumerate(ports_in):
|
||||
if not self._port_name or self._port_name.lower() in p.lower():
|
||||
target_in = i
|
||||
break
|
||||
|
||||
for i, p in enumerate(ports_out):
|
||||
if not self._port_name or self._port_name.lower() in p.lower():
|
||||
target_out = i
|
||||
break
|
||||
|
||||
if target_in is None:
|
||||
logger.warning("No USB-MIDI input port found (available: %s)", ports_in)
|
||||
self._midi_in = None
|
||||
return False
|
||||
|
||||
if target_out is None:
|
||||
logger.warning("No USB-MIDI output port found, output disabled")
|
||||
target_out = None
|
||||
|
||||
self._midi_in.open_port(target_in)
|
||||
logger.info("USB-MIDI in opened on port %d: %s", target_in, ports_in[target_in])
|
||||
|
||||
if target_out is not None:
|
||||
self._midi_out.open_port(target_out)
|
||||
logger.info("USB-MIDI out opened on port %d: %s", target_out, ports_out[target_out])
|
||||
|
||||
self._midi_in.set_callback(self._on_midi)
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.warning("python-rtmidi not available — USB-MIDI disabled")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("USB-MIDI open failed: %s", e)
|
||||
return False
|
||||
|
||||
def _on_midi(self, event: tuple[list[int], float], data: Any = None) -> None:
|
||||
"""Callback from rtmidi on incoming message."""
|
||||
msg, _timestamp = event
|
||||
self._queue.put(bytes(msg))
|
||||
|
||||
def close(self) -> None:
|
||||
if self._midi_in:
|
||||
try:
|
||||
self._midi_in.close_port()
|
||||
except Exception:
|
||||
pass
|
||||
if self._midi_out:
|
||||
try:
|
||||
self._midi_out.close_port()
|
||||
except Exception:
|
||||
pass
|
||||
self._midi_in = None
|
||||
self._midi_out = None
|
||||
logger.info("USB-MIDI closed")
|
||||
|
||||
def read(self, timeout: float = 0) -> list[bytes]:
|
||||
"""Drain the incoming queue."""
|
||||
msgs: list[bytes] = []
|
||||
try:
|
||||
while True:
|
||||
msgs.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
pass
|
||||
return msgs
|
||||
|
||||
def send(self, msg: bytes) -> None:
|
||||
if not self._midi_out:
|
||||
return
|
||||
try:
|
||||
self._midi_out.send_message(list(msg))
|
||||
except Exception as e:
|
||||
logger.warning("USB-MIDI write error: %s", e)
|
||||
|
||||
|
||||
class MIDIHandler:
|
||||
"""Core MIDI handler — manages I/O ports, message parsing, and routing.
|
||||
|
||||
Usage::
|
||||
|
||||
handler = MIDIHandler()
|
||||
handler.set_pc_callback(lambda ch, pg: print(f"Preset {pg}"))
|
||||
handler.register_cc(11, lambda val, ch: print(f"Expression: {val}"))
|
||||
handler.set_clock_callback(lambda bpm: print(f"Tempo: {bpm:.1f}"))
|
||||
handler.start(uart_port="/dev/ttyAMA0", usb=True)
|
||||
...
|
||||
handler.stop()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Callbacks
|
||||
self._pc_callback: Optional[Callable[[int, int], None]] = None
|
||||
self._cc_callbacks: dict[int, Callable[[int, int], None]] = {}
|
||||
self._midi_learn_callback: Optional[Callable[[LearnedMapping], None]] = None
|
||||
self._clock_callback: Optional[Callable[[float], None]] = None
|
||||
self._note_callback: Optional[Callable[[int, int, int], None]] = None
|
||||
|
||||
# MIDI Learn state
|
||||
self._learn_mode = False
|
||||
self._learn_target: Optional[str] = None
|
||||
self._mappings: dict[str, MIDIMapping] = {}
|
||||
|
||||
# Clock tracking
|
||||
self._clock_times: deque[float] = deque(maxlen=CLOCK_PPQN)
|
||||
self._current_bpm: float = 0.0
|
||||
self._clock_start_time: float = 0.0
|
||||
self._clock_running = False
|
||||
|
||||
# Motor control — for expression pedal smoothing
|
||||
self._cc_14bit_high: dict[int, int] = {} # MSB pending LSB
|
||||
|
||||
# I/O
|
||||
self._interfaces: list[MIDIInterface] = []
|
||||
self._running = False
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._msg_queue: queue.Queue[MIDIEvent] = queue.Queue()
|
||||
|
||||
# ── Callback registration ──────────────────────────────────────
|
||||
|
||||
def set_pc_callback(self, callback: Callable[[int, int], None]) -> None:
|
||||
"""Set callback for Program Change events.
|
||||
|
||||
Args:
|
||||
callback: (channel, program_number) called on PC.
|
||||
"""
|
||||
self._pc_callback = callback
|
||||
|
||||
def register_cc(self, cc_number: int,
|
||||
callback: Callable[[int, int], None]) -> None:
|
||||
"""Register a callback for a specific CC number.
|
||||
|
||||
Args:
|
||||
cc_number: 0-127
|
||||
callback: (value, channel) called on CC.
|
||||
"""
|
||||
self._cc_callbacks[cc_number] = callback
|
||||
|
||||
def set_clock_callback(self, callback: Callable[[float], None]) -> None:
|
||||
"""Set callback for detected MIDI clock BPM.
|
||||
|
||||
Args:
|
||||
callback: Called with detected BPM on tempo change.
|
||||
"""
|
||||
self._clock_callback = callback
|
||||
|
||||
def set_note_callback(self, callback: Callable[[int, int, int], None]) -> None:
|
||||
"""Set callback for Note On/Off events.
|
||||
|
||||
Args:
|
||||
callback: (note, velocity, channel) called on note on/off.
|
||||
Velocity 0 = note off.
|
||||
"""
|
||||
self._note_callback = callback
|
||||
|
||||
def set_midi_learn_callback(
|
||||
self, callback: Callable[[LearnedMapping], None]
|
||||
) -> None:
|
||||
"""Set callback for MIDI Learn completion.
|
||||
|
||||
Args:
|
||||
callback: Called when a CC is learned during learn mode.
|
||||
"""
|
||||
self._midi_learn_callback = callback
|
||||
|
||||
# ── MIDI Learn ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def learn_mode(self) -> bool:
|
||||
return self._learn_mode
|
||||
|
||||
def start_learn(self, target_param: str) -> None:
|
||||
"""Begin MIDI Learn for a parameter.
|
||||
|
||||
Args:
|
||||
target_param: Unique key identifying the parameter (e.g.
|
||||
"delay.feedback", "reverb.mix").
|
||||
"""
|
||||
self._learn_mode = True
|
||||
self._learn_target = target_param
|
||||
logger.info("MIDI Learn started for %s — move a controller", target_param)
|
||||
|
||||
def stop_learn(self) -> None:
|
||||
"""Exit MIDI Learn mode without assigning."""
|
||||
self._learn_mode = False
|
||||
self._learn_target = None
|
||||
logger.info("MIDI Learn stopped")
|
||||
|
||||
def cancel_learn(self) -> None:
|
||||
"""Cancel MIDI Learn (alias for stop_learn)."""
|
||||
self.stop_learn()
|
||||
|
||||
def get_mapping(self, param_key: str) -> Optional[MIDIMapping]:
|
||||
"""Get the MIDI mapping for a parameter, if any."""
|
||||
return self._mappings.get(param_key)
|
||||
|
||||
def set_mapping(self, param_key: str, mapping: MIDIMapping) -> None:
|
||||
"""Set a MIDI mapping for a parameter."""
|
||||
self._mappings[param_key] = mapping
|
||||
|
||||
def remove_mapping(self, param_key: str) -> None:
|
||||
"""Remove the MIDI mapping for a parameter."""
|
||||
self._mappings.pop(param_key, None)
|
||||
|
||||
def get_all_mappings(self) -> dict[str, MIDIMapping]:
|
||||
"""Return all current MIDI mappings."""
|
||||
return dict(self._mappings)
|
||||
|
||||
# ── Message parsing ────────────────────────────────────────────
|
||||
|
||||
def parse(self, data: bytes) -> Optional[MIDIEvent]:
|
||||
"""Parse raw MIDI bytes into a structured event.
|
||||
|
||||
Only accepts a single complete message (caller must pre-split).
|
||||
For multi-byte messages, pass the full message including status.
|
||||
|
||||
Args:
|
||||
data: Complete MIDI message bytes.
|
||||
|
||||
Returns:
|
||||
Parsed MIDIEvent or None for unrecognized / real-time
|
||||
messages that don't produce events.
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
status = data[0]
|
||||
|
||||
# Real-time messages
|
||||
if status >= 0xF8:
|
||||
if status == RT_CLOCK:
|
||||
self._handle_clock_tick()
|
||||
return MIDIEvent(type="clock")
|
||||
elif status == RT_START:
|
||||
self._handle_clock_start()
|
||||
return MIDIEvent(type="start")
|
||||
elif status == RT_CONTINUE:
|
||||
self._handle_clock_continue()
|
||||
return MIDIEvent(type="continue")
|
||||
elif status == RT_STOP:
|
||||
self._handle_clock_stop()
|
||||
return MIDIEvent(type="stop")
|
||||
return None # active sensing, tick, reset — ignored
|
||||
|
||||
# Channel voice messages
|
||||
status_type = status & STATUS_TYPE_MASK
|
||||
channel = status & CHANNEL_MASK
|
||||
|
||||
match status_type:
|
||||
case 0x90: # Note On
|
||||
if len(data) < 3:
|
||||
return None
|
||||
return MIDIEvent(
|
||||
type="note_on" if data[2] > 0 else "note_off",
|
||||
channel=channel,
|
||||
note=data[1],
|
||||
velocity=data[2],
|
||||
)
|
||||
case 0x80: # Note Off
|
||||
if len(data) < 3:
|
||||
return None
|
||||
return MIDIEvent(
|
||||
type="note_off",
|
||||
channel=channel,
|
||||
note=data[1],
|
||||
velocity=data[2],
|
||||
)
|
||||
case 0xB0: # Control Change
|
||||
if len(data) < 3:
|
||||
return None
|
||||
return MIDIEvent(
|
||||
type="cc",
|
||||
channel=channel,
|
||||
cc_number=data[1],
|
||||
cc_value=data[2],
|
||||
)
|
||||
case 0xC0: # Program Change
|
||||
if len(data) < 2:
|
||||
return None
|
||||
return MIDIEvent(
|
||||
type="pc",
|
||||
channel=channel,
|
||||
program=data[1],
|
||||
)
|
||||
case 0xD0: # Channel Pressure (Aftertouch)
|
||||
if len(data) < 2:
|
||||
return None
|
||||
return MIDIEvent(
|
||||
type="channel_pressure",
|
||||
channel=channel,
|
||||
velocity=data[1],
|
||||
)
|
||||
case 0xE0: # Pitch Bend
|
||||
if len(data) < 3:
|
||||
return None
|
||||
# 14-bit value
|
||||
value = data[1] | (data[2] << 7)
|
||||
return MIDIEvent(
|
||||
type="pitch_bend",
|
||||
channel=channel,
|
||||
cc_value=value, # Reuse field, 0-16383
|
||||
)
|
||||
case 0xA0: # Polyphonic Pressure
|
||||
if len(data) < 3:
|
||||
return None
|
||||
return MIDIEvent(
|
||||
type="poly_pressure",
|
||||
channel=channel,
|
||||
note=data[1],
|
||||
velocity=data[2],
|
||||
)
|
||||
case _:
|
||||
return None
|
||||
|
||||
def dispatch(self, event: MIDIEvent) -> None:
|
||||
"""Dispatch a parsed MIDI event to registered handlers.
|
||||
|
||||
Args:
|
||||
event: Parsed MIDI event.
|
||||
"""
|
||||
match event.type:
|
||||
case "pc":
|
||||
if self._pc_callback:
|
||||
self._pc_callback(event.channel, event.program)
|
||||
logger.debug("PC: ch=%d, program=%d", event.channel, event.program)
|
||||
|
||||
case "cc":
|
||||
self._dispatch_cc(event)
|
||||
|
||||
case "note_on" | "note_off":
|
||||
if self._note_callback:
|
||||
self._note_callback(event.note, event.velocity, event.channel)
|
||||
logger.debug("Note: %s %d (vel=%d, ch=%d)",
|
||||
event.type, event.note, event.velocity, event.channel)
|
||||
|
||||
case "pitch_bend":
|
||||
logger.debug("Pitch Bend: %d (ch=%d)", event.cc_value, event.channel)
|
||||
|
||||
case "clock" | "start" | "stop" | "continue":
|
||||
pass # Already handled by parse
|
||||
|
||||
case _:
|
||||
logger.debug("Unhandled event: %s", event.type)
|
||||
|
||||
def _dispatch_cc(self, event: MIDIEvent) -> None:
|
||||
"""Handle a Control Change event.
|
||||
|
||||
Handles 14-bit CC (MSB values 0-31 cue LSB values 32-63),
|
||||
MIDI Learn, expression pedal, and registered callbacks.
|
||||
"""
|
||||
cc = event.cc_number
|
||||
val = event.cc_value
|
||||
|
||||
# ── MIDI Learn ──
|
||||
if self._learn_mode and self._learn_target:
|
||||
mapping = MIDIMapping(
|
||||
cc_number=cc,
|
||||
channel=event.channel,
|
||||
min_val=0.0,
|
||||
max_val=1.0,
|
||||
)
|
||||
self._mappings[self._learn_target] = mapping
|
||||
learned = LearnedMapping(
|
||||
cc_number=cc,
|
||||
channel=event.channel,
|
||||
param_key=self._learn_target,
|
||||
)
|
||||
if self._midi_learn_callback:
|
||||
self._midi_learn_callback(learned)
|
||||
logger.info("MIDI Learned: CC %d → %s", cc, self._learn_target)
|
||||
self._learn_mode = False # Auto-exit after learn
|
||||
self._learn_target = None
|
||||
return
|
||||
|
||||
# ── 14-bit CC (MSB values 0-31 paired with LSB 32-63) ──
|
||||
if cc < 32:
|
||||
# MSB received — store and wait for LSB
|
||||
self._cc_14bit_high[cc + 32] = val
|
||||
# Also fire the MSB callback for immediate response
|
||||
cb = self._cc_callbacks.get(cc)
|
||||
if cb:
|
||||
cb(val, event.channel)
|
||||
# Check if we got both halves
|
||||
if cc + 32 in self._cc_14bit_high:
|
||||
# We have LSB too — compute 14-bit
|
||||
lsb = self._cc_14bit_high.pop(cc + 32)
|
||||
# Don't fire again; MSB already fired
|
||||
elif 32 <= cc <= 63:
|
||||
# LSB — if we have MSB, build 14-bit; otherwise ignore
|
||||
if cc in self._cc_14bit_high:
|
||||
msb = self._cc_14bit_high.pop(cc)
|
||||
# Fire the MSB callback with combined value scaled to 0-127
|
||||
combined_14bit = (msb << 7) | val
|
||||
# Scale to 0-127 for the CC callback
|
||||
scaled = combined_14bit >> 7 # ≈ msb, but with LSB contribution
|
||||
cb = self._cc_callbacks.get(cc - 32)
|
||||
if cb:
|
||||
cb(scaled, event.channel)
|
||||
return # Already handled via MSB callback
|
||||
else:
|
||||
# Orphaned LSB — could be mono expression pedal
|
||||
cb = self._cc_callbacks.get(cc)
|
||||
if cb:
|
||||
cb(val, event.channel)
|
||||
return
|
||||
|
||||
# ── Standard CC dispatch ──
|
||||
cb = self._cc_callbacks.get(cc)
|
||||
if cb:
|
||||
cb(val, event.channel)
|
||||
logger.debug("CC: %d → %d (ch=%d)", cc, val, event.channel)
|
||||
|
||||
# ── Clock sync ─────────────────────────────────────────────────
|
||||
|
||||
def _handle_clock_tick(self) -> None:
|
||||
"""Process a MIDI clock tick (24 PPQN)."""
|
||||
now = time.monotonic()
|
||||
self._clock_times.append(now)
|
||||
|
||||
if not self._clock_running:
|
||||
return
|
||||
|
||||
# Calculate BPM from the interval between consecutive clock ticks
|
||||
if len(self._clock_times) >= 2:
|
||||
recent = list(self._clock_times)[-2:]
|
||||
interval = recent[1] - recent[0]
|
||||
if interval > 0:
|
||||
bpm = 60.0 / (interval * CLOCK_PPQN)
|
||||
# Only update if significantly different and plausible
|
||||
if 20 < bpm < 300:
|
||||
# Smooth with exponential moving average
|
||||
if self._current_bpm == 0:
|
||||
self._current_bpm = bpm
|
||||
else:
|
||||
alpha = 0.3
|
||||
self._current_bpm = (
|
||||
alpha * bpm + (1 - alpha) * self._current_bpm
|
||||
)
|
||||
if self._clock_callback:
|
||||
self._clock_callback(self._current_bpm)
|
||||
|
||||
def _handle_clock_start(self) -> None:
|
||||
"""MIDI Start — begin clock tracking."""
|
||||
self._clock_times.clear()
|
||||
self._clock_running = True
|
||||
self._clock_start_time = time.monotonic()
|
||||
self._current_bpm = 0.0
|
||||
logger.info("MIDI clock started")
|
||||
|
||||
def _handle_clock_continue(self) -> None:
|
||||
"""MIDI Continue — resume clock tracking."""
|
||||
self._clock_running = True
|
||||
logger.info("MIDI clock continued")
|
||||
|
||||
def _handle_clock_stop(self) -> None:
|
||||
"""MIDI Stop — pause clock tracking."""
|
||||
self._clock_running = False
|
||||
logger.info("MIDI clock stopped")
|
||||
|
||||
@property
|
||||
def current_bpm(self) -> float:
|
||||
"""Current detected BPM from MIDI clock, or 0.0 if no clock."""
|
||||
return self._current_bpm
|
||||
|
||||
@property
|
||||
def clock_running(self) -> bool:
|
||||
"""Whether MIDI clock is actively being received."""
|
||||
return self._clock_running
|
||||
|
||||
def reset_clock(self) -> None:
|
||||
"""Manually reset MIDI clock state."""
|
||||
self._clock_times.clear()
|
||||
self._current_bpm = 0.0
|
||||
self._clock_running = False
|
||||
|
||||
# ── MIDI output builders ───────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def send_cc(cc_number: int, value: int, channel: int = 0) -> bytes:
|
||||
"""Build a MIDI Control Change message."""
|
||||
return bytes([CONTROL_CHANGE | (channel & 0x0F),
|
||||
max(0, min(127, cc_number)),
|
||||
max(0, min(127, value))])
|
||||
|
||||
@staticmethod
|
||||
def send_pc(program: int, channel: int = 0) -> bytes:
|
||||
"""Build a MIDI Program Change message."""
|
||||
return bytes([PROGRAM_CHANGE | (channel & 0x0F),
|
||||
max(0, min(127, program))])
|
||||
|
||||
@staticmethod
|
||||
def send_note_on(note: int, velocity: int = 127, channel: int = 0) -> bytes:
|
||||
"""Build a MIDI Note On message."""
|
||||
return bytes([NOTE_ON | (channel & 0x0F),
|
||||
max(0, min(127, note)),
|
||||
max(0, min(127, velocity))])
|
||||
|
||||
@staticmethod
|
||||
def send_note_off(note: int, velocity: int = 0, channel: int = 0) -> bytes:
|
||||
"""Build a MIDI Note Off message."""
|
||||
return bytes([NOTE_OFF | (channel & 0x0F),
|
||||
max(0, min(127, note)),
|
||||
max(0, min(127, velocity))])
|
||||
|
||||
@staticmethod
|
||||
def send_pitch_bend(value: int, channel: int = 0) -> bytes:
|
||||
"""Build a MIDI Pitch Bend message (0-16383, center 8192)."""
|
||||
val = max(0, min(16383, value))
|
||||
return bytes([PITCH_BEND | (channel & 0x0F),
|
||||
val & 0x7F,
|
||||
(val >> 7) & 0x7F])
|
||||
|
||||
@staticmethod
|
||||
def send_clock_start() -> bytes:
|
||||
"""Build a MIDI Real-Time Start message."""
|
||||
return bytes([RT_START])
|
||||
|
||||
@staticmethod
|
||||
def send_clock_stop() -> bytes:
|
||||
"""Build a MIDI Real-Time Stop message."""
|
||||
return bytes([RT_STOP])
|
||||
|
||||
@staticmethod
|
||||
def send_clock_tick() -> bytes:
|
||||
"""Build a MIDI Clock message."""
|
||||
return bytes([RT_CLOCK])
|
||||
|
||||
@staticmethod
|
||||
def send_sysex(manufacturer_id: int, data: bytes) -> bytes:
|
||||
"""Build a SysEx message."""
|
||||
return bytes([SYS_EXCLUSIVE, manufacturer_id & 0x7F]) + data + bytes([SYS_EXCLUSIVE_END])
|
||||
|
||||
# ── Hardware I/O ───────────────────────────────────────────────
|
||||
|
||||
def start(
|
||||
self,
|
||||
uart_port: str | None = "/dev/ttyAMA0",
|
||||
usb: bool = True,
|
||||
usb_port_name: str = "",
|
||||
) -> None:
|
||||
"""Open MIDI ports and start the read thread.
|
||||
|
||||
Args:
|
||||
uart_port: UART device path for 5-pin DIN, or None to skip.
|
||||
usb: Whether to attempt USB-MIDI.
|
||||
usb_port_name: Filter string for USB port selection.
|
||||
"""
|
||||
self._interfaces = []
|
||||
|
||||
if uart_port:
|
||||
uart = UARTMIDI(port=uart_port)
|
||||
if uart.open():
|
||||
self._interfaces.append(uart)
|
||||
|
||||
if usb:
|
||||
usb_midi = USBMIDI(port_name=usb_port_name)
|
||||
if usb_midi.open():
|
||||
self._interfaces.append(usb_midi)
|
||||
|
||||
if not self._interfaces:
|
||||
logger.warning("No MIDI interfaces opened — running in software-only mode")
|
||||
|
||||
self._running = True
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop,
|
||||
name="midi-read",
|
||||
daemon=True,
|
||||
)
|
||||
self._read_thread.start()
|
||||
logger.info("MIDI handler started with %d interface(s)", len(self._interfaces))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the read thread and close MIDI ports."""
|
||||
self._running = False
|
||||
if self._read_thread and self._read_thread.is_alive():
|
||||
self._read_thread.join(timeout=2.0)
|
||||
for iface in self._interfaces:
|
||||
iface.close()
|
||||
self._interfaces.clear()
|
||||
logger.info("MIDI handler stopped")
|
||||
|
||||
def send(self, msg: bytes) -> None:
|
||||
"""Send a MIDI message to all open interfaces.
|
||||
|
||||
Args:
|
||||
msg: Raw MIDI message bytes.
|
||||
"""
|
||||
for iface in self._interfaces:
|
||||
iface.send(msg)
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Background thread: poll all interfaces and queue events."""
|
||||
while self._running:
|
||||
for iface in self._interfaces:
|
||||
try:
|
||||
msgs = iface.read(timeout=0.001)
|
||||
except Exception as e:
|
||||
logger.warning("Read error on %s: %s", iface.name, e)
|
||||
continue
|
||||
for msg in msgs:
|
||||
event = self.parse(msg)
|
||||
if event:
|
||||
self.dispatch(event)
|
||||
# Small sleep to prevent busy-wait
|
||||
time.sleep(0.0005)
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def interfaces(self) -> list[MIDIInterface]:
|
||||
return list(self._interfaces)
|
||||
|
||||
@property
|
||||
def interface_names(self) -> list[str]:
|
||||
return [i.name for i in self._interfaces]
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Preset and bank manager — save, load, navigate, and auto-restore presets.
|
||||
|
||||
The PresetManager owns the on-disk preset store and provides:
|
||||
- Human-readable JSON preset storage
|
||||
- Bank structure with 4 presets per bank
|
||||
- Footswitch navigation (preset-up/down wraps within bank, bank-up/down switches banks)
|
||||
- MIDI Program Change routing (bank=channel, program=preset)
|
||||
- Auto-restore of last active preset on power cycle
|
||||
- Factory preset installation
|
||||
- Preset rename and reorder
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .types import Bank, FXBlock, FXType, MIDIMapping, Preset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
PRESETS_PER_BANK = 4
|
||||
"""Number of presets per bank (convention: footswitch 1-4 within a bank)."""
|
||||
|
||||
AUTO_SAVE_DELAY_S = 1.0
|
||||
"""Debounce delay in seconds before writing auto-save state to disk."""
|
||||
|
||||
FACTORY_PRESET_DIR = Path(__file__).resolve().parent.parent.parent / "presets" / "factory"
|
||||
"""Location of bundled factory preset JSON files."""
|
||||
|
||||
|
||||
# ── Serialisation helpers ───────────────────────────────────────────────────
|
||||
|
||||
def _preset_to_dict(preset: Preset) -> dict:
|
||||
"""Serialize a Preset to a JSON-compatible dict."""
|
||||
return {
|
||||
"name": preset.name,
|
||||
"bank": preset.bank,
|
||||
"program": preset.program,
|
||||
"master_volume": preset.master_volume,
|
||||
"tuner_enabled": preset.tuner_enabled,
|
||||
"chain": [
|
||||
{
|
||||
"fx_type": block.fx_type.value,
|
||||
"enabled": block.enabled,
|
||||
"bypass": block.bypass,
|
||||
"params": dict(block.params),
|
||||
"nam_model_path": block.nam_model_path,
|
||||
"ir_file_path": block.ir_file_path,
|
||||
}
|
||||
for block in preset.chain
|
||||
],
|
||||
"midi_mappings": {
|
||||
key: {
|
||||
"cc_number": mapping.cc_number,
|
||||
"channel": mapping.channel,
|
||||
"min_val": mapping.min_val,
|
||||
"max_val": mapping.max_val,
|
||||
}
|
||||
for key, mapping in preset.midi_mappings.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _preset_from_dict(data: dict) -> Preset:
|
||||
"""Deserialize a Preset from a JSON-compatible dict."""
|
||||
chain = []
|
||||
for block_data in data.get("chain", []):
|
||||
chain.append(
|
||||
FXBlock(
|
||||
fx_type=FXType(block_data["fx_type"]),
|
||||
enabled=block_data.get("enabled", True),
|
||||
bypass=block_data.get("bypass", False),
|
||||
params=dict(block_data.get("params", {})),
|
||||
nam_model_path=block_data.get("nam_model_path", ""),
|
||||
ir_file_path=block_data.get("ir_file_path", ""),
|
||||
)
|
||||
)
|
||||
|
||||
midi_mappings = {}
|
||||
for key, md in data.get("midi_mappings", {}).items():
|
||||
midi_mappings[key] = MIDIMapping(
|
||||
cc_number=md.get("cc_number", 0),
|
||||
channel=md.get("channel", 0),
|
||||
min_val=md.get("min_val", 0.0),
|
||||
max_val=md.get("max_val", 1.0),
|
||||
)
|
||||
|
||||
return Preset(
|
||||
name=data["name"],
|
||||
bank=data.get("bank", 0),
|
||||
program=data.get("program", 0),
|
||||
chain=chain,
|
||||
midi_mappings=midi_mappings,
|
||||
master_volume=data.get("master_volume", 0.8),
|
||||
tuner_enabled=data.get("tuner_enabled", False),
|
||||
)
|
||||
|
||||
|
||||
# ── The PresetManager ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PresetManager:
|
||||
"""Manages preset/bank storage, navigation, MIDI binding, and auto-save.
|
||||
|
||||
Args:
|
||||
preset_dir: Directory for the preset store (created if missing).
|
||||
audio_pipeline: Optional AudioPipeline to activate presets on.
|
||||
When set, ``activate()`` calls ``pipeline.load_preset()``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
preset_dir: str | Path = "~/.pedal/presets",
|
||||
audio_pipeline: object = None,
|
||||
) -> None:
|
||||
self._dir = Path(preset_dir).expanduser().resolve()
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
self._pipeline = audio_pipeline
|
||||
|
||||
# Runtime state
|
||||
self._current_bank: int = 0
|
||||
self._current_program: int = 0
|
||||
self._dirty = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Register of known bank numbers (populated lazily)
|
||||
self._known_banks: set[int] = set()
|
||||
|
||||
# Auto-save debounce timer
|
||||
self._auto_save_timer: Optional[threading.Timer] = None
|
||||
|
||||
logger.info("PresetManager root: %s", self._dir)
|
||||
|
||||
# ── Public navigation API ───────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def current_bank(self) -> int:
|
||||
return self._current_bank
|
||||
|
||||
@property
|
||||
def current_program(self) -> int:
|
||||
return self._current_program
|
||||
|
||||
@property
|
||||
def current_preset_path(self) -> Path:
|
||||
"""Path to the slot for the current (bank, program)."""
|
||||
return self._preset_path(self._current_bank, self._current_program)
|
||||
|
||||
def preset_up(self) -> Preset:
|
||||
"""Select next preset in current bank (wraps 3→0).
|
||||
|
||||
Returns:
|
||||
The activated Preset.
|
||||
"""
|
||||
bank = self._get_or_create_bank(self._current_bank)
|
||||
prog = (self._current_program + 1) % PRESETS_PER_BANK
|
||||
return self._select_and_activate(self._current_bank, prog)
|
||||
|
||||
def preset_down(self) -> Preset:
|
||||
"""Select previous preset in current bank (wraps 0→3).
|
||||
|
||||
Returns:
|
||||
The activated Preset.
|
||||
"""
|
||||
prog = (self._current_program - 1) % PRESETS_PER_BANK
|
||||
return self._select_and_activate(self._current_bank, prog)
|
||||
|
||||
def bank_up(self) -> tuple[Bank, Preset]:
|
||||
"""Move to the next higher-numbered bank.
|
||||
|
||||
If there are no banks above the current one, wraps to the
|
||||
lowest known bank number.
|
||||
|
||||
Returns:
|
||||
(Bank, Preset) — the new bank and its currently active preset.
|
||||
"""
|
||||
return self._switch_bank(+1)
|
||||
|
||||
def bank_down(self) -> tuple[Bank, Preset]:
|
||||
"""Move to the next lower-numbered bank (wraps around).
|
||||
|
||||
Returns:
|
||||
(Bank, Preset) — the new bank and its currently active preset.
|
||||
"""
|
||||
return self._switch_bank(-1)
|
||||
|
||||
def select(self, bank: int, program: int) -> Preset:
|
||||
"""Directly select a specific (bank, program) slot.
|
||||
|
||||
Args:
|
||||
bank: Bank number.
|
||||
program: Preset index (0-3) within the bank.
|
||||
|
||||
Returns:
|
||||
The activated Preset.
|
||||
"""
|
||||
return self._select_and_activate(bank, program)
|
||||
|
||||
def midi_pc(self, channel: int, program: int) -> Preset:
|
||||
"""Handle MIDI Program Change: bank=channel, program=program.
|
||||
|
||||
Args:
|
||||
channel: MIDI channel (used as bank number).
|
||||
program: MIDI program number (used as preset index).
|
||||
|
||||
Returns:
|
||||
The activated Preset.
|
||||
"""
|
||||
logger.info("MIDI PC: bank=%d, program=%d", channel, program)
|
||||
return self._select_and_activate(channel, program)
|
||||
|
||||
# ── CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
def save(self, preset: Preset, *, auto: bool = False) -> None:
|
||||
"""Save a preset to its (bank, program) slot on disk.
|
||||
|
||||
Args:
|
||||
preset: The preset to persist.
|
||||
auto: If True, this was triggered by auto-save and the
|
||||
dirty/current-state write is implicitly handled.
|
||||
"""
|
||||
path = self._preset_path(preset.bank, preset.program)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(_preset_to_dict(preset), indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if not auto:
|
||||
self._dirty = True
|
||||
self._known_banks.add(preset.bank)
|
||||
|
||||
# Save/update bank metadata
|
||||
self._save_bank_meta(preset.bank)
|
||||
|
||||
logger.info("Saved preset '%s' → %s", preset.name, path)
|
||||
|
||||
def load(self, bank: int, program: int) -> Preset:
|
||||
"""Load a preset from disk.
|
||||
|
||||
Args:
|
||||
bank: Bank number.
|
||||
program: Preset index (0-3).
|
||||
|
||||
Returns:
|
||||
The deserialized Preset.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the preset slot doesn't exist.
|
||||
json.JSONDecodeError: If the file is corrupt.
|
||||
"""
|
||||
path = self._preset_path(bank, program)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"No preset at (bank={bank}, program={program}): {path}"
|
||||
)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
preset = _preset_from_dict(data)
|
||||
# Ensure metadata is correct even if file was manually altered
|
||||
preset.bank = bank
|
||||
preset.program = program
|
||||
return preset
|
||||
|
||||
def delete(self, bank: int, program: int) -> None:
|
||||
"""Delete a preset slot from disk."""
|
||||
path = self._preset_path(bank, program)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.info("Deleted preset at (bank=%d, program=%d)", bank, program)
|
||||
self._dirty = True
|
||||
|
||||
def rename(self, bank: int, program: int, new_name: str) -> Preset:
|
||||
"""Rename a preset and re-save it.
|
||||
|
||||
Args:
|
||||
bank: Bank number.
|
||||
program: Preset index.
|
||||
new_name: Replacement name.
|
||||
|
||||
Returns:
|
||||
The renamed Preset.
|
||||
"""
|
||||
preset = self.load(bank, program)
|
||||
preset.name = new_name
|
||||
self.save(preset)
|
||||
logger.info("Renamed preset at (%d,%d) → '%s'", bank, program, new_name)
|
||||
return preset
|
||||
|
||||
def reorder(self, bank: int, program: int, new_program: int) -> None:
|
||||
"""Move a preset to a different slot within the same bank.
|
||||
|
||||
If the target slot is occupied the two presets are swapped.
|
||||
If the current active preset is being moved, tracking is updated.
|
||||
|
||||
Args:
|
||||
bank: Bank number.
|
||||
program: Current program index.
|
||||
new_program: Target program index (0-3).
|
||||
"""
|
||||
if program == new_program:
|
||||
return
|
||||
if not (0 <= new_program < PRESETS_PER_BANK):
|
||||
raise ValueError(f"Program index {new_program} out of range [0, {PRESETS_PER_BANK})")
|
||||
|
||||
# Load both existing presets (or create empty placeholder for empty slot)
|
||||
try:
|
||||
src = self.load(bank, program)
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Cannot reorder: no preset at (bank={bank}, program={program})")
|
||||
|
||||
try:
|
||||
dst = self.load(bank, new_program)
|
||||
src.program = new_program
|
||||
dst.program = program
|
||||
self.save(dst)
|
||||
except FileNotFoundError:
|
||||
src.program = new_program
|
||||
|
||||
self.save(src)
|
||||
|
||||
# Update current tracking if we moved the active preset
|
||||
with self._lock:
|
||||
if self._current_bank == bank and self._current_program == program:
|
||||
self._current_program = new_program
|
||||
|
||||
logger.info("Reordered preset at (%d,%d) → (%d,%d)", bank, program, bank, new_program)
|
||||
|
||||
# ── Bank management ─────────────────────────────────────────────────────
|
||||
|
||||
def list_banks(self) -> list[Bank]:
|
||||
"""Scan the preset directory and return known banks sorted by number."""
|
||||
banks: dict[int, Bank] = {}
|
||||
for meta_path in sorted(self._dir.glob("bank_*/bank.json")):
|
||||
try:
|
||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
num = data.get("number", 0)
|
||||
preset_count = sum(
|
||||
1 for _ in meta_path.parent.glob("preset_*.json")
|
||||
)
|
||||
banks[num] = Bank(
|
||||
name=data.get("name", f"Bank {num}"),
|
||||
number=num,
|
||||
presets=[None] * preset_count, # Placeholder
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
# Also discover banks by scanning dirs without bank.json
|
||||
for p_dir in sorted(self._dir.glob("bank_*")):
|
||||
try:
|
||||
num = int(p_dir.name.split("_")[1])
|
||||
if num not in banks:
|
||||
banks[num] = Bank(name=f"Bank {num}", number=num)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
self._known_banks = set(banks.keys())
|
||||
return [banks[k] for k in sorted(banks)]
|
||||
|
||||
def get_or_create_bank(self, number: int, name: str = "") -> Bank:
|
||||
"""Return an existing bank or create a new one.
|
||||
|
||||
Args:
|
||||
number: Bank number.
|
||||
name: Optional display name (defaults to f"Bank {number}").
|
||||
|
||||
Returns:
|
||||
The Bank object.
|
||||
"""
|
||||
return self._get_or_create_bank(number, name)
|
||||
|
||||
# ── Auto-restore ────────────────────────────────────────────────────────
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""Persist the current (bank, program) for power-cycle restore.
|
||||
|
||||
Call this whenever the active preset changes to guarantee
|
||||
the pedal comes back to the same sound after a power cycle.
|
||||
"""
|
||||
state = {"current_bank": self._current_bank, "current_program": self._current_program}
|
||||
state_path = self._dir / "state.json"
|
||||
state_path.write_text(
|
||||
json.dumps(state, indent=2), encoding="utf-8"
|
||||
)
|
||||
logger.debug("State saved: bank=%d program=%d", self._current_bank, self._current_program)
|
||||
|
||||
def restore_state(self) -> Optional[Preset]:
|
||||
"""Restore the last active preset from state.json.
|
||||
|
||||
Returns:
|
||||
The restored Preset, or None if no state file exists or the
|
||||
saved slot is empty.
|
||||
"""
|
||||
state_path = self._dir / "state.json"
|
||||
if not state_path.exists():
|
||||
logger.info("No saved state to restore")
|
||||
return None
|
||||
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
bank = state["current_bank"]
|
||||
program = state["current_program"]
|
||||
preset = self.load(bank, program)
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||
logger.warning("Could not restore state, falling back to first preset")
|
||||
return None
|
||||
|
||||
self._current_bank = bank
|
||||
self._current_program = program
|
||||
self._dirty = False
|
||||
logger.info("Restored state: bank=%d program=%d '%s'", bank, program, preset.name)
|
||||
return preset
|
||||
|
||||
# ── Activation ──────────────────────────────────────────────────────────
|
||||
|
||||
def activate(self, preset: Preset) -> None:
|
||||
"""Apply a preset to the audio pipeline (if one is connected).
|
||||
|
||||
Args:
|
||||
preset: The preset to activate.
|
||||
"""
|
||||
if self._pipeline is not None:
|
||||
self._pipeline.load_preset(preset)
|
||||
logger.info("Activated preset '%s' (bank=%d, program=%d)",
|
||||
preset.name, preset.bank, preset.program)
|
||||
|
||||
# ── Factory presets ─────────────────────────────────────────────────────
|
||||
|
||||
def install_factory_presets(self, overwrite: bool = False) -> int:
|
||||
"""Install bundled factory presets into the preset store.
|
||||
|
||||
Scans ``FACTORY_PRESET_DIR`` for ``bank_*/preset_*.json`` files
|
||||
and copies them into the user preset store.
|
||||
|
||||
Args:
|
||||
overwrite: If True, overwrite existing presets at the same
|
||||
(bank, program) slots. If False, skip occupied slots.
|
||||
|
||||
Returns:
|
||||
Number of factory presets installed.
|
||||
"""
|
||||
if not FACTORY_PRESET_DIR.is_dir():
|
||||
logger.warning("Factory preset directory not found: %s", FACTORY_PRESET_DIR)
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for src in sorted(FACTORY_PRESET_DIR.rglob("preset_*.json")):
|
||||
# Derive (bank, program) from file path
|
||||
bank_dir = src.parent
|
||||
try:
|
||||
bank_num = int(bank_dir.name.split("_")[1])
|
||||
prog_num = int(src.stem.split("_")[1])
|
||||
except (ValueError, IndexError):
|
||||
logger.warning("Skipping malformed factory preset path: %s", src)
|
||||
continue
|
||||
|
||||
dest = self._preset_path(bank_num, prog_num)
|
||||
if dest.exists() and not overwrite:
|
||||
continue
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
self._known_banks.add(bank_num)
|
||||
self._save_bank_meta(bank_num)
|
||||
count += 1
|
||||
logger.debug("Installed factory preset: %s", dest)
|
||||
|
||||
if count:
|
||||
self._dirty = True
|
||||
logger.info("Installed %d factory presets", count)
|
||||
return count
|
||||
|
||||
# ── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _preset_path(self, bank: int, program: int) -> Path:
|
||||
"""Compute the on-disk path for a (bank, program) slot."""
|
||||
return self._dir / f"bank_{bank}" / f"preset_{program}.json"
|
||||
|
||||
def _bank_dir(self, bank: int) -> Path:
|
||||
return self._dir / f"bank_{bank}"
|
||||
|
||||
def _bank_meta_path(self, bank: int) -> Path:
|
||||
return self._bank_dir(bank) / "bank.json"
|
||||
|
||||
def _get_or_create_bank(self, number: int, name: str = "") -> Bank:
|
||||
"""Return an existing bank or create a new one on disk."""
|
||||
meta_path = self._bank_meta_path(number)
|
||||
if meta_path.exists():
|
||||
try:
|
||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
return Bank(
|
||||
name=data.get("name", name or f"Bank {number}"),
|
||||
number=data.get("number", number),
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Create new bank
|
||||
bank = Bank(name=name or f"Bank {number}", number=number)
|
||||
self._save_bank_meta(number, bank.name)
|
||||
self._known_banks.add(number)
|
||||
logger.info("Created bank %d: '%s'", number, bank.name)
|
||||
return bank
|
||||
|
||||
def _save_bank_meta(self, bank_num: int, name: str | None = None) -> None:
|
||||
"""Write or update a bank's metadata file."""
|
||||
meta_path = self._bank_meta_path(bank_num)
|
||||
existing = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
existing = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
meta = {
|
||||
"number": bank_num,
|
||||
"name": name or existing.get("name", f"Bank {bank_num}"),
|
||||
"preset_count": PRESETS_PER_BANK,
|
||||
}
|
||||
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
def _switch_bank(self, direction: int) -> tuple[Bank, Preset]:
|
||||
"""Move +1 or -1 through known banks, wrapping at edges.
|
||||
|
||||
Args:
|
||||
direction: +1 for bank_up, -1 for bank_down.
|
||||
|
||||
Returns:
|
||||
(Bank, Preset) of the new active slot.
|
||||
"""
|
||||
banks = self.list_banks()
|
||||
if not banks:
|
||||
raise RuntimeError("No banks available")
|
||||
|
||||
current_idx = next(
|
||||
(i for i, b in enumerate(banks) if b.number == self._current_bank),
|
||||
None,
|
||||
)
|
||||
if current_idx is None:
|
||||
current_idx = 0
|
||||
|
||||
new_idx = (current_idx + direction) % len(banks)
|
||||
new_bank = banks[new_idx]
|
||||
|
||||
# Activate preset at same program index within the new bank,
|
||||
# defaulting to a blank preset if the slot is empty
|
||||
try:
|
||||
preset = self.load(new_bank.number, self._current_program)
|
||||
except FileNotFoundError:
|
||||
preset = Preset(
|
||||
name=f"Empty {new_bank.name}",
|
||||
bank=new_bank.number,
|
||||
program=self._current_program,
|
||||
)
|
||||
|
||||
self._current_bank = new_bank.number
|
||||
self._current_program = preset.program
|
||||
self.activate(preset)
|
||||
self.save_state()
|
||||
logger.info("Switched to bank %d, preset '%s'", new_bank.number, preset.name)
|
||||
return new_bank, preset
|
||||
|
||||
def _select_and_activate(self, bank: int, program: int) -> Preset:
|
||||
"""Select a (bank, program) slot and activate it.
|
||||
|
||||
If the slot is empty, creates a blank preset in that slot first.
|
||||
|
||||
Args:
|
||||
bank: Bank number.
|
||||
program: Preset index (0-3).
|
||||
|
||||
Returns:
|
||||
The activated Preset.
|
||||
"""
|
||||
program = max(0, min(PRESETS_PER_BANK - 1, program))
|
||||
|
||||
try:
|
||||
preset = self.load(bank, program)
|
||||
except FileNotFoundError:
|
||||
# Create a blank placeholder preset
|
||||
bank_obj = self._get_or_create_bank(bank)
|
||||
preset = Preset(name=f"New {bank_obj.name} #{program}", bank=bank, program=program)
|
||||
self.save(preset, auto=True)
|
||||
|
||||
self._current_bank = bank
|
||||
self._current_program = program
|
||||
self.activate(preset)
|
||||
self.save_state()
|
||||
return preset
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Preset and signal chain data model for the Pi Multi-FX Pedal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class FXType(enum.StrEnum):
|
||||
"""Types of effects in the pedal signal chain."""
|
||||
NOISE_GATE = "noise_gate"
|
||||
COMPRESSOR = "compressor"
|
||||
BOOST = "boost"
|
||||
OVERDRIVE = "overdrive"
|
||||
DISTORTION = "distortion"
|
||||
FUZZ = "fuzz"
|
||||
NAM_AMP = "nam_amp"
|
||||
IR_CAB = "ir_cab"
|
||||
EQ = "eq"
|
||||
CHORUS = "chorus"
|
||||
FLANGER = "flanger"
|
||||
PHASER = "phaser"
|
||||
TREMOLO = "tremolo"
|
||||
VIBRATO = "vibrato"
|
||||
DELAY = "delay"
|
||||
REVERB = "reverb"
|
||||
VOLUME = "volume"
|
||||
TUNER = "tuner"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FXBlock:
|
||||
"""A single block in the signal chain."""
|
||||
fx_type: FXType
|
||||
enabled: bool = True
|
||||
bypass: bool = False
|
||||
params: dict[str, float] = field(default_factory=dict)
|
||||
nam_model_path: str = "" # Path to .nam file (for NAM_AMP type)
|
||||
ir_file_path: str = "" # Path to .wav IR file (for IR_CAB type)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MIDIMapping:
|
||||
"""MIDI control mapping for a parameter."""
|
||||
cc_number: int = 0
|
||||
channel: int = 0
|
||||
min_val: float = 0.0
|
||||
max_val: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Preset:
|
||||
"""A complete pedal preset — full signal chain state."""
|
||||
name: str
|
||||
bank: int = 0
|
||||
program: int = 0
|
||||
chain: list[FXBlock] = field(default_factory=list)
|
||||
midi_mappings: dict[str, MIDIMapping] = field(default_factory=dict)
|
||||
master_volume: float = 0.8
|
||||
tuner_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bank:
|
||||
"""A bank of presets (typically 4 per bank)."""
|
||||
name: str
|
||||
number: int
|
||||
presets: list[Preset] = field(default_factory=list)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""System integration subpackage.
|
||||
|
||||
- audio: ALSA/JACK/I2S configuration and lifecycle
|
||||
- setup (WIP): First-boot setup scripts
|
||||
"""
|
||||
|
||||
from .audio import AudioConfig, AudioSystem
|
||||
|
||||
__all__ = [
|
||||
"AudioConfig",
|
||||
"AudioSystem",
|
||||
]
|
||||
@@ -0,0 +1,597 @@
|
||||
"""System-level audio configuration for the Pi Multi-FX Pedal.
|
||||
|
||||
Manages ALSA / JACK / I2S setup on RPi 4B:
|
||||
- I2S DAC/ADC initialization with known-good overlays
|
||||
- JACK audio server configuration with PREEMPT_RT priority
|
||||
- ALSA device discovery and naming
|
||||
- JACK port auto-connect for FX pipeline
|
||||
- XRun monitoring
|
||||
- Round-trip latency measurement
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Known-good I2S overlays ───────────────────────────────────────
|
||||
# Map: short key → (dtoverlay line, expected ALSA card index, description)
|
||||
# Card index 0 assumes the I2S HAT is the only sound card; adjust if HDMI/USB audio present.
|
||||
I2S_CONFIGS: dict[str, tuple[str, int, str]] = {
|
||||
"audioinjector": (
|
||||
"dtoverlay=audioinjector-wm8731",
|
||||
0,
|
||||
"AudioInjector Stereo HAT — Cirrus Logic CS5343 ADC + CS4344 DAC",
|
||||
),
|
||||
"pcm1808_pcm5102": (
|
||||
"dtoverlay=audiosense-pi",
|
||||
0,
|
||||
"PCM1808 ADC + PCM5102 DAC breakout combo (budget, ~$12)",
|
||||
),
|
||||
"iqaudio_codec": (
|
||||
"dtoverlay=iqaudio-codec",
|
||||
0,
|
||||
"IQaudio Codec Zero — ADC+DAC, 48 kHz max (BCKL limitation)",
|
||||
),
|
||||
"justboom": (
|
||||
"dtoverlay=justboom-dac",
|
||||
0,
|
||||
"JustBoom DAC/ADC HAT — full 192 kHz/24-bit",
|
||||
),
|
||||
"wm8731": (
|
||||
"dtoverlay=wm8731",
|
||||
0,
|
||||
"Waveshare / PHAT DAC — WM8731 codec, 48 kHz",
|
||||
),
|
||||
}
|
||||
|
||||
# ── JACK latency profiles ─────────────────────────────────────────
|
||||
# 48 kHz / 128 frames = 2.67 ms buffer → well under 10 ms RT
|
||||
# 48 kHz / 64 frames = 1.33 ms buffer → aggressive, may xrun on RPi 4B
|
||||
LATENCY_PROFILES: dict[str, dict] = {
|
||||
"standard": {
|
||||
"period": 128,
|
||||
"nperiods": 2,
|
||||
"rate": 48000,
|
||||
"rt_priority": 70,
|
||||
},
|
||||
"low": {
|
||||
"period": 64,
|
||||
"nperiods": 2,
|
||||
"rate": 48000,
|
||||
"rt_priority": 80,
|
||||
},
|
||||
}
|
||||
|
||||
# ── System paths ──────────────────────────────────────────────────
|
||||
CONFIG_TXT = Path("/boot/config.txt")
|
||||
JACK_SERVICE_PATH = Path("/etc/systemd/system/jackd.service")
|
||||
LIMITS_CONF = Path("/etc/security/limits.d/99-audio.conf")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
"""Audio system configuration.
|
||||
|
||||
Attributes:
|
||||
hat_type: I2S HAT type key from I2S_CONFIGS.
|
||||
profile: Latency profile key from LATENCY_PROFILES.
|
||||
input_device: ALSA hardware device for capture (e.g. hw:0,0).
|
||||
output_device: ALSA hardware device for playback (e.g. hw:0,0).
|
||||
jack_enabled: Whether to start the JACK server.
|
||||
auto_connect: Auto-connect JACK capture→FX pipeline→playback ports.
|
||||
xrun_warn_only: If True, log xruns instead of restarting JACK.
|
||||
"""
|
||||
|
||||
hat_type: str = "audioinjector"
|
||||
profile: str = "standard"
|
||||
input_device: str = "hw:0,0"
|
||||
output_device: str = "hw:0,0"
|
||||
jack_enabled: bool = True
|
||||
auto_connect: bool = True
|
||||
xrun_warn_only: bool = True
|
||||
|
||||
@property
|
||||
def latency_profile(self) -> dict:
|
||||
"""Resolve the latency profile dict."""
|
||||
p = LATENCY_PROFILES.get(self.profile)
|
||||
if p is None:
|
||||
logger.warning("Unknown profile %r, falling back to standard", self.profile)
|
||||
return LATENCY_PROFILES["standard"]
|
||||
return p
|
||||
|
||||
@property
|
||||
def overlay_line(self) -> Optional[str]:
|
||||
"""Get the dtoverlay line for the configured HAT, or None."""
|
||||
entry = I2S_CONFIGS.get(self.hat_type)
|
||||
if entry is None:
|
||||
logger.warning("Unknown HAT type %r", self.hat_type)
|
||||
return None
|
||||
return entry[0]
|
||||
|
||||
@property
|
||||
def jack_device_arg(self) -> str:
|
||||
"""JACK ALSA device argument (output device drives JACK master)."""
|
||||
return f"d{self.output_device}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Audio system manager
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class AudioSystem:
|
||||
"""Manages the audio subsystem: I2S, ALSA, and JACK.
|
||||
|
||||
Usage:
|
||||
sys = AudioSystem(AudioConfig(hat_type="audioinjector"))
|
||||
sys.setup_i2s()
|
||||
sys.start_jack()
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[AudioConfig] = None) -> None:
|
||||
self.config = config or AudioConfig()
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# I2S overlay management
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def setup_i2s(self, reboot_hint: bool = True) -> bool:
|
||||
"""Verify the I2S overlay is present in config.txt.
|
||||
|
||||
If missing, appends the line (dry-run unless run as root).
|
||||
|
||||
Args:
|
||||
reboot_hint: If True, log a message that a reboot is needed.
|
||||
|
||||
Returns:
|
||||
True if overlay is already active or was successfully added.
|
||||
"""
|
||||
overlay = self.config.overlay_line
|
||||
if overlay is None:
|
||||
return False
|
||||
|
||||
# Check if it exists
|
||||
try:
|
||||
txt = CONFIG_TXT.read_text()
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.error("Cannot read %s: %s", CONFIG_TXT, exc)
|
||||
return False
|
||||
|
||||
if overlay in txt:
|
||||
logger.info("I2S overlay already enabled: %s", overlay)
|
||||
return True
|
||||
|
||||
# Append
|
||||
try:
|
||||
with CONFIG_TXT.open("a") as f:
|
||||
f.write(f"\n# Pi Multi-FX Pedal — {self.config.hat_type}\n{overlay}\n")
|
||||
logger.info("Appended %s to %s", overlay, CONFIG_TXT)
|
||||
except PermissionError:
|
||||
logger.warning(
|
||||
"Need root to edit %s. "
|
||||
"Run: echo '%s' | sudo tee -a %s",
|
||||
CONFIG_TXT, overlay, CONFIG_TXT,
|
||||
)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.error("Failed to write %s: %s", CONFIG_TXT, exc)
|
||||
return False
|
||||
|
||||
if reboot_hint:
|
||||
logger.info("Reboot required for I2S overlay to take effect")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_active_overlay() -> Optional[str]:
|
||||
"""Detect which I2S overlay is currently set in config.txt.
|
||||
|
||||
Returns the overlay line if found, or None.
|
||||
"""
|
||||
try:
|
||||
txt = CONFIG_TXT.read_text()
|
||||
except OSError:
|
||||
return None
|
||||
for match in re.finditer(r"^dtoverlay=(.+)", txt, re.MULTILINE):
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# JACK server lifecycle
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def start_jack(self, timeout: int = 10) -> bool:
|
||||
"""Start the JACK audio server with optimal settings.
|
||||
|
||||
Blocks up to *timeout* seconds waiting for JACK to report ready.
|
||||
|
||||
Args:
|
||||
timeout: Max seconds to wait for JACK to start.
|
||||
|
||||
Returns:
|
||||
True if JACK is running.
|
||||
"""
|
||||
if not self.config.jack_enabled:
|
||||
logger.info("JACK disabled in config")
|
||||
return False
|
||||
|
||||
# Already running?
|
||||
if _jack_is_running():
|
||||
logger.info("JACK already running")
|
||||
return True
|
||||
|
||||
profile = self.config.latency_profile
|
||||
cmd = [
|
||||
"jackd",
|
||||
f"-P{profile['rt_priority']}",
|
||||
f"-p{profile['period']}",
|
||||
f"-n{profile['nperiods']}",
|
||||
f"-r{profile['rate']}",
|
||||
"-dalsa",
|
||||
f"-d{self.config.output_device}",
|
||||
f"-i2", f"-o2",
|
||||
]
|
||||
logger.info("Starting JACK: %s", " ".join(cmd))
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("jackd not found — install jackd2")
|
||||
return False
|
||||
|
||||
# Wait for readiness via jack_wait
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.3)
|
||||
if _jack_is_running():
|
||||
logger.info(
|
||||
"JACK started: period=%d, nperiods=%d, rate=%d",
|
||||
profile["period"], profile["nperiods"], profile["rate"],
|
||||
)
|
||||
# Auto-connect ports if enabled
|
||||
if self.config.auto_connect:
|
||||
self.connect_fx_ports()
|
||||
return True
|
||||
|
||||
# Timed out — check for common issues
|
||||
poll = proc.poll()
|
||||
if poll is not None:
|
||||
logger.error("jackd exited early with code %d", poll)
|
||||
else:
|
||||
logger.error("JACK failed to become ready within %ds", timeout)
|
||||
proc.kill()
|
||||
return False
|
||||
|
||||
def stop_jack(self) -> None:
|
||||
"""Gracefully stop the JACK server."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["killall", "jackd"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
logger.info("JACK stopped")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("JACK stop timed out — sending SIGKILL")
|
||||
subprocess.run(["killall", "-9", "jackd"], capture_output=True)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def restart_jack(self, timeout: int = 10) -> bool:
|
||||
"""Restart JACK server."""
|
||||
self.stop_jack()
|
||||
time.sleep(0.5)
|
||||
return self.start_jack(timeout=timeout)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# JACK port connections
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def connect_fx_ports(self) -> None:
|
||||
"""Connect JACK ports for the FX pipeline.
|
||||
|
||||
Default wiring:
|
||||
system:capture_1 → fx_in:input_0 (guitar → FX chain)
|
||||
fx_out:output_0 → system:playback_1 (FX chain → output)
|
||||
|
||||
This is a no-op if jack_connect is unavailable (not in PATH)
|
||||
or if any of the target ports don't exist yet.
|
||||
"""
|
||||
connections = [
|
||||
("system:capture_1", "fx_in:input_0"),
|
||||
("fx_out:output_0", "system:playback_1"),
|
||||
]
|
||||
for src, dst in connections:
|
||||
try:
|
||||
subprocess.run(
|
||||
["jack_connect", src, dst],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
logger.debug("Connected %s → %s", src, dst)
|
||||
except FileNotFoundError:
|
||||
logger.warning("jack_connect not found — skipping port connections")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout connecting %s → %s", src, dst)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# ALSA device listing
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def list_devices() -> list[dict]:
|
||||
"""List available ALSA audio devices with structured info.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: card, device, name, type, description.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["aplay", "-l"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
for line in result.stdout.splitlines():
|
||||
m = re.match(
|
||||
r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]",
|
||||
line,
|
||||
)
|
||||
if m:
|
||||
devices.append({
|
||||
"card": int(m.group(1)),
|
||||
"device": int(m.group(3)),
|
||||
"short_name": m.group(2).strip(),
|
||||
"full_name": m.group(4).strip(),
|
||||
"type": "playback",
|
||||
})
|
||||
|
||||
# Also capture devices
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["arecord", "-l"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
for line in result.stdout.splitlines():
|
||||
m = re.match(
|
||||
r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]",
|
||||
line,
|
||||
)
|
||||
if m:
|
||||
devices.append({
|
||||
"card": int(m.group(1)),
|
||||
"device": int(m.group(3)),
|
||||
"short_name": m.group(2).strip(),
|
||||
"full_name": m.group(4).strip(),
|
||||
"type": "capture",
|
||||
})
|
||||
|
||||
return devices
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# XRun monitoring
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def read_xrun_count() -> Optional[int]:
|
||||
"""Read JACK xrun counter from jack_showtime.
|
||||
|
||||
Returns:
|
||||
Number of xruns since JACK started, or None if unavailable.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jack_showtime", "-c"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
m = re.search(r"xruns\s*=\s*(\d+)", result.stdout)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
def monitor_xruns(self, duration: int = 300, interval: int = 10) -> dict:
|
||||
"""Monitor xruns over a test period.
|
||||
|
||||
Args:
|
||||
duration: Test duration in seconds (default 300 = 5 min).
|
||||
interval: Poll interval in seconds.
|
||||
|
||||
Returns:
|
||||
Dict with xrun_total, xrun_rate_per_min, duration, stable.
|
||||
"""
|
||||
logger.info("Starting xrun monitor: duration=%ds, interval=%ds", duration, interval)
|
||||
|
||||
start_xruns = self.read_xrun_count()
|
||||
if start_xruns is None:
|
||||
logger.warning("Cannot read xrun count — jack_showtime not available")
|
||||
return {"xrun_total": None, "xrun_rate_per_min": None, "duration": duration, "stable": None}
|
||||
|
||||
deadline = time.monotonic() + duration
|
||||
last_val = start_xruns
|
||||
peak = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(interval)
|
||||
val = self.read_xrun_count()
|
||||
if val is None:
|
||||
continue
|
||||
delta = val - last_val
|
||||
if delta > 0:
|
||||
logger.warning("XRUN detected: +%d (total: %d)", delta, val)
|
||||
peak = max(peak, delta)
|
||||
last_val = val
|
||||
|
||||
total = last_val - start_xruns
|
||||
rate = total / (duration / 60.0)
|
||||
stable = total == 0
|
||||
logger.info(
|
||||
"XRun monitor complete: %d total (%.2f/min), stable=%s",
|
||||
total, rate, stable,
|
||||
)
|
||||
return {
|
||||
"xrun_total": total,
|
||||
"xrun_rate_per_min": round(rate, 2),
|
||||
"duration": duration,
|
||||
"stable": stable,
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Round-trip latency measurement
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def measure_roundtrip_latency(samples: int = 8, timeout: int = 30) -> Optional[float]:
|
||||
"""Measure JACK round-trip latency using jack_iodelay.
|
||||
|
||||
Args:
|
||||
samples: Number of measurements to take.
|
||||
timeout: Max seconds per measurement.
|
||||
|
||||
Returns:
|
||||
Average round-trip latency in milliseconds, or None on failure.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["jack_iodelay", "--help"],
|
||||
capture_output=True, timeout=3,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning("jack_iodelay not found — install jack-tools")
|
||||
return None
|
||||
|
||||
latencies: list[float] = []
|
||||
for i in range(samples):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jack_iodelay"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
m = re.search(r"round.trip\s+latency[:\s]+([\d.]+)\s*ms", result.stdout, re.IGNORECASE)
|
||||
if m:
|
||||
val = float(m.group(1))
|
||||
latencies.append(val)
|
||||
logger.info("Latency measurement %d/%d: %.2f ms", i + 1, samples, val)
|
||||
else:
|
||||
logger.debug("jack_iodelay output did not match: %s", result.stdout[:200])
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Latency measurement %d timed out", i + 1)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("Latency measurement %d failed: %s", i + 1, exc)
|
||||
|
||||
if not latencies:
|
||||
logger.error("No valid latency measurements")
|
||||
return None
|
||||
|
||||
avg = sum(latencies) / len(latencies)
|
||||
logger.info(
|
||||
"Round-trip latency: avg=%.2f ms, min=%.2f ms, max=%.2f ms (n=%d)",
|
||||
avg, min(latencies), max(latencies), len(latencies),
|
||||
)
|
||||
return avg
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Systemd service management
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def systemd_service_content(config: AudioConfig) -> str:
|
||||
"""Generate a systemd unit file for JACK.
|
||||
|
||||
Args:
|
||||
config: Audio configuration for the service parameters.
|
||||
|
||||
Returns:
|
||||
Systemd unit file content as a string.
|
||||
"""
|
||||
profile = config.latency_profile
|
||||
exec_start = (
|
||||
f"/usr/bin/jackd -P{profile['rt_priority']} "
|
||||
f"-p{profile['period']} -n{profile['nperiods']} "
|
||||
f"-r{profile['rate']} -dalsa -d{config.output_device} -i2 -o2"
|
||||
)
|
||||
return f"""[Unit]
|
||||
Description=JACK Audio Server — Pi Multi-FX Pedal
|
||||
After=sound.target network.target
|
||||
Wants=multi-fx-pedal.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
ExecStart={exec_start}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitRTPRIO=95
|
||||
LimitMEMLOCK=infinity
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
def install_systemd_service(self) -> bool:
|
||||
"""Install the JACK systemd service.
|
||||
|
||||
Requires root.
|
||||
|
||||
Returns:
|
||||
True if installed successfully.
|
||||
"""
|
||||
content = self.systemd_service_content(self.config)
|
||||
try:
|
||||
JACK_SERVICE_PATH.write_text(content)
|
||||
subprocess.run(
|
||||
["systemctl", "daemon-reload"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
logger.info("Installed systemd service: %s", JACK_SERVICE_PATH)
|
||||
return True
|
||||
except PermissionError:
|
||||
logger.warning(
|
||||
"Need root to install systemd service. "
|
||||
"Run setup_audio.sh as root, or manually: "
|
||||
"sudo cp the unit file to %s && sudo systemctl daemon-reload",
|
||||
JACK_SERVICE_PATH,
|
||||
)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.error("Failed to install service: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Internal helpers
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _jack_is_running() -> bool:
|
||||
"""Check if JACK is running via jack_wait."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jack_wait", "-c"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Pi Multi-FX Pedal — Hardware UI layer."""
|
||||
from src.ui.footswitch import (
|
||||
DEBOUNCE_MS,
|
||||
LONG_PRESS_MS,
|
||||
FootSwitch,
|
||||
FootswitchController,
|
||||
SwitchAction,
|
||||
)
|
||||
from src.ui.leds import (
|
||||
LEDAnimation,
|
||||
LEDConfig,
|
||||
LEDController,
|
||||
LEDDriver,
|
||||
LEDPattern,
|
||||
)
|
||||
from src.ui.display import (
|
||||
DISPLAY_H,
|
||||
DISPLAY_W,
|
||||
DisplayController,
|
||||
DisplayState,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# footswitch
|
||||
"DEBOUNCE_MS",
|
||||
"LONG_PRESS_MS",
|
||||
"FootSwitch",
|
||||
"FootswitchController",
|
||||
"SwitchAction",
|
||||
# LEDs
|
||||
"LEDAnimation",
|
||||
"LEDConfig",
|
||||
"LEDController",
|
||||
"LEDDriver",
|
||||
"LEDPattern",
|
||||
# display
|
||||
"DISPLAY_H",
|
||||
"DISPLAY_W",
|
||||
"DisplayController",
|
||||
"DisplayState",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
"""OLED display manager for the Pi Multi-FX Pedal.
|
||||
|
||||
Controls a 128x64 SSD1306 OLED via I2C to show:
|
||||
- Current preset name, bank, and status
|
||||
- Bypass status and tuner mode
|
||||
- Active FX chain with per-block status
|
||||
- Parameter values on edit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Display dimensions
|
||||
DISPLAY_W = 128
|
||||
DISPLAY_H = 64
|
||||
|
||||
# Layout constants
|
||||
MARGIN = 2
|
||||
LINE_H = 10 # 8px font + 2px spacing
|
||||
HEADER_H = 10 # Top status bar height
|
||||
FOOTER_Y = 56 # Bottom status line Y
|
||||
|
||||
# Font sizes for PIL
|
||||
FONT_SMALL = 8
|
||||
FONT_NORMAL = 10
|
||||
FONT_LARGE = 16
|
||||
|
||||
# Tuner display geometry
|
||||
TUNER_CENTER_X = 64
|
||||
TUNER_CENTER_Y = 32
|
||||
TUNER_NOTE_RADIUS = 20
|
||||
TUNER_NOTE_FONT = 24
|
||||
TUNER_CENT_FONT = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayState:
|
||||
"""What to show on the display."""
|
||||
mode: str = "preset" # preset, tuner, fx_edit, settings
|
||||
preset_name: str = ""
|
||||
bank_name: str = ""
|
||||
bypassed: bool = False
|
||||
bypass_led_state: bool = False
|
||||
fx_active: list[str] = field(default_factory=list)
|
||||
fx_bypass_states: dict[str, bool] = field(default_factory=dict)
|
||||
tuner_note: str = ""
|
||||
tuner_cents: int = 0
|
||||
param_name: str = ""
|
||||
param_value: float = 0.0
|
||||
|
||||
|
||||
def _import_display_driver():
|
||||
"""Import the SSD1306 display library gracefully."""
|
||||
try:
|
||||
import board
|
||||
import busio
|
||||
import adafruit_ssd1306
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
return (adafruit_ssd1306, Image, ImageDraw, ImageFont)
|
||||
except (ImportError, NotImplementedError):
|
||||
return None
|
||||
|
||||
|
||||
class DisplayController:
|
||||
"""Manages the OLED display.
|
||||
|
||||
In production, uses adafruit-circuitpython-ssd1306 over I2C with PIL.
|
||||
In dev/testing, logs display output.
|
||||
"""
|
||||
|
||||
def __init__(self, i2c_bus: int = 1, i2c_addr: int = 0x3C):
|
||||
self._i2c_bus = i2c_bus
|
||||
self._i2c_addr = i2c_addr
|
||||
self._state = DisplayState()
|
||||
self._initialized = False
|
||||
|
||||
# Hardware handles
|
||||
self._display = None
|
||||
self._image: Optional["Image"] = None
|
||||
self._draw: Optional["ImageDraw"] = None
|
||||
self._fonts: dict[str, "ImageFont"] = {}
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""Initialize the OLED display hardware.
|
||||
|
||||
Returns False if no display is connected (dev mode).
|
||||
"""
|
||||
try:
|
||||
driver_pkg = _import_display_driver()
|
||||
if driver_pkg is None:
|
||||
self._initialized = False
|
||||
logger.info("Display libraries not available — running in headless mode")
|
||||
return False
|
||||
|
||||
adafruit_ssd1306, Image, ImageDraw, ImageFont = driver_pkg
|
||||
|
||||
import board
|
||||
import busio
|
||||
|
||||
i2c = busio.I2C(board.SCL, board.SDA)
|
||||
self._display = adafruit_ssd1306.SSD1306_I2C(
|
||||
DISPLAY_W, DISPLAY_H, i2c, addr=self._i2c_addr
|
||||
)
|
||||
|
||||
# Create an image buffer
|
||||
self._image = Image.new("1", (DISPLAY_W, DISPLAY_H))
|
||||
self._draw = ImageDraw.Draw(self._image)
|
||||
|
||||
# Load default font — use PIL default bitmap font
|
||||
self._fonts["small"] = ImageFont.load_default()
|
||||
self._fonts["normal"] = ImageFont.load_default()
|
||||
self._fonts["large"] = ImageFont.load_default()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Display initialized (128x64 OLED @ 0x%02x)", self._i2c_addr)
|
||||
|
||||
# Show splash
|
||||
self._splash()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.info("No display detected — running in headless mode: %s", e)
|
||||
self._initialized = False
|
||||
return False
|
||||
|
||||
def update(self, state: DisplayState) -> None:
|
||||
"""Update the display with new state and re-render."""
|
||||
self._state = state
|
||||
if not self._initialized:
|
||||
logger.debug(
|
||||
"Display [%s]: %s | bypass=%s | FX=%s | tuner=%s %+d",
|
||||
state.mode,
|
||||
state.preset_name or state.bank_name or "",
|
||||
"BYP" if state.bypassed else "ON",
|
||||
", ".join(state.fx_active) or "—",
|
||||
state.tuner_note or "—",
|
||||
state.tuner_cents,
|
||||
)
|
||||
else:
|
||||
self._render()
|
||||
|
||||
def _render(self) -> None:
|
||||
"""Render current state to the OLED using the PIL buffer."""
|
||||
if not self._initialized or self._draw is None or self._image is None:
|
||||
return
|
||||
|
||||
draw = self._draw
|
||||
img = self._image
|
||||
|
||||
# Clear the buffer (white background — SSD1306 white=1)
|
||||
draw.rectangle((0, 0, DISPLAY_W - 1, DISPLAY_H - 1), fill=0, outline=0)
|
||||
|
||||
if self._state.mode == "tuner":
|
||||
self._render_tuner(draw)
|
||||
elif self._state.mode == "preset":
|
||||
self._render_preset(draw)
|
||||
elif self._state.mode == "fx_edit":
|
||||
self._render_fx_edit(draw)
|
||||
elif self._state.mode == "settings":
|
||||
self._render_settings(draw)
|
||||
else:
|
||||
self._render_preset(draw)
|
||||
|
||||
# Blit to hardware
|
||||
self._display.image(img)
|
||||
self._display.show()
|
||||
|
||||
# --- Rendering modes ---
|
||||
|
||||
def _render_preset(self, draw) -> None:
|
||||
"""Render preset mode layout."""
|
||||
s = self._state
|
||||
font = self._fonts.get("normal")
|
||||
|
||||
# Header bar — bank name
|
||||
bank_text = f"[{s.bank_name}]" if s.bank_name else ""
|
||||
draw.text((MARGIN, MARGIN), bank_text, fill=255, font=font)
|
||||
|
||||
# Bypass indicator top-right
|
||||
bypass_text = "BYP" if s.bypassed else "ACTIVE"
|
||||
bypass_x = DISPLAY_W - MARGIN - (len(bypass_text) * 6)
|
||||
draw.text((bypass_x, MARGIN), bypass_text, fill=255, font=font)
|
||||
|
||||
# Preset name — large, centered-ish
|
||||
preset_y = HEADER_H + 4
|
||||
draw.text((MARGIN, preset_y), s.preset_name or "—", fill=255, font=self._fonts.get("large"))
|
||||
|
||||
# Active FX chain (wrap if needed)
|
||||
fx_y = preset_y + 20
|
||||
if s.fx_active:
|
||||
parts = []
|
||||
for fx in s.fx_active:
|
||||
bypassed = s.fx_bypass_states.get(fx, False)
|
||||
if bypassed:
|
||||
parts.append(f"[{fx}]")
|
||||
else:
|
||||
parts.append(fx)
|
||||
fx_line = " ".join(parts)
|
||||
# Truncate to fit display width
|
||||
max_chars = DISPLAY_W // 6
|
||||
if len(fx_line) > max_chars:
|
||||
fx_line = fx_line[:max_chars - 3] + "..."
|
||||
draw.text((MARGIN, fx_y), fx_line, fill=255, font=font)
|
||||
|
||||
# Footer — preset number or info
|
||||
draw.text((MARGIN, FOOTER_Y), s.mode.upper(), fill=255, font=font)
|
||||
|
||||
def _render_tuner(self, draw) -> None:
|
||||
"""Render tuner mode — note name + cents indicator."""
|
||||
s = self._state
|
||||
font_normal = self._fonts.get("normal")
|
||||
|
||||
# "TUNER" header
|
||||
draw.text((MARGIN, MARGIN), "TUNER", fill=255, font=font_normal)
|
||||
|
||||
# Large note name in center
|
||||
note = s.tuner_note or "--"
|
||||
draw.text(
|
||||
(TUNER_CENTER_X - len(note) * 7, TUNER_CENTER_Y - 8),
|
||||
note,
|
||||
fill=255,
|
||||
font=self._fonts.get("large"),
|
||||
)
|
||||
|
||||
# Cents indicator bar
|
||||
cents = max(-50, min(50, s.tuner_cents))
|
||||
bar_center_x = TUNER_CENTER_X
|
||||
bar_y = TUNER_CENTER_Y + 16
|
||||
bar_w = 40
|
||||
bar_h = 4
|
||||
|
||||
# Background bar
|
||||
draw.rectangle(
|
||||
(bar_center_x - bar_w // 2, bar_y, bar_center_x + bar_w // 2, bar_y + bar_h),
|
||||
fill=0, outline=255,
|
||||
)
|
||||
|
||||
# Indicator position
|
||||
pos = int((cents + 50) / 100 * bar_w) - bar_w // 2
|
||||
indicator_x = bar_center_x + pos
|
||||
draw.rectangle(
|
||||
(indicator_x - 2, bar_y - 1, indicator_x + 2, bar_y + bar_h + 1),
|
||||
fill=255,
|
||||
)
|
||||
|
||||
# Cents text
|
||||
draw.text(
|
||||
(MARGIN, FOOTER_Y),
|
||||
f"{cents:+d} cents",
|
||||
fill=255,
|
||||
font=font_normal,
|
||||
)
|
||||
|
||||
def _render_fx_edit(self, draw) -> None:
|
||||
"""Render FX parameter edit mode."""
|
||||
s = self._state
|
||||
font = self._fonts.get("normal")
|
||||
|
||||
draw.text((MARGIN, MARGIN), f"EDIT: {s.param_name}", fill=255, font=font)
|
||||
|
||||
# Parameter value bar
|
||||
val = max(0.0, min(1.0, s.param_value))
|
||||
bar_x = MARGIN
|
||||
bar_y = 20
|
||||
bar_w = DISPLAY_W - 2 * MARGIN
|
||||
bar_h = 8
|
||||
|
||||
draw.rectangle((bar_x, bar_y, bar_x + bar_w, bar_y + bar_h), fill=0, outline=255)
|
||||
fill_w = int(bar_w * val)
|
||||
draw.rectangle((bar_x, bar_y, bar_x + fill_w, bar_y + bar_h), fill=255)
|
||||
|
||||
# Value text
|
||||
draw.text((MARGIN, bar_y + 12), f"{val:.2f}", fill=255, font=font)
|
||||
|
||||
def _render_settings(self, draw) -> None:
|
||||
"""Render settings mode."""
|
||||
s = self._state
|
||||
font = self._fonts.get("normal")
|
||||
draw.text((MARGIN, MARGIN), "SETTINGS", fill=255, font=font)
|
||||
draw.text((MARGIN, 20), s.param_name or "", fill=255, font=font)
|
||||
|
||||
def _splash(self) -> None:
|
||||
"""Show boot splash screen."""
|
||||
if not self._initialized or self._draw is None or self._image is None:
|
||||
return
|
||||
draw = self._draw
|
||||
draw.rectangle((0, 0, DISPLAY_W - 1, DISPLAY_H - 1), fill=0)
|
||||
draw.text((24, 24), "Pi Multi-FX", fill=255, font=self._fonts.get("large"))
|
||||
draw.text((28, 44), "Booting...", fill=255, font=self._fonts.get("normal"))
|
||||
self._display.image(self._image)
|
||||
self._display.show()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the display."""
|
||||
if self._initialized and self._display is not None:
|
||||
self._display.fill(0)
|
||||
self._display.show()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Footswitch controller — debounced GPIO input for stomp switches.
|
||||
|
||||
Handles multiple momentary footswitches with debouncing,
|
||||
long-press detection, and mode switching.
|
||||
|
||||
Typical layout:
|
||||
[FS1] Preset Up / Tap Tempo [FS2] Preset Down / Hold for Tuner
|
||||
[FS3] Bypass [FS4] Bank Up
|
||||
[FS5] FX Select [FS6] Tap Tempo
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEBOUNCE_MS = 20 # Debounce window
|
||||
LONG_PRESS_MS = 500 # Long press threshold
|
||||
POLL_INTERVAL_S = 0.005 # 5ms poll for responsiveness
|
||||
|
||||
|
||||
class SwitchAction(Enum):
|
||||
"""Actions triggered by footswitch events."""
|
||||
PRESET_UP = "preset_up"
|
||||
PRESET_DOWN = "preset_down"
|
||||
BANK_UP = "bank_up"
|
||||
BANK_DOWN = "bank_down"
|
||||
BYPASS = "bypass"
|
||||
TAP_TEMPO = "tap_tempo"
|
||||
TUNER = "tuner"
|
||||
FX_PREV = "fx_prev"
|
||||
FX_NEXT = "fx_next"
|
||||
EXPRESSION_TOGGLE = "expression_toggle"
|
||||
MIDI_LEARN = "midi_learn"
|
||||
SNAPSHOT_SAVE = "snapshot_save"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FootSwitch:
|
||||
"""State of a single footswitch."""
|
||||
gpio_pin: int
|
||||
action_default: SwitchAction
|
||||
action_long_press: Optional[SwitchAction] = None
|
||||
active_low: bool = True
|
||||
|
||||
|
||||
def _import_gpio():
|
||||
"""Import RPi.GPIO gracefully — returns None on non-RPi platforms."""
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
return GPIO
|
||||
except (ImportError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
class FootswitchController:
|
||||
"""Debounced footswitch input monitor.
|
||||
|
||||
In production, this reads RPi.GPIO. In testing/dev,
|
||||
it uses a virtual pin store that can be driven by simulate_press.
|
||||
"""
|
||||
|
||||
def __init__(self, switches: list[FootSwitch] | None = None):
|
||||
self._switches = switches or self._default_layout()
|
||||
self._callbacks: dict[SwitchAction, list[Callable]] = {}
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._gpio = _import_gpio()
|
||||
|
||||
# Per-pin state tracking — maps gpio_pin -> pin state
|
||||
self._pin_tracker: dict[int, _PinState] = {}
|
||||
|
||||
for sw in self._switches:
|
||||
self._pin_tracker[sw.gpio_pin] = _PinState()
|
||||
|
||||
def _default_layout(self) -> list[FootSwitch]:
|
||||
"""4-switch default layout."""
|
||||
return [
|
||||
FootSwitch(17, SwitchAction.PRESET_UP, SwitchAction.TAP_TEMPO),
|
||||
FootSwitch(27, SwitchAction.PRESET_DOWN, SwitchAction.TUNER),
|
||||
FootSwitch(22, SwitchAction.BYPASS, SwitchAction.SNAPSHOT_SAVE),
|
||||
FootSwitch(23, SwitchAction.BANK_UP, SwitchAction.BANK_DOWN),
|
||||
]
|
||||
|
||||
def register_callback(self, action: SwitchAction, callback: Callable) -> None:
|
||||
"""Register a callback for a switch action."""
|
||||
self._callbacks.setdefault(action, []).append(callback)
|
||||
|
||||
def _trigger(self, action: SwitchAction) -> None:
|
||||
"""Fire callbacks for an action."""
|
||||
for cb in self._callbacks.get(action, []):
|
||||
try:
|
||||
cb()
|
||||
except Exception as e:
|
||||
logger.error("Switch callback error for %s: %s", action.value, e)
|
||||
|
||||
# --- GPIO abstraction layer ---
|
||||
|
||||
def _read_pin(self, pin: int) -> bool:
|
||||
"""Read a GPIO pin.
|
||||
|
||||
Returns True = pressed, False = released.
|
||||
If RPi.GPIO is unavailable, reads from a virtual store (for testing).
|
||||
"""
|
||||
if self._gpio:
|
||||
raw = self._gpio.input(pin)
|
||||
# Find which switch maps to this pin so we can invert if active_low
|
||||
for sw in self._switches:
|
||||
if sw.gpio_pin == pin:
|
||||
return not raw if sw.active_low else bool(raw)
|
||||
return bool(raw)
|
||||
else:
|
||||
return self._pin_tracker[pin].virtual_level
|
||||
|
||||
def _setup_pins(self) -> None:
|
||||
"""Configure GPIO pins as inputs with pull-up/down."""
|
||||
if not self._gpio:
|
||||
logger.info("No RPi.GPIO — running in virtual (test) mode")
|
||||
return
|
||||
|
||||
self._gpio.setmode(self._gpio.BCM)
|
||||
for sw in self._switches:
|
||||
pull = self._gpio.PUD_UP if sw.active_low else self._gpio.PUD_DOWN
|
||||
self._gpio.setup(sw.gpio_pin, self._gpio.IN, pull_up_down=pull)
|
||||
|
||||
def _cleanup_pins(self) -> None:
|
||||
"""Release GPIO pin configuration."""
|
||||
if self._gpio:
|
||||
self._gpio.cleanup()
|
||||
|
||||
# --- Debounce engine ---
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Background thread: poll pins with debounce and long-press detection."""
|
||||
last_logged_state: dict[int, bool] = {}
|
||||
|
||||
for sw in self._switches:
|
||||
last_logged_state[sw.gpio_pin] = False
|
||||
|
||||
while self._running:
|
||||
now_ms = time.monotonic() * 1000
|
||||
|
||||
for sw in self._switches:
|
||||
pin = sw.gpio_pin
|
||||
tracker = self._pin_tracker[pin]
|
||||
raw = self._read_pin(pin)
|
||||
|
||||
# --- Debounce ---
|
||||
if raw != tracker.unstable_level:
|
||||
tracker.unstable_level = raw
|
||||
tracker.last_change_ms = now_ms
|
||||
continue # Changed — wait for debounce window
|
||||
|
||||
# Stable across debounce window?
|
||||
elapsed = now_ms - tracker.last_change_ms
|
||||
if elapsed < DEBOUNCE_MS:
|
||||
continue # Still within debounce window
|
||||
|
||||
# Stable and beyond debounce window — commit stable state
|
||||
if raw != tracker.stable_level:
|
||||
tracker.stable_level = raw
|
||||
logger.debug("Pin %d debounced: %s", pin, "PRESSED" if raw else "released")
|
||||
|
||||
if raw: # Just pressed
|
||||
tracker.press_start_ms = now_ms
|
||||
else: # Just released — check for short vs long press
|
||||
press_duration = now_ms - tracker.press_start_ms
|
||||
if press_duration >= LONG_PRESS_MS and sw.action_long_press:
|
||||
logger.debug("Pin %d LONG press (%dms) → %s", pin, int(press_duration), sw.action_long_press.value)
|
||||
self._trigger(sw.action_long_press)
|
||||
tracker.long_press_handled = True
|
||||
elif press_duration >= LONG_PRESS_MS and not sw.action_long_press:
|
||||
# No long-press action mapped — fall through to default
|
||||
logger.debug("Pin %d long press, no action mapped — triggering default", pin)
|
||||
self._trigger(sw.action_default)
|
||||
else:
|
||||
logger.debug("Pin %d short press (%dms) → %s", pin, int(press_duration), sw.action_default.value)
|
||||
self._trigger(sw.action_default)
|
||||
tracker.long_press_handled = False
|
||||
|
||||
# If still pressed and past long-press threshold but no release yet
|
||||
# — don't repeatedly fire, just mark it
|
||||
if raw and tracker.stable_level and not tracker.long_press_handled:
|
||||
press_duration = now_ms - tracker.press_start_ms
|
||||
if press_duration >= LONG_PRESS_MS and sw.action_long_press:
|
||||
logger.debug("Pin %d LONG press triggered (no release needed)", pin)
|
||||
self._trigger(sw.action_long_press)
|
||||
tracker.long_press_handled = True
|
||||
|
||||
# Reset long_press_handled when released
|
||||
if not raw:
|
||||
tracker.long_press_handled = False
|
||||
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
|
||||
# --- Lifecycle ---
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start monitoring footswitches.
|
||||
|
||||
In production, starts GPIO monitoring thread.
|
||||
"""
|
||||
self._setup_pins()
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name="footswitch-poll")
|
||||
self._thread.start()
|
||||
logger.info("Footswitch controller started (%d switches)", len(self._switches))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop monitoring."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
self._cleanup_pins()
|
||||
logger.info("Footswitch controller stopped")
|
||||
|
||||
# --- Testing hooks ---
|
||||
|
||||
def simulate_press(self, action: SwitchAction) -> None:
|
||||
"""Simulate a footswitch press (for testing).
|
||||
|
||||
Directly triggers the action without going through GPIO.
|
||||
"""
|
||||
logger.debug("SIMULATED press: %s", action.value)
|
||||
self._trigger(action)
|
||||
|
||||
def simulate_gpio_change(self, pin: int, pressed: bool) -> None:
|
||||
"""Set a virtual GPIO pin level for testing the debounce engine."""
|
||||
tracker = self._pin_tracker.get(pin)
|
||||
if tracker:
|
||||
tracker.virtual_level = pressed
|
||||
|
||||
|
||||
# --- Internal state holder ---
|
||||
|
||||
@dataclass
|
||||
class _PinState:
|
||||
"""Per-pin debounce state."""
|
||||
last_change_ms: float = 0.0
|
||||
press_start_ms: float = 0.0
|
||||
unstable_level: bool = False
|
||||
stable_level: bool = False
|
||||
long_press_handled: bool = False
|
||||
virtual_level: bool = False # For testing without RPi.GPIO
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
"""RGB LED controller for the Pi Multi-FX Pedal.
|
||||
|
||||
Controls WS2812B (NeoPixel) or APA102 (DotStar) LEDs for:
|
||||
- Per-footswitch status LEDs
|
||||
- Bypass indicator (red/green)
|
||||
- Preset navigation animations
|
||||
- Tap tempo flashing
|
||||
- Configurable brightness
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LEDDriver(Enum):
|
||||
"""Which LED driver to use."""
|
||||
NEOPIXEL = "neopixel" # WS2812B — adafruit-circuitpython-neopixel
|
||||
DOTSTAR = "dotstar" # APA102 — adafruit-circuitpython-dotstar
|
||||
MOCK = "mock" # No hardware — log only
|
||||
|
||||
|
||||
class LEDPattern(Enum):
|
||||
"""Built-in LED animation patterns."""
|
||||
SOLID = "solid"
|
||||
PULSE = "pulse" # Gentle breathe
|
||||
BLINK = "blink" # On/off square wave
|
||||
TAP_TEMPO = "tap_tempo" # Flash at detected BPM
|
||||
SCAN = "scan" # Chase across strip
|
||||
PRESET_UP = "preset_up" # Sweep up
|
||||
PRESET_DOWN = "preset_down" # Sweep down
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDConfig:
|
||||
"""Per-LED configuration."""
|
||||
index: int
|
||||
default_color: tuple[int, int, int] = (0, 0, 0) # RGB
|
||||
default_brightness: float = 0.5
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDAnimation:
|
||||
"""Active animation state."""
|
||||
pattern: LEDPattern
|
||||
color: tuple[int, int, int]
|
||||
speed_ms: int = 500 # Cycle time in ms
|
||||
brightness: float = 0.5
|
||||
start_time: float = 0.0
|
||||
repeats: int = 0 # 0 = infinite
|
||||
|
||||
|
||||
def _import_driver(driver: LEDDriver):
|
||||
"""Import the correct LED driver library."""
|
||||
if driver == LEDDriver.NEOPIXEL:
|
||||
try:
|
||||
import board
|
||||
import neopixel
|
||||
return neopixel
|
||||
except (ImportError, NotImplementedError):
|
||||
logger.warning("NeoPixel not available — falling back to mock")
|
||||
return None
|
||||
elif driver == LEDDriver.DOTSTAR:
|
||||
try:
|
||||
import board
|
||||
import adafruit_dotstar as dotstar
|
||||
return dotstar
|
||||
except (ImportError, NotImplementedError):
|
||||
logger.warning("DotStar not available — falling back to mock")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class LEDController:
|
||||
"""RGB LED controller with animation support.
|
||||
|
||||
Handles per-LED animations for preset navigation, bypass status,
|
||||
tap tempo, and tuner mode.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_leds: int,
|
||||
driver: LEDDriver = LEDDriver.NEOPIXEL,
|
||||
pin: str = "D18",
|
||||
brightness: float = 0.5,
|
||||
led_configs: Optional[list[LEDConfig]] = None,
|
||||
):
|
||||
self._num_leds = num_leds
|
||||
self._driver_type = driver
|
||||
self._pin = pin
|
||||
self._global_brightness = brightness
|
||||
self._led_configs = led_configs or [
|
||||
LEDConfig(i, default_brightness=brightness) for i in range(num_leds)
|
||||
]
|
||||
|
||||
# Physical LED strip handle
|
||||
self._strip = None
|
||||
self._initialized = False
|
||||
|
||||
# Current pixel colors (RGB)
|
||||
self._pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * num_leds
|
||||
|
||||
# Active animations per LED
|
||||
self._animations: dict[int, LEDAnimation] = {}
|
||||
|
||||
# Callbacks
|
||||
self._animation_callbacks: list[Callable] = []
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""Initialize the LED strip hardware.
|
||||
|
||||
Returns False in dev mode (no hardware).
|
||||
"""
|
||||
try:
|
||||
driver_mod = _import_driver(self._driver_type)
|
||||
if driver_mod is None or self._driver_type == LEDDriver.MOCK:
|
||||
self._initialized = False
|
||||
logger.info("LED driver in MOCK mode — no hardware")
|
||||
return False
|
||||
|
||||
if self._driver_type == LEDDriver.NEOPIXEL:
|
||||
import board
|
||||
self._strip = driver_mod.NeoPixel(
|
||||
getattr(board, self._pin),
|
||||
self._num_leds,
|
||||
brightness=self._global_brightness,
|
||||
auto_write=False,
|
||||
)
|
||||
elif self._driver_type == LEDDriver.DOTSTAR:
|
||||
import board
|
||||
self._strip = driver_mod.DotStar(
|
||||
board.SCK, board.MOSI,
|
||||
self._num_leds,
|
||||
brightness=self._global_brightness,
|
||||
auto_write=False,
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
"LED strip initialized: %d LEDs, %s @ %s",
|
||||
self._num_leds, self._driver_type.value, self._pin,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.info("No LED strip detected — running in mock mode: %s", e)
|
||||
self._initialized = False
|
||||
return False
|
||||
|
||||
# --- Pixel control ---
|
||||
|
||||
def set_pixel(
|
||||
self,
|
||||
index: int,
|
||||
color: tuple[int, int, int],
|
||||
brightness: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Set a single LED to a color (0-255 per channel)."""
|
||||
if not 0 <= index < self._num_leds:
|
||||
logger.warning("LED index %d out of range (0-%d)", index, self._num_leds - 1)
|
||||
return
|
||||
|
||||
bri = brightness if brightness is not None else self._global_brightness
|
||||
r, g, b = self._clamp_color(color)
|
||||
# Apply brightness scaling
|
||||
self._pixels[index] = (int(r * bri), int(g * bri), int(b * bri))
|
||||
self._write_pixel(index)
|
||||
|
||||
def set_all(
|
||||
self,
|
||||
color: tuple[int, int, int],
|
||||
brightness: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Set all LEDs to the same color."""
|
||||
bri = brightness if brightness is not None else self._global_brightness
|
||||
r, g, b = self._clamp_color(color)
|
||||
scaled = (int(r * bri), int(g * bri), int(b * bri))
|
||||
self._pixels = [scaled] * self._num_leds
|
||||
self._write_all()
|
||||
|
||||
def set_bypass_led(self, index: int, bypassed: bool) -> None:
|
||||
"""Set a bypass indicator LED.
|
||||
|
||||
Red = bypassed, Green = active.
|
||||
"""
|
||||
if bypassed:
|
||||
self.set_pixel(index, (255, 0, 0))
|
||||
else:
|
||||
self.set_pixel(index, (0, 255, 0))
|
||||
|
||||
def preset_animate(self, direction: str = "up") -> None:
|
||||
"""Animate preset change (sweep).
|
||||
|
||||
direction: "up" → sweep left-to-right, "down" → right-to-left.
|
||||
"""
|
||||
self._animate_scan(
|
||||
color=(0, 64, 255), # Blue
|
||||
reverse=(direction == "down"),
|
||||
speed_ms=120,
|
||||
)
|
||||
|
||||
def tap_tempo_blip(self) -> None:
|
||||
"""Quick white flash indicating a tap tempo hit."""
|
||||
old = self._pixels[:]
|
||||
# Flash all LEDs white briefly
|
||||
self.set_all((255, 255, 255), brightness=0.8)
|
||||
time.sleep(0.03)
|
||||
# Restore — but don't block, schedule async
|
||||
for i in range(self._num_leds):
|
||||
self._pixels[i] = old[i]
|
||||
self._write_all()
|
||||
|
||||
def tap_tempo_animate(self, bpm: float) -> None:
|
||||
"""Start a tempo-synchronized flash on all LEDs.
|
||||
|
||||
Animates in sync with the detected BPM.
|
||||
The caller still drives the actual flash via tap_tempo_blip;
|
||||
this sets a subtle pulse background at the tempo.
|
||||
"""
|
||||
period_ms = int(60000 / max(bpm, 20)) # ms per beat
|
||||
self._animate_all(
|
||||
pattern=LEDPattern.TAP_TEMPO,
|
||||
color=(255, 255, 255),
|
||||
speed_ms=period_ms,
|
||||
brightness=0.15, # Subtle
|
||||
)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Turn off all LEDs."""
|
||||
self._pixels = [(0, 0, 0)] * self._num_leds
|
||||
self._animations.clear()
|
||||
self._write_all()
|
||||
|
||||
def set_brightness(self, brightness: float) -> None:
|
||||
"""Set global brightness (0.0 - 1.0)."""
|
||||
self._global_brightness = max(0.0, min(1.0, brightness))
|
||||
if self._strip is not None:
|
||||
self._strip.brightness = self._global_brightness
|
||||
logger.info("Global brightness set to %.2f", self._global_brightness)
|
||||
|
||||
# --- Animation engine ---
|
||||
|
||||
def _animate_all(
|
||||
self,
|
||||
pattern: LEDPattern,
|
||||
color: tuple[int, int, int],
|
||||
speed_ms: int,
|
||||
brightness: float,
|
||||
) -> None:
|
||||
"""Start an animation on all LEDs."""
|
||||
return None # Full animation tick runs on _animation_tick
|
||||
|
||||
def _animate_scan(
|
||||
self,
|
||||
color: tuple[int, int, int],
|
||||
reverse: bool = False,
|
||||
speed_ms: int = 120,
|
||||
) -> None:
|
||||
"""Run a scan animation in a separate thread (non-blocking)."""
|
||||
import threading
|
||||
|
||||
def _scan():
|
||||
r, g, b = color
|
||||
indices = range(self._num_leds)
|
||||
if reverse:
|
||||
indices = reversed(indices)
|
||||
for i in indices:
|
||||
self.set_all((0, 0, 0))
|
||||
self.set_pixel(i, (r, g, b))
|
||||
self._write_all()
|
||||
time.sleep(speed_ms / 1000)
|
||||
self.set_all((0, 0, 0))
|
||||
self._write_all()
|
||||
|
||||
t = threading.Thread(target=_scan, daemon=True, name="led-scan")
|
||||
t.start()
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
def _clamp_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]:
|
||||
"""Clamp 0-255 per channel."""
|
||||
return (
|
||||
max(0, min(255, color[0])),
|
||||
max(0, min(255, color[1])),
|
||||
max(0, min(255, color[2])),
|
||||
)
|
||||
|
||||
def _write_pixel(self, index: int) -> None:
|
||||
"""Write a single pixel to the hardware strip."""
|
||||
if self._strip is not None and self._initialized:
|
||||
self._strip[index] = self._pixels[index]
|
||||
self._strip.show()
|
||||
else:
|
||||
logger.debug("LED[%d] → RGB(%d,%d,%d)", index, *self._pixels[index])
|
||||
|
||||
def _write_all(self) -> None:
|
||||
"""Write all pixels to the hardware strip."""
|
||||
if self._strip is not None and self._initialized:
|
||||
for i in range(self._num_leds):
|
||||
self._strip[i] = self._pixels[i]
|
||||
self._strip.show()
|
||||
else:
|
||||
for i, c in enumerate(self._pixels):
|
||||
if any(v > 0 for v in c):
|
||||
logger.debug("LED[%d] → RGB(%d,%d,%d)", i, *c)
|
||||
|
||||
def __enter__(self):
|
||||
self.initialize()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.clear_all()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Tests for system/audio.py - AudioSystem, AudioConfig, and helpers."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from system.audio import (
|
||||
AudioConfig,
|
||||
AudioSystem,
|
||||
LATENCY_PROFILES,
|
||||
I2S_CONFIGS,
|
||||
CONFIG_TXT,
|
||||
JACK_SERVICE_PATH,
|
||||
LIMITS_CONF,
|
||||
)
|
||||
|
||||
|
||||
# ── AudioConfig tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_audio_config_defaults():
|
||||
cfg = AudioConfig()
|
||||
assert cfg.hat_type == "audioinjector"
|
||||
assert cfg.profile == "standard"
|
||||
assert cfg.input_device == "hw:0,0"
|
||||
assert cfg.output_device == "hw:0,0"
|
||||
assert cfg.jack_enabled is True
|
||||
assert cfg.auto_connect is True
|
||||
assert cfg.xrun_warn_only is True
|
||||
|
||||
|
||||
def test_audio_config_overlay_line():
|
||||
for key, expected_line in [
|
||||
("audioinjector", "dtoverlay=audioinjector-wm8731"),
|
||||
("pcm1808_pcm5102", "dtoverlay=audiosense-pi"),
|
||||
("iqaudio_codec", "dtoverlay=iqaudio-codec"),
|
||||
("justboom", "dtoverlay=justboom-dac"),
|
||||
("wm8731", "dtoverlay=wm8731"),
|
||||
]:
|
||||
cfg = AudioConfig(hat_type=key)
|
||||
assert cfg.overlay_line == expected_line, f"{key}: {cfg.overlay_line}"
|
||||
|
||||
|
||||
def test_audio_config_unknown_hat():
|
||||
cfg = AudioConfig(hat_type="nonexistent-hat")
|
||||
assert cfg.overlay_line is None
|
||||
|
||||
|
||||
def test_audio_config_latency_profile():
|
||||
cfg = AudioConfig(profile="standard")
|
||||
assert cfg.latency_profile["period"] == 128
|
||||
assert cfg.latency_profile["nperiods"] == 2
|
||||
assert cfg.latency_profile["rate"] == 48000
|
||||
assert cfg.latency_profile["rt_priority"] == 70
|
||||
|
||||
cfg2 = AudioConfig(profile="low")
|
||||
assert cfg2.latency_profile["period"] == 64
|
||||
assert cfg2.latency_profile["rt_priority"] == 80
|
||||
|
||||
|
||||
def test_audio_config_unknown_profile():
|
||||
cfg = AudioConfig(profile="bogus")
|
||||
# Should fall back to standard
|
||||
assert cfg.latency_profile["period"] == 128
|
||||
|
||||
|
||||
def test_audio_config_jack_device_arg():
|
||||
cfg = AudioConfig(output_device="hw:1,0")
|
||||
assert cfg.jack_device_arg == "dhw:1,0"
|
||||
|
||||
|
||||
# ── Latency buffer time computation ───────────────────────────────
|
||||
|
||||
|
||||
def test_latency_profiles_buffer_times():
|
||||
for name, profile in LATENCY_PROFILES.items():
|
||||
period_ms = (profile["period"] / profile["rate"]) * 1000
|
||||
buffer_ms = period_ms * profile["nperiods"]
|
||||
# Standard: 128/48000 * 2 = 5.33ms; Low: 64/48000 * 2 = 2.67ms
|
||||
assert buffer_ms < 10.0, f"{name}: {buffer_ms:.2f} ms >= 10 ms"
|
||||
assert buffer_ms > 0
|
||||
|
||||
|
||||
# ── I2S config entries ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_i2s_configs_have_all_fields():
|
||||
for key, (line, card, desc) in I2S_CONFIGS.items():
|
||||
assert line.startswith("dtoverlay="), f"{key}: bad overlay line"
|
||||
assert isinstance(card, int) and card >= 0, f"{key}: bad card index"
|
||||
assert len(desc) > 10, f"{key}: missing description"
|
||||
|
||||
|
||||
# ── AudioSystem tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_audio_system_init():
|
||||
sys = AudioSystem()
|
||||
assert sys.config.jack_enabled is True
|
||||
assert isinstance(sys.config, AudioConfig)
|
||||
|
||||
|
||||
def test_audio_system_custom_config():
|
||||
cfg = AudioConfig(hat_type="justboom", profile="low")
|
||||
sys = AudioSystem(config=cfg)
|
||||
assert sys.config.hat_type == "justboom"
|
||||
assert sys.config.profile == "low"
|
||||
|
||||
|
||||
def test_audio_system_list_devices_no_alsa():
|
||||
# On a non-RPi / without ALSA tools, list_devices returns []
|
||||
sys = AudioSystem()
|
||||
devices = sys.list_devices()
|
||||
assert isinstance(devices, list)
|
||||
|
||||
|
||||
def test_audio_system_stop_jack_no_crash():
|
||||
"""stop_jack should not crash even if jackd is not running."""
|
||||
sys = AudioSystem()
|
||||
# Should finish without exception
|
||||
sys.stop_jack()
|
||||
|
||||
|
||||
def test_audio_system_read_xrun_count_no_jack():
|
||||
"""read_xrun_count returns None when JACK isn't running."""
|
||||
result = AudioSystem.read_xrun_count()
|
||||
assert result is None or isinstance(result, int)
|
||||
|
||||
|
||||
def test_audio_system_get_active_overlay():
|
||||
"""get_active_overlay should not crash on a non-RPi."""
|
||||
result = AudioSystem.get_active_overlay()
|
||||
# May be None or a string — just shouldn't crash
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
|
||||
def test_audio_system_monitor_xruns_no_jack():
|
||||
"""monitor_xruns should not crash when JACK isn't running."""
|
||||
sys = AudioSystem()
|
||||
result = sys.monitor_xruns(duration=1, interval=1)
|
||||
assert isinstance(result, dict)
|
||||
assert "xrun_total" in result
|
||||
assert "stable" in result
|
||||
|
||||
|
||||
def test_audio_system_measure_roundtrip_latency():
|
||||
"""measure_roundtrip_latency returns None when jack_iodelay is missing."""
|
||||
result = AudioSystem.measure_roundtrip_latency(samples=1, timeout=2)
|
||||
assert result is None # jack_iodelay not installed on dev machine
|
||||
|
||||
|
||||
# ── Systemd service content tests ─────────────────────────────────
|
||||
|
||||
|
||||
def test_systemd_service_content_default():
|
||||
cfg = AudioConfig()
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "[Unit]" in content
|
||||
assert "Description=JACK Audio Server" in content
|
||||
assert "ExecStart=/usr/bin/jackd" in content
|
||||
assert "-P70" in content
|
||||
assert "-p128" in content
|
||||
assert "-n2" in content
|
||||
assert "-r48000" in content
|
||||
assert "LimitRTPRIO=95" in content
|
||||
assert "LimitMEMLOCK=infinity" in content
|
||||
assert "WantedBy=multi-user.target" in content
|
||||
|
||||
|
||||
def test_systemd_service_content_low_latency():
|
||||
cfg = AudioConfig(profile="low")
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "-P80" in content # higher RT priority for low profile
|
||||
assert "-p64" in content # smaller period
|
||||
|
||||
|
||||
def test_systemd_service_content_different_hat():
|
||||
cfg = AudioConfig(hat_type="justboom")
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "-dhw:0" in content # same device default
|
||||
|
||||
|
||||
def test_systemd_service_content_custom_device():
|
||||
cfg = AudioConfig(output_device="hw:2,0")
|
||||
content = AudioSystem.systemd_service_content(cfg)
|
||||
assert "-dhw:2,0" in content
|
||||
|
||||
|
||||
# ── Module exports ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_module_exports():
|
||||
from system import AudioConfig as AC, AudioSystem as AS
|
||||
assert AC is AudioConfig
|
||||
assert AS is AudioSystem
|
||||
@@ -0,0 +1,631 @@
|
||||
"""Unit tests for individual FX blocks in the audio pipeline.
|
||||
|
||||
Each test validates a specific effect against known output shapes
|
||||
(silence clipping, envelope tracking, modulation range, etc.).
|
||||
All tests use 256-sample blocks at 48kHz to match real-time operation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.dsp.pipeline import (
|
||||
AudioPipeline,
|
||||
BLOCK_SIZE,
|
||||
SAMPLE_RATE,
|
||||
_DelayLine,
|
||||
_CombFilter,
|
||||
_AllpassFilter,
|
||||
_compute_lowshelf_coeffs,
|
||||
_compute_highshelf_coeffs,
|
||||
_compute_peaking_coeffs,
|
||||
)
|
||||
from src.presets.types import FXBlock, FXType, Preset
|
||||
|
||||
# ── Fixtures ───────────────────────────────────────────────────────
|
||||
|
||||
SILENCE = np.zeros(BLOCK_SIZE, dtype=np.float32)
|
||||
SINE_TONE = (np.sin(2 * np.pi * 440.0 * np.arange(BLOCK_SIZE) / SAMPLE_RATE)
|
||||
.astype(np.float32))
|
||||
HALF_SCALE = np.full(BLOCK_SIZE, 0.5, dtype=np.float32)
|
||||
FULL_SCALE = np.full(BLOCK_SIZE, 0.99, dtype=np.float32)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline():
|
||||
p = AudioPipeline()
|
||||
yield p
|
||||
|
||||
|
||||
def _load_fx(pipeline: AudioPipeline, fx_type: FXType,
|
||||
params: dict[str, float] | None = None) -> None:
|
||||
"""Quick-apply a single FX block to the pipeline for testing."""
|
||||
block = FXBlock(
|
||||
fx_type=fx_type,
|
||||
enabled=True,
|
||||
bypass=False,
|
||||
params=params or {},
|
||||
)
|
||||
preset = Preset(
|
||||
name="test",
|
||||
chain=[block],
|
||||
master_volume=1.0,
|
||||
)
|
||||
pipeline.load_preset(preset)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 1. Noise Gate
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestNoiseGate:
|
||||
def test_silence_muted(self, pipeline):
|
||||
"""Gate at default threshold (0.01) should silence silence."""
|
||||
_load_fx(pipeline, FXType.NOISE_GATE, {"threshold": 0.01})
|
||||
out = pipeline.process(SILENCE)
|
||||
assert np.max(np.abs(out)) == 0.0, "Silence should be muted"
|
||||
|
||||
def test_strong_signal_passes(self, pipeline):
|
||||
"""Gate should pass full-scale tone."""
|
||||
_load_fx(pipeline, FXType.NOISE_GATE, {"threshold": 0.01})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.max(np.abs(out)) > 0.8, "Strong signal should pass"
|
||||
|
||||
def test_release_tail(self, pipeline):
|
||||
"""Gate should have exponential release, not instant cut."""
|
||||
_load_fx(pipeline, FXType.NOISE_GATE, {"threshold": 0.1, "release": 50.0})
|
||||
# Transition: silence -> tone -> silence
|
||||
out1 = pipeline.process(SILENCE) # below threshold — muted
|
||||
assert np.max(np.abs(out1)) == 0.0, "Initial silence muted"
|
||||
out2 = pipeline.process(SINE_TONE * 0.5) # strong — opens
|
||||
assert np.max(np.abs(out2)) > 0.1, "Gate opened for tone"
|
||||
out3 = pipeline.process(SILENCE) # release tail
|
||||
out4 = pipeline.process(SILENCE) # should fully decay
|
||||
out5 = pipeline.process(SILENCE)
|
||||
# After enough silence blocks, output should be zero
|
||||
assert np.max(np.abs(out5)) < 0.001, "Release should fully decay"
|
||||
|
||||
def test_bypass(self, pipeline):
|
||||
"""Bypassed gate passes audio unchanged."""
|
||||
block = FXBlock(
|
||||
fx_type=FXType.NOISE_GATE, enabled=True,
|
||||
bypass=True, params={"threshold": 1.0},
|
||||
)
|
||||
preset = Preset(name="test", chain=[block], master_volume=1.0)
|
||||
pipeline.load_preset(preset)
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE), "Bypassed gate should passthrough"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 2. Compressor
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCompressor:
|
||||
def test_below_threshold_no_change(self, pipeline):
|
||||
"""Signal below threshold should pass at unity (after makeup)."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": 0.0, "ratio": 4.0, "gain": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.01) # very quiet
|
||||
assert np.max(np.abs(out)) > 0, "Quiet signal passes"
|
||||
|
||||
def test_limits_above_threshold(self, pipeline):
|
||||
"""Signal well above threshold gets compressed."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": -10.0, "ratio": 8.0, "gain": 1.0})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
rms_out = np.sqrt(np.mean(out ** 2))
|
||||
# High ratio should substantially reduce dynamics
|
||||
assert rms_out < 0.8, "Compressor should reduce level above threshold"
|
||||
|
||||
def test_makeup_gain(self, pipeline):
|
||||
"""Makeup gain should be applied after compression."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": -20.0, "ratio": 4.0, "gain": 1.5})
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.max(np.abs(out)) <= 1.0, "Output must be in [-1, 1]"
|
||||
|
||||
def test_output_clipped(self, pipeline):
|
||||
"""Compressor output never exceeds [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.COMPRESSOR,
|
||||
{"threshold": -50.0, "ratio": 2.0, "gain": 20.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 3. Boost / Overdrive / Distortion / Fuzz
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestBoost:
|
||||
def test_linear_gain(self, pipeline):
|
||||
"""Boost applies linear gain without clipping (small signal)."""
|
||||
_load_fx(pipeline, FXType.BOOST, {"gain_db": 6.0})
|
||||
quiet = SINE_TONE * 0.1
|
||||
out = pipeline.process(quiet)
|
||||
expected = np.clip(quiet * 10 ** (6.0 / 20.0), -1.0, 1.0)
|
||||
assert np.allclose(out, expected, atol=1e-6), \
|
||||
"6dB boost should double amplitude"
|
||||
|
||||
def test_clips_at_unity(self, pipeline):
|
||||
"""Boost clips to [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.BOOST, {"gain_db": 60.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||
|
||||
|
||||
class TestOverdrive:
|
||||
def test_asymmetric_clipping(self, pipeline):
|
||||
"""Overdrive clips asymmetrically (tube-like)."""
|
||||
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 0.8, "gain": 1.0})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||
# Should have harmonics — check shape differs from input
|
||||
assert not np.allclose(out, FULL_SCALE, atol=0.05)
|
||||
|
||||
def test_low_drive_passthrough(self, pipeline):
|
||||
"""Low drive should pass nearly clean."""
|
||||
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 0.0, "gain": 1.0})
|
||||
quiet = SINE_TONE * 0.05
|
||||
out = pipeline.process(quiet)
|
||||
assert np.max(np.abs(out)) > 0.0
|
||||
|
||||
|
||||
class TestDistortion:
|
||||
def test_harder_clipping(self, pipeline):
|
||||
"""Distortion clips harder than overdrive."""
|
||||
_load_fx(pipeline, FXType.DISTORTION, {"drive": 0.8, "gain": 1.0})
|
||||
out = pipeline.process(FULL_SCALE)
|
||||
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||
|
||||
def test_distortion_changes_waveform(self, pipeline):
|
||||
"""Distortion should significantly reshape the waveform."""
|
||||
_load_fx(pipeline, FXType.DISTORTION, {"drive": 1.0, "gain": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
# Should have square-ish shape (overtones)
|
||||
assert np.std(out) > 0, "Distortion produces output"
|
||||
|
||||
|
||||
class TestFuzz:
|
||||
def test_hard_clip_shape(self, pipeline):
|
||||
"""Fuzz creates near-square-wave shape."""
|
||||
_load_fx(pipeline, FXType.FUZZ, {"drive": 1.0, "gain": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.8)
|
||||
assert np.max(out) <= 1.0 and np.min(out) >= -1.0
|
||||
# Fuzz should be very clipped
|
||||
rms_out = np.sqrt(np.mean(out ** 2))
|
||||
assert rms_out > 0.2, "Fuzz should produce significant output"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 4. EQ
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEQ:
|
||||
def test_flat_chain(self, pipeline):
|
||||
"""EQ with 0dB on all bands passes signal unchanged."""
|
||||
_load_fx(pipeline, FXType.EQ, {"bass": 0.0, "mid": 0.0, "treble": 0.0})
|
||||
out = pipeline.process(SINE_TONE)
|
||||
assert np.allclose(out, SINE_TONE, atol=1e-4), \
|
||||
"Flat EQ should pass through"
|
||||
|
||||
def test_bass_boost(self, pipeline):
|
||||
"""Bass boost amplifies low frequencies."""
|
||||
_load_fx(pipeline, FXType.EQ,
|
||||
{"bass": 12.0, "mid": 0.0, "treble": 0.0,
|
||||
"bass_freq": 200.0})
|
||||
# Low frequency test tone
|
||||
low_tone = (np.sin(2 * np.pi * 80.0 * np.arange(BLOCK_SIZE) / SAMPLE_RATE)
|
||||
.astype(np.float32))
|
||||
out = pipeline.process(low_tone * 0.3)
|
||||
rms_out = np.sqrt(np.mean(out ** 2))
|
||||
rms_in = np.sqrt(np.mean((low_tone * 0.3) ** 2))
|
||||
assert rms_out > rms_in * 1.5, \
|
||||
"Bass boost should amplify low tones"
|
||||
|
||||
def test_output_range(self, pipeline):
|
||||
"""EQ stays in [-3, 3] before final clip in process()."""
|
||||
_load_fx(pipeline, FXType.EQ,
|
||||
{"bass": 15.0, "mid": 15.0, "treble": 15.0})
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_biquad_coeffs_stable(self):
|
||||
"""Biquad coefficient generators produce stable filters."""
|
||||
coeffs = _compute_lowshelf_coeffs(200, 6.0, 0.707, SAMPLE_RATE)
|
||||
assert all(np.isfinite(c) for c in coeffs)
|
||||
coeffs = _compute_highshelf_coeffs(3500, -6.0, 0.707, SAMPLE_RATE)
|
||||
assert all(np.isfinite(c) for c in coeffs)
|
||||
coeffs = _compute_peaking_coeffs(1000, 6.0, 0.707, SAMPLE_RATE)
|
||||
assert all(np.isfinite(c) for c in coeffs)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 5. Chorus
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestChorus:
|
||||
def test_output_range(self, pipeline):
|
||||
"""Chorus output stays in [-1, 1]."""
|
||||
_load_fx(pipeline, FXType.CHORUS,
|
||||
{"rate": 0.5, "depth": 0.5, "mix": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_dry_only_at_zero_mix(self, pipeline):
|
||||
"""0% mix = pass-through."""
|
||||
_load_fx(pipeline, FXType.CHORUS,
|
||||
{"rate": 0.5, "depth": 0.5, "mix": 0.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||
|
||||
def test_wet_at_full_mix(self, pipeline):
|
||||
"""100% mix = modulated signal only."""
|
||||
_load_fx(pipeline, FXType.CHORUS,
|
||||
{"rate": 0.5, "depth": 0.5, "mix": 1.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
out2 = pipeline.process(SINE_TONE * 0.5)
|
||||
# Chorus should produce varied output (LFO modulation)
|
||||
assert not np.allclose(out, out2, atol=0.01), \
|
||||
"Chorus LFO should produce different samples each block"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 6. Flanger
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestFlanger:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.FLANGER,
|
||||
{"rate": 0.25, "depth": 0.7, "feedback": 0.3, "mix": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_zero_mix_pass(self, pipeline):
|
||||
"""0% mix = pass-through."""
|
||||
_load_fx(pipeline, FXType.FLANGER,
|
||||
{"rate": 0.25, "depth": 0.7, "feedback": 0.3, "mix": 0.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||
|
||||
def test_feedback_accumulates(self, pipeline):
|
||||
"""Flanger with high feedback should produce different output
|
||||
over successive blocks than without feedback."""
|
||||
_load_fx(pipeline, FXType.FLANGER,
|
||||
{"rate": 0.25, "depth": 0.7, "feedback": 0.8, "mix": 1.0})
|
||||
out1 = pipeline.process(SINE_TONE * 0.3)
|
||||
out2 = pipeline.process(SINE_TONE * 0.3)
|
||||
# With high feedback, two identical inputs produce different outputs
|
||||
# due to feedback accumulation
|
||||
assert not np.allclose(out1, out2, atol=0.01), \
|
||||
"High feedback should accumulate in flanger"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 7. Phaser
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPhaser:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.PHASER,
|
||||
{"rate": 0.4, "depth": 0.5, "feedback": 0.3, "mix": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_zero_mix_pass(self, pipeline):
|
||||
_load_fx(pipeline, FXType.PHASER,
|
||||
{"rate": 0.4, "depth": 0.5, "feedback": 0.3, "mix": 0.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||
|
||||
def test_phaser_modulates(self, pipeline):
|
||||
"""Phaser produces different output across successive blocks (LFO sweep)."""
|
||||
_load_fx(pipeline, FXType.PHASER,
|
||||
{"rate": 0.4, "depth": 0.5, "feedback": 0.3, "mix": 1.0})
|
||||
out1 = pipeline.process(SINE_TONE * 0.3)
|
||||
out2 = pipeline.process(SINE_TONE * 0.3)
|
||||
assert not np.allclose(out1, out2, atol=0.01), \
|
||||
"Phaser LFO should modulate across blocks"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 8. Tremolo
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTremolo:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 0.7, "shape": "sine"})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_silence_stays_silent(self, pipeline):
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 1.0, "shape": "sine"})
|
||||
out = pipeline.process(SILENCE)
|
||||
assert np.max(np.abs(out)) == 0.0
|
||||
|
||||
def test_triangular_lfo_shape(self, pipeline):
|
||||
"""Triangle LFO produces different amplitude envelope."""
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 2.0, "depth": 1.0, "shape": "square"})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
# Square wave LFO — should have extreme variation
|
||||
max_val = np.max(out)
|
||||
min_val = np.min(out)
|
||||
assert max_val > 0.9 or min_val < 0.01, \
|
||||
f"Square LFO should produce extremes (max={max_val:.3f}, min={min_val:.3f})"
|
||||
|
||||
def test_zero_depth_no_effect(self, pipeline):
|
||||
"""0% depth = no modulation."""
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 0.0, "shape": "sine"})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=1e-4)
|
||||
|
||||
def test_modulation_changes_each_block(self, pipeline):
|
||||
"""LFO phase advances across blocks."""
|
||||
_load_fx(pipeline, FXType.TREMOLO,
|
||||
{"rate": 4.0, "depth": 1.0, "shape": "sine"})
|
||||
out1 = pipeline.process(HALF_SCALE)
|
||||
out2 = pipeline.process(HALF_SCALE)
|
||||
assert not np.allclose(out1, out2, atol=0.001), \
|
||||
"LFO should produce different modulation each block"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 9. Vibrato
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVibrato:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VIBRATO,
|
||||
{"rate": 3.0, "depth": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_pitch_modulation(self, pipeline):
|
||||
"""Vibrato produces time-varying delay (pitch warble)."""
|
||||
_load_fx(pipeline, FXType.VIBRATO,
|
||||
{"rate": 3.0, "depth": 0.5})
|
||||
out1 = pipeline.process(SINE_TONE * 0.3)
|
||||
out2 = pipeline.process(SINE_TONE * 0.3)
|
||||
# Identical input blocks produce different output due to LFO
|
||||
assert not np.allclose(out1, out2, atol=0.001), \
|
||||
"Vibrato LFO should modulate pitch across blocks"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 10. Delay
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDelay:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"time": 400.0, "feedback": 0.3, "mix": 0.4})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_dry_only_at_zero_mix(self, pipeline):
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"time": 400.0, "feedback": 0.3, "mix": 0.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||
|
||||
def test_no_silence_output_on_silence(self, pipeline):
|
||||
"""Silence in with delay should produce echo decay tail."""
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"time": 50.0, "feedback": 0.5, "mix": 1.0})
|
||||
pipeline.process(SINE_TONE * 0.5) # Fill delay line
|
||||
pipeline.process(SILENCE)
|
||||
out3 = pipeline.process(SILENCE)
|
||||
out4 = pipeline.process(SILENCE)
|
||||
# Should have decaying echo
|
||||
assert np.max(np.abs(out3)) > 0, "Delay tail should still be present"
|
||||
# Echo should decay toward zero
|
||||
assert np.max(np.abs(out4)) <= np.max(np.abs(out3)) + 0.001, \
|
||||
"Echo should decay"
|
||||
|
||||
def test_tap_tempo_callback(self, pipeline):
|
||||
"""Tap tempo overrides time_ms when set."""
|
||||
_load_fx(pipeline, FXType.DELAY,
|
||||
{"time": 400.0, "tap_tempo": 200.0, "feedback": 0.3, "mix": 0.5})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 11. Reverb (Schroeder)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestReverb:
|
||||
def test_output_range(self, pipeline):
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.5, "damping": 0.4, "mix": 0.3})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_zero_mix_passthrough(self, pipeline):
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.5, "damping": 0.4, "mix": 0.0})
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.allclose(out, SINE_TONE * 0.5, atol=1e-4)
|
||||
|
||||
def test_decay_tail(self, pipeline):
|
||||
"""Reverb produces decaying tail after input stops."""
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.8, "damping": 0.4, "mix": 1.0})
|
||||
pipeline.process(FULL_SCALE) # Fill reverb
|
||||
tail1 = pipeline.process(SILENCE)
|
||||
tail2 = pipeline.process(SILENCE)
|
||||
tail3 = pipeline.process(SILENCE)
|
||||
tail4 = pipeline.process(SILENCE)
|
||||
# Should have a decay tail
|
||||
assert np.max(np.abs(tail1)) > 0.001, "Reverb tail should be audible"
|
||||
# Should decay (not necessarily monotonic but trend downward)
|
||||
tail_energy = [np.sqrt(np.mean(t ** 2))
|
||||
for t in [tail1, tail2, tail3, tail4]]
|
||||
assert sum(tail_energy) > 0, "Tail must have energy"
|
||||
|
||||
def test_different_decay_values(self, pipeline):
|
||||
"""Higher decay = longer tail."""
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.9, "damping": 0.2, "mix": 1.0})
|
||||
pipeline.process(FULL_SCALE)
|
||||
high_tail = pipeline.process(SILENCE)
|
||||
|
||||
_load_fx(pipeline, FXType.REVERB,
|
||||
{"decay": 0.3, "damping": 0.2, "mix": 1.0})
|
||||
pipeline.process(FULL_SCALE)
|
||||
low_tail = pipeline.process(SILENCE)
|
||||
|
||||
energy_high = np.sqrt(np.mean(high_tail ** 2))
|
||||
energy_low = np.sqrt(np.mean(low_tail ** 2))
|
||||
|
||||
# Higher decay should generally produce more tail energy
|
||||
# (not assertion — informational only; structural differences matter)
|
||||
assert energy_high >= 0 and energy_low >= 0, \
|
||||
"Both decay values should produce non-negative energy"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 12. Volume
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVolume:
|
||||
def test_linear_scaling(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VOLUME, {"level": 0.5})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE * 0.5, atol=1e-6)
|
||||
|
||||
def test_unity_gain(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VOLUME, {"level": 1.0})
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 13. Integrated chain tests
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestChain:
|
||||
def test_multiple_fx_chain(self, pipeline):
|
||||
"""Chain multiple effects without error."""
|
||||
chain = [
|
||||
FXBlock(FXType.NOISE_GATE, params={"threshold": 0.001}),
|
||||
FXBlock(FXType.COMPRESSOR, params={"threshold": -20.0, "ratio": 4.0}),
|
||||
FXBlock(FXType.OVERDRIVE, params={"drive": 0.4}),
|
||||
FXBlock(FXType.CHORUS, params={"rate": 0.5, "mix": 0.3}),
|
||||
FXBlock(FXType.DELAY, params={"time": 300.0, "mix": 0.3}),
|
||||
FXBlock(FXType.REVERB, params={"decay": 0.4, "mix": 0.2}),
|
||||
FXBlock(FXType.VOLUME, params={"level": 0.8}),
|
||||
]
|
||||
preset = Preset(name="chain_test", chain=chain, master_volume=1.0)
|
||||
pipeline.load_preset(preset)
|
||||
|
||||
out = pipeline.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out)), "Chain should produce finite output"
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0), \
|
||||
"Chain output must be in [-1, 1]"
|
||||
|
||||
def test_all_bypass_passthrough(self, pipeline):
|
||||
"""All blocks bypassed = pass-through."""
|
||||
chain = [
|
||||
FXBlock(FXType.NOISE_GATE, enabled=False),
|
||||
FXBlock(FXType.COMPRESSOR, bypass=True),
|
||||
FXBlock(FXType.OVERDRIVE, bypass=True),
|
||||
FXBlock(FXType.EQ, bypass=True),
|
||||
FXBlock(FXType.DELAY, bypass=True),
|
||||
FXBlock(FXType.REVERB, bypass=True),
|
||||
]
|
||||
preset = Preset(name="bypass_test", chain=chain, master_volume=1.0)
|
||||
pipeline.load_preset(preset)
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=1e-6)
|
||||
|
||||
def test_global_bypass(self, pipeline):
|
||||
"""Global bypass = dry output at master volume."""
|
||||
_load_fx(pipeline, FXType.OVERDRIVE, {"drive": 1.0})
|
||||
pipeline.bypassed = True
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE, atol=1e-6)
|
||||
|
||||
def test_master_volume(self, pipeline):
|
||||
_load_fx(pipeline, FXType.VOLUME, {"level": 1.0})
|
||||
pipeline.master_volume = 0.5
|
||||
out = pipeline.process(HALF_SCALE)
|
||||
assert np.allclose(out, HALF_SCALE * 0.5, atol=1e-6)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 14. DelayLine unit tests
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDelayLine:
|
||||
def test_write_read_identity(self):
|
||||
dl = _DelayLine(1024)
|
||||
block = np.arange(BLOCK_SIZE, dtype=np.float32) * 0.001
|
||||
dl.write_block(block)
|
||||
out = dl.read_block(float(BLOCK_SIZE), BLOCK_SIZE)
|
||||
assert np.allclose(out, block, atol=1e-5), \
|
||||
"Read after write at exact delay should return original"
|
||||
|
||||
def test_fractional_interpolation(self):
|
||||
dl = _DelayLine(128)
|
||||
dl.write_block(np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32))
|
||||
# Read at delay 2.5 — should interpolate between 0 and 0
|
||||
dl.write_block(np.zeros(120, dtype=np.float32))
|
||||
out = dl.read_block(2.5, 1)
|
||||
assert 0.0 <= out[0] <= 0.6, \
|
||||
f"Interpolation at 2.5 should be ~0.5 (±error), got {out[0]:.4f}"
|
||||
|
||||
def test_circular_wraparound(self):
|
||||
dl = _DelayLine(4)
|
||||
dl.write_block(np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32))
|
||||
dl.write_block(np.array([5.0, 6.0, 0.0, 0.0], dtype=np.float32))
|
||||
out = dl.read_block(2.0, 2)
|
||||
# After two writes: buffer = [3, 4, 5, 6], write_idx=0
|
||||
# Read at delay 2: idx = (0 - 2) % 4 = 2 -> buf[2]=5, idx=3-> buf[3]=6
|
||||
assert np.allclose(out, [5.0, 6.0], atol=1e-5), \
|
||||
f"Wraparound read should get [5, 6], got {out}"
|
||||
|
||||
|
||||
class TestCombFilter:
|
||||
def test_output_finite(self):
|
||||
cf = _CombFilter(7)
|
||||
out = cf.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out))
|
||||
assert np.all(np.abs(out) < 10.0) # Should be well-behaved
|
||||
|
||||
|
||||
class TestAllpassFilter:
|
||||
def test_output_finite(self):
|
||||
ap = _AllpassFilter(5)
|
||||
out = ap.process(SINE_TONE * 0.5)
|
||||
assert np.all(np.isfinite(out))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 15. State isolation between blocks
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestStateIsolation:
|
||||
def test_two_reverbs_different(self, pipeline):
|
||||
"""Two reverb blocks in chain should have separate state."""
|
||||
chain = [
|
||||
FXBlock(FXType.REVERB, params={"decay": 0.7, "mix": 0.5}),
|
||||
FXBlock(FXType.REVERB, params={"decay": 0.7, "mix": 0.5}),
|
||||
]
|
||||
preset = Preset(name="dual_reverb", chain=chain, master_volume=1.0)
|
||||
pipeline.load_preset(preset)
|
||||
out = pipeline.process(SINE_TONE * 0.3)
|
||||
assert np.all(np.isfinite(out))
|
||||
assert np.all(out >= -1.0) and np.all(out <= 1.0)
|
||||
|
||||
def test_delay_and_reverb_state_independent(self, pipeline):
|
||||
"""Delay and reverb should not share state."""
|
||||
_load_fx(pipeline, FXType.DELAY, {"time": 100.0, "mix": 0.5})
|
||||
pipeline.process(SINE_TONE * 0.3)
|
||||
out1 = pipeline._state.get("fx_0", {}).get("delay", None)
|
||||
assert out1 is not None, "Delay block should have 'delay' in state"
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Tests for the hardware UI layer — footswitch, LEDs, display.
|
||||
|
||||
Uses mock GPIO/LED/display so tests run on any machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ui.footswitch import (
|
||||
DEBOUNCE_MS,
|
||||
LONG_PRESS_MS,
|
||||
FootSwitch,
|
||||
FootswitchController,
|
||||
SwitchAction,
|
||||
)
|
||||
from src.ui.leds import LEDConfig, LEDController, LEDDriver, LEDPattern
|
||||
from src.ui.display import DisplayController, DisplayState, DISPLAY_W, DISPLAY_H
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Footswitch tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestFootSwitch:
|
||||
"""FootSwitch dataclass construction."""
|
||||
|
||||
def test_basic_switch(self):
|
||||
sw = FootSwitch(17, SwitchAction.PRESET_UP, SwitchAction.TAP_TEMPO)
|
||||
assert sw.gpio_pin == 17
|
||||
assert sw.action_default == SwitchAction.PRESET_UP
|
||||
assert sw.action_long_press == SwitchAction.TAP_TEMPO
|
||||
assert sw.active_low is True
|
||||
|
||||
def test_default_active_low(self):
|
||||
sw = FootSwitch(22, SwitchAction.BYPASS)
|
||||
assert sw.active_low is True
|
||||
|
||||
def test_no_long_press(self):
|
||||
sw = FootSwitch(22, SwitchAction.BYPASS)
|
||||
assert sw.action_long_press is None
|
||||
|
||||
|
||||
class TestFootswitchController:
|
||||
"""FootswitchController — debounce, long-press, callbacks."""
|
||||
|
||||
def test_default_layout_has_4_switches(self):
|
||||
ctrl = FootswitchController()
|
||||
assert len(ctrl._switches) == 4
|
||||
|
||||
def test_register_and_fire_callback(self):
|
||||
ctrl = FootswitchController()
|
||||
fired = []
|
||||
|
||||
def cb():
|
||||
fired.append("bye")
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, cb)
|
||||
ctrl._trigger(SwitchAction.BYPASS)
|
||||
assert fired == ["bye"]
|
||||
|
||||
def test_callback_error_does_not_crash(self):
|
||||
ctrl = FootswitchController()
|
||||
|
||||
def broken():
|
||||
raise ValueError("boom")
|
||||
|
||||
def ok():
|
||||
pass
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, broken)
|
||||
ctrl.register_callback(SwitchAction.BYPASS, ok)
|
||||
ctrl._trigger(SwitchAction.BYPASS) # should not raise
|
||||
|
||||
def test_simulate_press_triggers_callback(self):
|
||||
ctrl = FootswitchController()
|
||||
fired = []
|
||||
|
||||
def cb():
|
||||
fired.append("up")
|
||||
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, cb)
|
||||
ctrl.simulate_press(SwitchAction.PRESET_UP)
|
||||
assert fired == ["up"]
|
||||
|
||||
def test_start_stop_no_crash(self):
|
||||
ctrl = FootswitchController()
|
||||
ctrl.start()
|
||||
assert ctrl._running is True
|
||||
ctrl.stop()
|
||||
assert ctrl._running is False
|
||||
|
||||
def test_virtual_gpio_press_and_release(self):
|
||||
"""Test debounce engine via virtual GPIO pins."""
|
||||
ctrl = FootswitchController()
|
||||
actions = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.append("up"))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
pin = ctrl._switches[0].gpio_pin # pin 17, PRESET_UP
|
||||
|
||||
# Press the pin
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000) # Wait past debounce window
|
||||
|
||||
# Now release
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000)
|
||||
|
||||
# Should have fired PRESET_UP on release (short press)
|
||||
assert "up" in actions, f"Expected 'up' in {actions}"
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_long_press_via_gpio(self):
|
||||
"""Test long-press triggers the long-press action."""
|
||||
ctrl = FootswitchController()
|
||||
actions = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.append("tap"))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
# FS1 has PRESET_UP default, TAP_TEMPO long-press
|
||||
pin = ctrl._switches[0].gpio_pin
|
||||
|
||||
# Press and hold past LONG_PRESS_MS
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep((LONG_PRESS_MS + 50) / 1000)
|
||||
|
||||
# Long press should fire without release
|
||||
assert "tap" in actions, f"Expected 'tap' in {actions}"
|
||||
|
||||
# Release
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000)
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_short_press_no_long_press_action(self):
|
||||
"""Short press triggers default, not long-press."""
|
||||
actions = {"default": False, "long": False}
|
||||
|
||||
ctrl = FootswitchController()
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.update(default=True))
|
||||
ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.update(long=True))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
pin = ctrl._switches[0].gpio_pin
|
||||
|
||||
# Short press
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep(DEBOUNCE_MS / 1000 + 0.01)
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep(DEBOUNCE_MS / 1000 + 0.01)
|
||||
|
||||
# Poll cycle should have fired
|
||||
assert actions["default"] is True, f"Expected default fired, got {actions}"
|
||||
assert actions["long"] is False, f"Expected long NOT fired, got {actions}"
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_multiple_callbacks_same_action(self):
|
||||
ctrl = FootswitchController()
|
||||
results = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(1))
|
||||
ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(2))
|
||||
ctrl._trigger(SwitchAction.BYPASS)
|
||||
assert results == [1, 2]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# LED tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestLEDController:
|
||||
"""LEDController — initialization, pixel control, animations."""
|
||||
|
||||
def test_init_basic(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
assert ctrl._num_leds == 4
|
||||
assert ctrl._global_brightness == 0.5
|
||||
assert ctrl._initialized is False
|
||||
|
||||
def test_initialize_mock_returns_false(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
result = ctrl.initialize()
|
||||
assert result is False
|
||||
assert ctrl._initialized is False
|
||||
|
||||
def test_set_pixel(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_pixel(0, (255, 0, 0))
|
||||
assert ctrl._pixels[0] == (127, 0, 0) # scaled by brightness 0.5
|
||||
|
||||
def test_set_pixel_out_of_range(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_pixel(99, (255, 0, 0)) # should not crash
|
||||
assert ctrl._pixels[0] == (0, 0, 0) # unchanged
|
||||
|
||||
def test_set_all(self):
|
||||
ctrl = LEDController(3, driver=LEDDriver.MOCK)
|
||||
ctrl.set_all((100, 200, 50))
|
||||
for px in ctrl._pixels:
|
||||
assert px[0] == 50 # 100 * 0.5
|
||||
assert px[1] == 100 # 200 * 0.5
|
||||
|
||||
def test_bypass_led_red_when_bypassed(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.set_bypass_led(0, bypassed=True)
|
||||
assert ctrl._pixels[0] == (127, 0, 0) # red
|
||||
|
||||
def test_bypass_led_green_when_active(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.set_bypass_led(0, bypassed=False)
|
||||
assert ctrl._pixels[0] == (0, 127, 0) # green
|
||||
|
||||
def test_clear_all(self):
|
||||
ctrl = LEDController(3, driver=LEDDriver.MOCK)
|
||||
ctrl.set_all((255, 255, 255))
|
||||
ctrl.clear_all()
|
||||
assert all(px == (0, 0, 0) for px in ctrl._pixels)
|
||||
|
||||
def test_set_brightness(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_brightness(0.8)
|
||||
assert ctrl._global_brightness == 0.8
|
||||
|
||||
def test_set_brightness_clamps(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_brightness(2.0)
|
||||
assert ctrl._global_brightness == 1.0
|
||||
ctrl.set_brightness(-0.5)
|
||||
assert ctrl._global_brightness == 0.0
|
||||
|
||||
def test_context_manager(self):
|
||||
with LEDController(2, driver=LEDDriver.MOCK) as ctrl:
|
||||
assert ctrl._num_leds == 2
|
||||
# After exit, pixels should be cleared
|
||||
|
||||
def test_preset_animate_does_not_crash(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.preset_animate("up")
|
||||
ctrl.preset_animate("down")
|
||||
|
||||
def test_tap_tempo_blip_does_not_crash(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.tap_tempo_blip()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Display tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDisplayController:
|
||||
"""DisplayController — initialization, state updates, rendering modes."""
|
||||
|
||||
def test_init(self):
|
||||
dc = DisplayController()
|
||||
assert dc._i2c_bus == 1
|
||||
assert dc._i2c_addr == 0x3C
|
||||
assert dc._initialized is False
|
||||
|
||||
def test_initialize_returns_false_no_hardware(self):
|
||||
dc = DisplayController()
|
||||
result = dc.initialize()
|
||||
assert result is False # No hardware
|
||||
|
||||
def test_update_logs_state_in_headless(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
state = DisplayState(
|
||||
mode="preset",
|
||||
preset_name="Crunch",
|
||||
bypassed=False,
|
||||
fx_active=["overdrive", "delay"],
|
||||
tuner_note="",
|
||||
tuner_cents=0,
|
||||
)
|
||||
dc.update(state)
|
||||
assert "Crunch" in caplog.text
|
||||
assert "overdrive" in caplog.text
|
||||
|
||||
def test_update_tuner_mode(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
state = DisplayState(
|
||||
mode="tuner",
|
||||
tuner_note="A",
|
||||
tuner_cents=-12,
|
||||
)
|
||||
dc.update(state)
|
||||
assert "tuner" in caplog.text.lower()
|
||||
assert "A" in caplog.text
|
||||
assert "-12" in caplog.text
|
||||
|
||||
def test_display_state_defaults(self):
|
||||
state = DisplayState()
|
||||
assert state.mode == "preset"
|
||||
assert state.preset_name == ""
|
||||
assert state.fx_active == []
|
||||
assert state.bypassed is False
|
||||
|
||||
def test_display_state_full(self):
|
||||
state = DisplayState(
|
||||
mode="fx_edit",
|
||||
param_name="Drive",
|
||||
param_value=0.75,
|
||||
)
|
||||
assert state.param_name == "Drive"
|
||||
assert state.param_value == 0.75
|
||||
|
||||
def test_clear_no_crash(self):
|
||||
dc = DisplayController()
|
||||
dc.clear() # Should not crash in headless
|
||||
|
||||
def test_preset_mode_display_str(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
dc.update(DisplayState(preset_name="Clean", bank_name="A1", mode="preset"))
|
||||
assert "Clean" in caplog.text
|
||||
assert "A1" in caplog.text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Integration helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_switch_action_values():
|
||||
"""All SwitchAction values are unique and snake_case."""
|
||||
vals = [a.value for a in SwitchAction]
|
||||
assert len(vals) == len(set(vals)), "Duplicate SwitchAction values"
|
||||
for v in vals:
|
||||
assert "_" in v, f"SwitchAction value '{v}' not snake_case"
|
||||
|
||||
|
||||
def test_led_pattern_values():
|
||||
"""All LEDPattern values are unique."""
|
||||
vals = [p.value for p in LEDPattern]
|
||||
assert len(vals) == len(set(vals))
|
||||
|
||||
|
||||
def test_led_driver_values():
|
||||
"""All LEDDriver values are unique."""
|
||||
vals = [d.value for d in LEDDriver]
|
||||
assert len(vals) == len(set(vals))
|
||||
|
||||
|
||||
def test_display_constants():
|
||||
assert DISPLAY_W == 128
|
||||
assert DISPLAY_H == 64
|
||||
Reference in New Issue
Block a user