Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI

This commit is contained in:
2026-06-07 23:22:43 -04:00
commit ed29748a62
24 changed files with 6215 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
*.so
.nam/
*.nam
*.wav
*.aiff
*.ir
*.wv
config.json
.env
*.swp
.scone/
systemd/
+97
View File
@@ -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.
+439
View File
@@ -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** | 8k192kHz |
| **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** | $3540 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** | 8k192kHz (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: ~$1012
**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** | 8k192kHz |
| **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** | 8k48kHz |
| **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 ~100mV1V (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).
+30
View File
@@ -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
+247
View File
@@ -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"
View File
View File
+130
View File
@@ -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
+97
View File
@@ -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
+824
View File
@@ -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
+39
View File
@@ -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",
]
+916
View File
@@ -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]
View File
+597
View File
@@ -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
+69
View File
@@ -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)
+12
View File
@@ -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",
]
+597
View File
@@ -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
+41
View File
@@ -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",
]
+302
View File
@@ -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()
+251
View File
@@ -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
View File
@@ -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()
+196
View File
@@ -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
+631
View File
@@ -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"
+362
View File
@@ -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